//! The main Document type — entry point for the rdocx API.
use std::borrow::Cow;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt;
use std::ops::Range;
use std::path::Path;
use std::sync::{Arc, Mutex};
#[cfg(test)]
use std::cell::Cell;
use oxml_chart::{CT_ChartSpace, ChartData, ChartKind};
use oxml_core::app_properties::AppProperties;
use oxml_opc::content_types;
use oxml_opc::relationship::rel_types;
use oxml_opc::{OpcPackage, PackageReadLimits, write_atomic_file};
use oxml_sml::Workbook;
use quick_xml::Writer;
use quick_xml::XmlVersion;
use quick_xml::escape::resolve_xml_entity;
use quick_xml::events::{BytesStart, Event};
use quick_xml::name::{Namespace, PrefixDeclaration, ResolveResult};
use quick_xml::reader::NsReader;
use rdocx_oxml::MathProperties;
use rdocx_oxml::content_control::{CT_Sdt, SdtContent, StorySdtOwner};
use rdocx_oxml::document::{
BodyContent, CT_Column, CT_Columns, CT_DocGrid, CT_Document, CT_LineNumber, CT_NoteProperties,
CT_PageBorders, CT_PaperSource, CT_SectPr,
};
use rdocx_oxml::drawing::{
AnchorAlignH, AnchorAlignV, CT_Anchor, CT_Drawing, CT_Inline, ST_RelativeFromH,
ST_RelativeFromV, SourceRect, WrapType, drawing_ns,
};
use rdocx_oxml::font_table::{EmbeddedFontReference, FontFaceKind, FontRecord, FontTable};
use rdocx_oxml::header_footer::{
CT_HdrFtr, HdrFtrRef, HdrFtrType, VmlWatermark, replace_authored_watermark,
};
use rdocx_oxml::namespace::{M_NS, MC_NS, W_NS, matches_local_name};
use rdocx_oxml::numbering::{
CT_AbstractNum, CT_Lvl, CT_Num, CT_NumLvl, CT_Numbering, ST_LvlSuffix, ST_NumberFormat,
};
use rdocx_oxml::properties::{CT_PPr, CT_RPr};
use rdocx_oxml::revision::CT_Revision;
use rdocx_oxml::settings::{
CT_Settings, CharacterSpacingControl, CompatibilityOption, CompatibilitySetting,
DocumentProofState, DocumentProtection, DocumentView, DocumentZoom, MailMerge,
SettingsDiagnostic, ThemeFontLanguage,
};
use rdocx_oxml::shared::{ST_Jc, ST_PageOrientation, ST_SectionType};
use rdocx_oxml::styles::{CT_Styles, StyleType};
use rdocx_oxml::table::{CT_Row, CT_Tbl, CT_Tc, CellContent, ST_VerticalJc, VMerge};
use rdocx_oxml::text::{
CT_P, CT_R, RangeAnchor, RunContent, SpecialCharacter, story_field_instruction_has_name,
story_simple_field_is_typed,
};
use rdocx_oxml::units::Twips;
use rdocx_oxml::web_settings::CT_WebSettings;
use oxml_core::custom_properties::{CustomProperties, CustomProperty};
use rdocx_oxml::core_properties::CoreProperties;
use crate::Length;
use crate::comments::{StoryRunPosition, StoryRunRange};
use crate::content_control::ContentControlRef;
use crate::error::{Error, Result};
use crate::paragraph::{Paragraph, ParagraphRef};
use crate::revision::{RevisionRef, StoryRevision};
use crate::run::{FieldDisplaySegmentRef, RunRef};
use crate::style::{self, Style, StyleBuilder};
use crate::table::{Table, TableRef, row_cell_ranges, validate_table_topology};
/// Options that select a native document render projection.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RenderOptions {
/// The tracked-revision view to render.
pub revision_view: rdocx_layout::RevisionView,
/// How the tracked view marks revisions.
pub revision_style: rdocx_layout::RevisionStyle,
}
/// Which of Word's independent note streams a policy governs.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoteFamily {
Footnote,
Endnote,
}
/// Special content placed before or after a note that continues across pages.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoteSpecialRecord {
Separator,
ContinuationSeparator,
ContinuationNotice,
}
impl NoteSpecialRecord {
fn note_type(self) -> rdocx_oxml::footnotes::NoteType {
use rdocx_oxml::footnotes::NoteType;
match self {
Self::Separator => NoteType::Separator,
Self::ContinuationSeparator => NoteType::ContinuationSeparator,
Self::ContinuationNotice => NoteType::ContinuationNotice,
}
}
}
/// Marker formats the native renderer can reproduce.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoteNumberFormat {
Decimal,
UpperRoman,
LowerRoman,
UpperLetter,
LowerLetter,
}
impl NoteNumberFormat {
fn xml_value(self) -> &'static str {
match self {
Self::Decimal => "decimal",
Self::UpperRoman => "upperRoman",
Self::LowerRoman => "lowerRoman",
Self::UpperLetter => "upperLetter",
Self::LowerLetter => "lowerLetter",
}
}
}
/// Boundary at which numeric note markers start over.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoteRestart {
Continuous,
EachSection,
EachPage,
}
impl NoteRestart {
fn xml_value(self) -> &'static str {
match self {
Self::Continuous => "continuous",
Self::EachSection => "eachSect",
Self::EachPage => "eachPage",
}
}
}
/// Boundary at which note content appears.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NotePlacement {
PageBottom,
BeneathText,
SectionEnd,
DocumentEnd,
}
impl NotePlacement {
fn xml_value(self) -> &'static str {
match self {
Self::PageBottom => "pageBottom",
Self::BeneathText => "beneathText",
Self::SectionEnd => "sectEnd",
Self::DocumentEnd => "docEnd",
}
}
}
/// A complete visible note policy. `start` is one-based.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct NotePolicy {
pub format: NoteNumberFormat,
pub start: u32,
pub restart: NoteRestart,
pub placement: NotePlacement,
}
impl NotePolicy {
fn properties(self, family: NoteFamily) -> Result<CT_NoteProperties> {
if self.start == 0 {
return Err(Error::Other(
"note numbering must start above zero".to_owned(),
));
}
let valid = matches!(
(family, self.placement),
(
NoteFamily::Footnote,
NotePlacement::PageBottom | NotePlacement::BeneathText
) | (
NoteFamily::Endnote,
NotePlacement::SectionEnd | NotePlacement::DocumentEnd
)
);
if !valid {
return Err(Error::Other(
"placement is invalid for this note family".to_owned(),
));
}
Ok(CT_NoteProperties {
pos: Some(self.placement.xml_value().to_owned()),
num_fmt: Some(self.format.xml_value().to_owned()),
num_start: Some(self.start),
num_restart: Some(self.restart.xml_value().to_owned()),
..CT_NoteProperties::default()
})
}
}
/// The package class declared by a Word main document part.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WordPackageClass {
/// A macro-free `.docx` document.
Document,
/// A macro-enabled `.docm` document.
MacroEnabledDocument,
/// A macro-free `.dotx` template.
Template,
/// A macro-enabled `.dotm` template.
MacroEnabledTemplate,
}
/// Completeness and package class for a newly authored Word document.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WordCreationProfile {
/// The compact package graph historically emitted by `rdocx`.
///
/// It has no settings part, so it names no compatibility mode.
Minimal(WordPackageClass),
/// A Word-compatible blank package with the standard owned support parts.
///
/// Its settings name compatibility mode 15, as Word's own new documents do.
WordCompatible(WordPackageClass),
}
/// One font-table record authored through the native facade.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FontDefinition {
pub name: String,
pub alternate_name: Option<String>,
pub family: Option<String>,
pub pitch: Option<String>,
pub embedded_fonts: Vec<EmbeddedFont>,
}
impl FontDefinition {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
alternate_name: None,
family: None,
pitch: None,
embedded_fonts: Vec::new(),
}
}
pub fn with_alternate_name(mut self, name: impl Into<String>) -> Self {
self.alternate_name = Some(name.into());
self
}
pub fn with_family(mut self, family: impl Into<String>) -> Self {
self.family = Some(family.into());
self
}
pub fn with_pitch(mut self, pitch: impl Into<String>) -> Self {
self.pitch = Some(pitch.into());
self
}
}
/// The font-table slot occupied by an embedded font face.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EmbeddedFontKind {
Regular,
Bold,
Italic,
BoldItalic,
}
/// The caller's explicit authorization and exact license identity.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct FontEmbeddingLicense {
pub authorized: bool,
pub identity: String,
}
impl FontEmbeddingLicense {
pub fn new(authorized: bool, identity: impl Into<String>) -> Self {
Self {
authorized,
identity: identity.into(),
}
}
}
/// Caller-owned bytes and metadata for one embedded font face.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct EmbeddedFont {
pub kind: EmbeddedFontKind,
pub data: Vec<u8>,
pub font_key: String,
pub subsetted: bool,
pub license: FontEmbeddingLicense,
}
/// Derive a deterministic uppercase OOXML key from checked single-face font bytes.
pub fn derive_font_key(data: &[u8]) -> Result<String> {
use sha2::{Digest, Sha256};
if !valid_authored_sfnt(data) {
return Err(Error::Other(
"font-key derivation requires a bounded supported sfnt font".to_owned(),
));
}
let digest = Sha256::digest(data);
let hex = digest[..16]
.iter()
.map(|byte| format!("{byte:02X}"))
.collect::<String>();
Ok(format!(
"{{{}-{}-{}-{}-{}}}",
&hex[..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..]
))
}
impl EmbeddedFont {
pub fn new(
kind: EmbeddedFontKind,
data: Vec<u8>,
font_key: impl Into<String>,
license: FontEmbeddingLicense,
) -> Self {
Self {
kind,
data,
font_key: font_key.into(),
subsetted: false,
license,
}
}
pub fn with_subsetted(mut self, subsetted: bool) -> Self {
self.subsetted = subsetted;
self
}
}
impl From<EmbeddedFontKind> for FontFaceKind {
fn from(value: EmbeddedFontKind) -> Self {
match value {
EmbeddedFontKind::Regular => Self::Regular,
EmbeddedFontKind::Bold => Self::Bold,
EmbeddedFontKind::Italic => Self::Italic,
EmbeddedFontKind::BoldItalic => Self::BoldItalic,
}
}
}
impl From<FontFaceKind> for EmbeddedFontKind {
fn from(value: FontFaceKind) -> Self {
match value {
FontFaceKind::Regular => Self::Regular,
FontFaceKind::Bold => Self::Bold,
FontFaceKind::Italic => Self::Italic,
FontFaceKind::BoldItalic => Self::BoldItalic,
}
}
}
impl WordPackageClass {
pub(crate) fn content_type(self) -> &'static str {
match self {
Self::Document => content_types::WORD_DOCUMENT,
Self::MacroEnabledDocument => content_types::WORD_DOCUMENT_MACRO_ENABLED,
Self::Template => content_types::WORD_TEMPLATE,
Self::MacroEnabledTemplate => content_types::WORD_TEMPLATE_MACRO_ENABLED,
}
}
fn from_content_type(content_type: &str) -> Option<Self> {
match content_type {
content_types::WORD_DOCUMENT => Some(Self::Document),
content_types::WORD_DOCUMENT_MACRO_ENABLED => Some(Self::MacroEnabledDocument),
content_types::WORD_TEMPLATE => Some(Self::Template),
content_types::WORD_TEMPLATE_MACRO_ENABLED => Some(Self::MacroEnabledTemplate),
_ => None,
}
}
/// The class that a `.docx`, `.docm`, `.dotx`, or `.dotm` path names.
fn from_path(path: &Path) -> Option<Self> {
match path.extension()?.to_str()?.to_ascii_lowercase().as_str() {
"docx" => Some(Self::Document),
"docm" => Some(Self::MacroEnabledDocument),
"dotx" => Some(Self::Template),
"dotm" => Some(Self::MacroEnabledTemplate),
_ => None,
}
}
fn is_macro_enabled(self) -> bool {
matches!(
self,
Self::MacroEnabledDocument | Self::MacroEnabledTemplate
)
}
}
/// One direct child of a document body, in source order.
pub enum BodyItemRef<'a> {
/// A body paragraph.
Paragraph(ParagraphRef<'a>),
/// A body table.
Table(TableRef<'a>),
/// A body-level content control.
ContentControl(ContentControlRef<'a>),
/// A preserved body child that rdocx does not model.
UnsupportedXml(&'a [u8]),
}
/// A container in the WordprocessingML story graph.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StoryKind {
Body,
TableCell,
Header,
Footer,
Footnote,
Endnote,
Comment,
TextBox,
}
/// Whether a section story is a header or a footer.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum HeaderFooterKind {
Header,
Footer,
}
/// Horizontal frame used to position a floating Word drawing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DrawingHorizontalRelativeFrom {
Page,
Margin,
Column,
Character,
LeftMargin,
RightMargin,
InsideMargin,
OutsideMargin,
}
/// Vertical frame used to position a floating Word drawing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DrawingVerticalRelativeFrom {
Page,
Margin,
Paragraph,
Line,
TopMargin,
BottomMargin,
InsideMargin,
OutsideMargin,
}
/// Horizontal alignment used instead of a floating drawing offset.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DrawingHorizontalAlignment {
Left,
Center,
Right,
Inside,
Outside,
}
/// Vertical alignment used instead of a floating drawing offset.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DrawingVerticalAlignment {
Top,
Center,
Bottom,
Inside,
Outside,
}
/// Text wrapping policy for a floating Word drawing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DrawingWrap {
#[default]
None,
Square,
TopAndBottom,
Tight,
Through,
}
/// Picture crop in DrawingML thousandths of one percent.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct PictureCrop {
pub left: i32,
pub top: i32,
pub right: i32,
pub bottom: i32,
}
/// Floating placement for a picture or text box.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PictureAnchor {
pub horizontal_relative_from: DrawingHorizontalRelativeFrom,
pub horizontal_offset: Length,
pub horizontal_alignment: Option<DrawingHorizontalAlignment>,
pub vertical_relative_from: DrawingVerticalRelativeFrom,
pub vertical_offset: Length,
pub vertical_alignment: Option<DrawingVerticalAlignment>,
pub wrap: DrawingWrap,
pub distance_top: Length,
pub distance_bottom: Length,
pub distance_left: Length,
pub distance_right: Length,
pub relative_height: u32,
pub behind_text: bool,
}
/// Options for one story-scoped picture.
#[derive(Debug, Clone, PartialEq)]
pub struct PictureOptions {
pub width: Length,
pub height: Length,
pub crop: Option<PictureCrop>,
pub anchor: Option<PictureAnchor>,
pub name: Option<String>,
pub description: Option<String>,
pub title: Option<String>,
pub decorative: bool,
}
/// Text flow direction inside an authored Word text box.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TextBoxDirection {
#[default]
Horizontal,
Vertical,
Vertical270,
}
/// Options for one floating Word text box.
#[derive(Debug, Clone, PartialEq)]
pub struct TextBoxOptions {
pub width: Length,
pub height: Length,
pub anchor: PictureAnchor,
pub rotation_degrees: f64,
pub text_direction: TextBoxDirection,
pub fill_color: Option<String>,
}
/// Appearance of one section-variant image watermark.
#[derive(Debug, Clone, PartialEq)]
pub struct ImageWatermarkOptions {
pub width: Length,
pub height: Length,
pub rotation_degrees: f64,
pub opacity: f64,
}
/// Appearance of one section-variant text watermark.
#[derive(Debug, Clone, PartialEq)]
pub struct TextWatermarkOptions {
pub width: Length,
pub height: Length,
pub rotation_degrees: f64,
pub color: String,
pub font_family: Option<String>,
pub opacity: f64,
}
impl Default for TextWatermarkOptions {
fn default() -> Self {
Self {
width: Length::pt(468.0),
height: Length::pt(117.0),
rotation_degrees: 315.0,
color: "D9D9D9".to_owned(),
font_family: Some("Calibri".to_owned()),
opacity: 0.5,
}
}
}
impl From<DrawingHorizontalRelativeFrom> for ST_RelativeFromH {
fn from(value: DrawingHorizontalRelativeFrom) -> Self {
match value {
DrawingHorizontalRelativeFrom::Page => Self::Page,
DrawingHorizontalRelativeFrom::Margin => Self::Margin,
DrawingHorizontalRelativeFrom::Column => Self::Column,
DrawingHorizontalRelativeFrom::Character => Self::Character,
DrawingHorizontalRelativeFrom::LeftMargin => Self::LeftMargin,
DrawingHorizontalRelativeFrom::RightMargin => Self::RightMargin,
DrawingHorizontalRelativeFrom::InsideMargin => Self::InsideMargin,
DrawingHorizontalRelativeFrom::OutsideMargin => Self::OutsideMargin,
}
}
}
impl From<DrawingVerticalRelativeFrom> for ST_RelativeFromV {
fn from(value: DrawingVerticalRelativeFrom) -> Self {
match value {
DrawingVerticalRelativeFrom::Page => Self::Page,
DrawingVerticalRelativeFrom::Margin => Self::Margin,
DrawingVerticalRelativeFrom::Paragraph => Self::Paragraph,
DrawingVerticalRelativeFrom::Line => Self::Line,
DrawingVerticalRelativeFrom::TopMargin => Self::TopMargin,
DrawingVerticalRelativeFrom::BottomMargin => Self::BottomMargin,
DrawingVerticalRelativeFrom::InsideMargin => Self::InsideMargin,
DrawingVerticalRelativeFrom::OutsideMargin => Self::OutsideMargin,
}
}
}
impl From<DrawingHorizontalAlignment> for AnchorAlignH {
fn from(value: DrawingHorizontalAlignment) -> Self {
match value {
DrawingHorizontalAlignment::Left => Self::Left,
DrawingHorizontalAlignment::Center => Self::Center,
DrawingHorizontalAlignment::Right => Self::Right,
DrawingHorizontalAlignment::Inside => Self::Inside,
DrawingHorizontalAlignment::Outside => Self::Outside,
}
}
}
impl From<DrawingVerticalAlignment> for AnchorAlignV {
fn from(value: DrawingVerticalAlignment) -> Self {
match value {
DrawingVerticalAlignment::Top => Self::Top,
DrawingVerticalAlignment::Center => Self::Center,
DrawingVerticalAlignment::Bottom => Self::Bottom,
DrawingVerticalAlignment::Inside => Self::Inside,
DrawingVerticalAlignment::Outside => Self::Outside,
}
}
}
impl From<DrawingWrap> for WrapType {
fn from(value: DrawingWrap) -> Self {
match value {
DrawingWrap::None => Self::None,
DrawingWrap::Square => Self::Square,
DrawingWrap::TopAndBottom => Self::TopAndBottom,
DrawingWrap::Tight => Self::Tight,
DrawingWrap::Through => Self::Through,
}
}
}
// Qualified physical geometry, separate from the legacy typed parser's
// permissive local-name fields. Foreign lookalikes remain opaque.
fn picture_source_extent(
raw: &[u8],
scope: &BTreeMap<String, String>,
anchored: bool,
) -> Result<Option<(i64, i64)>> {
let closed = close_content_fragment_namespaces(raw, scope)?;
let mut reader = NsReader::from_reader(closed.as_slice());
let mut depth = 0usize;
let mut extent = None;
loop {
let (namespace, event) = reader
.read_resolved_event()
.map_err(|error| Error::Other(error.to_string()))?;
let word_drawing = matches!(namespace,ResolveResult::Bound(Namespace(uri)) if uri==drawing_ns::WP.as_bytes());
match event {
Event::Start(ref tag) | Event::Empty(ref tag) => {
let local = tag.local_name();
if depth == 0
&& (!word_drawing
|| local.as_ref() != if anchored { b"anchor" } else { b"inline" })
{
return Ok(None);
}
if depth == 1 && word_drawing && local.as_ref() == b"extent" {
if extent.is_some() {
return Err(Error::Other("picture has duplicate owned extents".into()));
}
let number = |name: &str| -> Result<i64> {
let attr = tag
.try_get_attribute(name)
.map_err(|error| Error::Other(error.to_string()))?
.ok_or_else(|| Error::Other(format!("picture extent has no {name}")))?;
let value = attr
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|error| Error::Other(error.to_string()))?;
value
.parse()
.map_err(|_| Error::Other("picture extent is not an integer".into()))
};
extent = Some((number("cx")?, number("cy")?));
}
if matches!(event, Event::Start(_)) {
depth += 1;
}
}
Event::End(_) => {
depth = depth
.checked_sub(1)
.ok_or_else(|| Error::Other("picture source is malformed".into()))?;
}
Event::Eof => break,
_ => {}
}
}
extent
.map(Some)
.ok_or_else(|| Error::Other("picture has no owned extent".into()))
}
fn picture_drawing(relationship_id: &str, drawing_id: u32, options: &PictureOptions) -> CT_Drawing {
let source_rect = options.crop.map(|crop| SourceRect {
left: crop.left,
top: crop.top,
right: crop.right,
bottom: crop.bottom,
});
match options.anchor {
Some(anchor_options) => {
let mut anchor = CT_Anchor::background(
relationship_id,
options.width.to_emu(),
options.height.to_emu(),
);
apply_picture_anchor(&mut anchor, anchor_options);
anchor.doc_pr_id = drawing_id;
anchor.name = options.name.clone();
anchor.description = options.description.clone();
anchor.title = options.title.clone();
anchor.decorative = options.decorative;
anchor.source_rect = source_rect;
CT_Drawing::anchor(anchor)
}
None => {
let mut inline = CT_Inline::new(
relationship_id,
options.width.to_emu(),
options.height.to_emu(),
);
inline.doc_pr_id = drawing_id;
inline.name = options.name.clone();
inline.description = options.description.clone();
inline.title = options.title.clone();
inline.decorative = options.decorative;
inline.source_rect = source_rect;
CT_Drawing::inline(inline)
}
}
}
fn validate_picture_options(options: &PictureOptions) -> Result<()> {
if options.width.to_emu() <= 0 || options.height.to_emu() <= 0 {
return Err(Error::Other(
"picture width and height must be positive".to_owned(),
));
}
for value in [&options.name, &options.description, &options.title]
.into_iter()
.flatten()
{
oxml_core::xml::reject_non_xml_characters("picture metadata", value)?;
}
if let Some(crop) = options.crop {
for (name, value) in [
("left", crop.left),
("top", crop.top),
("right", crop.right),
("bottom", crop.bottom),
] {
if !(0..100_000).contains(&value) {
return Err(Error::Other(format!(
"picture crop {name} must be between 0 and 99999"
)));
}
}
if crop.left + crop.right >= 100_000 || crop.top + crop.bottom >= 100_000 {
return Err(Error::Other(
"opposite picture crop values must leave a visible source rectangle".to_owned(),
));
}
}
if let Some(anchor) = options.anchor {
validate_picture_anchor(anchor)?;
}
Ok(())
}
fn validate_picture_anchor(anchor: PictureAnchor) -> Result<()> {
for (name, value) in [
("top", anchor.distance_top),
("bottom", anchor.distance_bottom),
("left", anchor.distance_left),
("right", anchor.distance_right),
] {
if value.to_emu() < 0 {
return Err(Error::Other(format!(
"drawing {name} wrap distance must be nonnegative"
)));
}
}
Ok(())
}
fn validate_text_box_options(options: &TextBoxOptions) -> Result<()> {
if options.width.to_emu() <= 0 || options.height.to_emu() <= 0 {
return Err(Error::Other(
"text box width and height must be positive".to_owned(),
));
}
drawingml_rotation(options.rotation_degrees)?;
if let Some(color) = options.fill_color.as_deref()
&& !is_rgb_hex(color)
{
return Err(Error::Other(
"text box fill color must be six hexadecimal digits".to_owned(),
));
}
validate_picture_anchor(options.anchor)
}
fn drawingml_rotation(rotation_degrees: f64) -> Result<i32> {
if !rotation_degrees.is_finite() {
return Err(Error::Other("text box rotation must be finite".to_owned()));
}
let rotation = rotation_degrees * 60_000.0;
if rotation < f64::from(i32::MIN) || rotation > f64::from(i32::MAX) {
return Err(Error::Other(
"text box rotation exceeds the DrawingML angle range".to_owned(),
));
}
Ok(rotation.round() as i32)
}
fn validate_text_watermark_options(options: &TextWatermarkOptions) -> Result<()> {
if options.width.to_emu() <= 0 || options.height.to_emu() <= 0 {
return Err(Error::Other(
"watermark width and height must be positive".to_owned(),
));
}
if !options.rotation_degrees.is_finite() {
return Err(Error::Other("watermark rotation must be finite".to_owned()));
}
if !options.opacity.is_finite() || !(0.0..=1.0).contains(&options.opacity) {
return Err(Error::Other(
"watermark opacity must be between zero and one".to_owned(),
));
}
if !is_rgb_hex(&options.color) {
return Err(Error::Other(
"watermark color must be six hexadecimal digits".to_owned(),
));
}
Ok(())
}
fn is_rgb_hex(value: &str) -> bool {
value.len() == 6 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn apply_picture_anchor(anchor: &mut CT_Anchor, options: PictureAnchor) {
anchor.behind_doc = options.behind_text;
anchor.pos_h_relative_from = options.horizontal_relative_from.into();
anchor.pos_h_offset = rdocx_oxml::units::Emu(options.horizontal_offset.to_emu());
anchor.pos_h_align = options.horizontal_alignment.map(Into::into);
anchor.pos_v_relative_from = options.vertical_relative_from.into();
anchor.pos_v_offset = rdocx_oxml::units::Emu(options.vertical_offset.to_emu());
anchor.pos_v_align = options.vertical_alignment.map(Into::into);
anchor.wrap = options.wrap.into();
anchor.dist_t = rdocx_oxml::units::Emu(options.distance_top.to_emu());
anchor.dist_b = rdocx_oxml::units::Emu(options.distance_bottom.to_emu());
anchor.dist_l = rdocx_oxml::units::Emu(options.distance_left.to_emu());
anchor.dist_r = rdocx_oxml::units::Emu(options.distance_right.to_emu());
anchor.relative_height = options.relative_height;
}
// Locate only the unchanged API-owned alternate representation. Raw unrelated
// header content and any edits outside this exact owner remain outside the splice.
fn drawingml_watermark_span(xml: &[u8]) -> Result<Option<(Range<usize>, u32)>> {
let mut reader = quick_xml::NsReader::from_reader(xml);
let mut depth = 0usize;
struct WatermarkOwner {
start: usize,
depth: usize,
marker: Option<(u32, String)>,
}
let mut alternate: Option<WatermarkOwner> = None;
let mut selected = None;
loop {
let start = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event()
.map_err(|error| Error::Other(format!("watermark owner scan failed: {error}")))?;
match event {
Event::Start(ref element) | Event::Empty(ref element) => {
let local = element.local_name();
let namespace = match namespace {
quick_xml::name::ResolveResult::Bound(namespace) => namespace.as_ref().to_vec(),
_ => Vec::new(),
};
if namespace == b"http://schemas.openxmlformats.org/markup-compatibility/2006"
&& local.as_ref() == b"AlternateContent"
&& alternate.is_none()
{
alternate = Some(WatermarkOwner {
start,
depth,
marker: None,
});
}
if namespace == b"http://schemas.openxmlformats.org/wordprocessingml/2006/main"
&& local.as_ref() == b"drawing"
&& alternate.is_none()
{
alternate = Some(WatermarkOwner {
start,
depth,
marker: None,
});
}
if namespace
== b"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"
&& local.as_ref() == b"docPr"
{
let mut name = None;
let mut id = None;
let mut description = None;
for attribute in element.attributes() {
let attribute = attribute.map_err(rdocx_oxml::OxmlError::from)?;
let value = attribute
.decoded_and_normalized_value(
quick_xml::XmlVersion::Implicit1_0,
element.decoder(),
)
.map_err(rdocx_oxml::OxmlError::from)?
.into_owned();
match attribute.key.as_ref() {
b"name" => name = Some(value),
b"id" => id = value.parse::<u32>().ok(),
b"descr" => description = Some(value),
_ => {}
}
}
if name.as_deref() == Some("RDOCX DrawingML Watermark") {
let owner = alternate.as_mut().ok_or_else(|| {
Error::Other("watermark identity is outside its alternate owner".into())
})?;
if owner.marker.is_some() {
return Err(Error::Other(
"duplicate watermark identity is unsupported".into(),
));
}
owner.marker = Some((
id.ok_or_else(|| {
Error::Other("watermark drawing identity is invalid".into())
})?,
description.ok_or_else(|| {
Error::Other("watermark source qualification is missing".into())
})?,
));
}
}
if matches!(event, Event::Start(_)) {
depth += 1;
}
}
Event::End(_) => {
depth = depth
.checked_sub(1)
.ok_or_else(|| Error::Other("watermark owner depth is invalid".into()))?;
if alternate.as_ref().is_some_and(|owner| owner.depth == depth) {
let owner = alternate.take().unwrap();
let start = owner.start;
if let Some((id, description)) = owner.marker {
if selected.is_some() {
return Err(Error::Other(
"multiple API watermark owners are unsupported".into(),
));
}
let end = reader.buffer_position() as usize;
let text = std::str::from_utf8(&xml[start..end])
.map_err(|error| Error::Other(error.to_string()))?;
let attribute = format!(" descr=\"{description}\"");
let checksum = description
.strip_prefix("rdocx-watermark-text-v1:")
.or_else(|| description.strip_prefix("rdocx-watermark-image-v1:"))
.and_then(|value| value.parse::<u64>().ok())
.ok_or_else(|| {
Error::Other("watermark source qualification is invalid".into())
})?;
if text.matches(&attribute).count() != 1
|| structural_fingerprint(text.replacen(&attribute, "", 1).as_bytes())?
!= checksum
{
return Err(Error::Other("API watermark has opaque or externally changed source, replacement refused".into()));
}
selected = Some((start..end, id));
}
}
}
Event::Eof => break,
_ => {}
}
}
Ok(selected)
}
fn authored_text_box_alternate_content(
drawing_id: u32,
text: &str,
options: &TextBoxOptions,
) -> Result<Vec<u8>> {
let horizontal = match options.anchor.horizontal_alignment {
Some(alignment) => format!(
"<wp:align>{}</wp:align>",
AnchorAlignH::from(alignment).to_str()
),
None => format!(
"<wp:posOffset>{}</wp:posOffset>",
options.anchor.horizontal_offset.to_emu()
),
};
let vertical = match options.anchor.vertical_alignment {
Some(alignment) => format!(
"<wp:align>{}</wp:align>",
AnchorAlignV::from(alignment).to_str()
),
None => format!(
"<wp:posOffset>{}</wp:posOffset>",
options.anchor.vertical_offset.to_emu()
),
};
let wrap = match options.anchor.wrap {
DrawingWrap::None => "<wp:wrapNone/>".to_owned(),
DrawingWrap::Square => "<wp:wrapSquare wrapText=\"bothSides\"/>".to_owned(),
DrawingWrap::TopAndBottom => "<wp:wrapTopAndBottom/>".to_owned(),
DrawingWrap::Tight => {
"<wp:wrapTight wrapText=\"bothSides\"><wp:wrapPolygon edited=\"0\"><wp:start x=\"0\" y=\"0\"/><wp:lineTo x=\"0\" y=\"21600\"/><wp:lineTo x=\"21600\" y=\"21600\"/><wp:lineTo x=\"21600\" y=\"0\"/><wp:lineTo x=\"0\" y=\"0\"/></wp:wrapPolygon></wp:wrapTight>".to_owned()
}
DrawingWrap::Through => {
"<wp:wrapThrough wrapText=\"bothSides\"><wp:wrapPolygon edited=\"0\"><wp:start x=\"0\" y=\"0\"/><wp:lineTo x=\"0\" y=\"21600\"/><wp:lineTo x=\"21600\" y=\"21600\"/><wp:lineTo x=\"21600\" y=\"0\"/><wp:lineTo x=\"0\" y=\"0\"/></wp:wrapPolygon></wp:wrapThrough>".to_owned()
}
};
let direction = match options.text_direction {
TextBoxDirection::Horizontal => "horz",
TextBoxDirection::Vertical => "vert",
TextBoxDirection::Vertical270 => "vert270",
};
let vml_direction = match options.text_direction {
TextBoxDirection::Horizontal => "layout-flow:horizontal",
TextBoxDirection::Vertical => "layout-flow:vertical;mso-layout-flow-alt:top-to-bottom",
TextBoxDirection::Vertical270 => "layout-flow:vertical;mso-layout-flow-alt:bottom-to-top",
};
let fill = options.fill_color.as_deref().map_or_else(
|| "<a:noFill/>".to_owned(),
|color| format!("<a:solidFill><a:srgbClr val=\"{color}\"/></a:solidFill>"),
);
let rotation = drawingml_rotation(options.rotation_degrees)?;
let escaped = quick_xml::escape::escape(text);
let width = options.width.to_emu();
let height = options.height.to_emu();
let anchor = options.anchor;
let xml = format!(
concat!(
"<mc:AlternateContent xmlns:mc=\"http://schemas.openxmlformats.org/markup-compatibility/2006\" ",
"xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" ",
"xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" ",
"xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" ",
"xmlns:wps=\"http://schemas.microsoft.com/office/word/2010/wordprocessingShape\" ",
"xmlns:v=\"urn:schemas-microsoft-com:vml\" ",
"xmlns:o=\"urn:schemas-microsoft-com:office:office\"><mc:Choice Requires=\"wps\"><w:drawing>",
"<wp:anchor behindDoc=\"{behind}\" simplePos=\"0\" relativeHeight=\"{relative_height}\" ",
"distT=\"{dist_t}\" distB=\"{dist_b}\" distL=\"{dist_l}\" distR=\"{dist_r}\" ",
"locked=\"0\" layoutInCell=\"1\" allowOverlap=\"1\"><wp:simplePos x=\"0\" y=\"0\"/>",
"<wp:positionH relativeFrom=\"{relative_h}\">{horizontal}</wp:positionH>",
"<wp:positionV relativeFrom=\"{relative_v}\">{vertical}</wp:positionV>",
"<wp:extent cx=\"{width}\" cy=\"{height}\"/>{wrap}<wp:docPr id=\"{drawing_id}\" name=\"Text Box {drawing_id}\"/>",
"<a:graphic><a:graphicData uri=\"http://schemas.microsoft.com/office/word/2010/wordprocessingShape\">",
"<wps:wsp><wps:cNvSpPr txBox=\"1\"/><wps:spPr><a:xfrm rot=\"{rotation}\"><a:off x=\"0\" y=\"0\"/>",
"<a:ext cx=\"{width}\" cy=\"{height}\"/></a:xfrm><a:prstGeom prst=\"rect\"><a:avLst/></a:prstGeom>{fill}</wps:spPr>",
"<wps:txbx><w:txbxContent><w:p><w:r><w:t xml:space=\"preserve\">{escaped}</w:t></w:r></w:p></w:txbxContent></wps:txbx>",
"<wps:bodyPr vert=\"{direction}\"/></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice>",
"<mc:Fallback><w:pict><v:shapetype id=\"rdocx-textbox-type-{drawing_id}\" coordsize=\"21600,21600\" ",
"o:spt=\"202\" path=\"m,l,21600r21600,l21600,xe\"><v:stroke joinstyle=\"miter\"/>",
"<v:path gradientshapeok=\"t\" o:connecttype=\"rect\"/></v:shapetype>",
"<v:shape id=\"rdocx-textbox-{drawing_id}\" type=\"#rdocx-textbox-type-{drawing_id}\" ",
"style=\"position:absolute;width:{width_pt}pt;height:{height_pt}pt;rotation:{rotation_degrees}\" ",
"fillcolor=\"#{vml_fill}\"><v:textbox style=\"{vml_direction}\"><w:txbxContent><w:p><w:r>",
"<w:t xml:space=\"preserve\">{escaped}</w:t></w:r></w:p>",
"</w:txbxContent></v:textbox></v:shape></w:pict></mc:Fallback></mc:AlternateContent>"
),
behind = u8::from(anchor.behind_text),
horizontal = horizontal,
vertical = vertical,
width = width,
height = height,
wrap = wrap,
drawing_id = drawing_id,
rotation = rotation,
fill = fill,
direction = direction,
vml_direction = vml_direction,
escaped = escaped,
relative_height = anchor.relative_height,
dist_t = anchor.distance_top.to_emu(),
dist_b = anchor.distance_bottom.to_emu(),
dist_l = anchor.distance_left.to_emu(),
dist_r = anchor.distance_right.to_emu(),
relative_h = ST_RelativeFromH::from(anchor.horizontal_relative_from).to_str(),
relative_v = ST_RelativeFromV::from(anchor.vertical_relative_from).to_str(),
width_pt = options.width.to_pt(),
height_pt = options.height.to_pt(),
rotation_degrees = options.rotation_degrees,
vml_fill = options.fill_color.as_deref().unwrap_or("FFFFFF"),
);
Ok(xml.into_bytes())
}
impl HeaderFooterKind {
fn story_kind(self) -> StoryKind {
match self {
Self::Header => StoryKind::Header,
Self::Footer => StoryKind::Footer,
}
}
fn relationship_type(self) -> &'static str {
match self {
Self::Header => rel_types::HEADER,
Self::Footer => rel_types::FOOTER,
}
}
fn content_type(self) -> &'static str {
match self {
Self::Header => {
"application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml"
}
Self::Footer => {
"application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml"
}
}
}
fn part_stem(self) -> &'static str {
match self {
Self::Header => "header",
Self::Footer => "footer",
}
}
fn is_header(self) -> bool {
self == Self::Header
}
}
/// The effective header or footer story selected for one section variant.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SectionStory {
story: StoryId,
source_section: usize,
inherited: bool,
}
impl SectionStory {
/// Return the physical story owner.
pub fn story(&self) -> &StoryId {
&self.story
}
/// Return the section that supplies the effective reference.
pub fn source_section(&self) -> usize {
self.source_section
}
/// Return whether the selected section inherits this story.
pub fn is_inherited(&self) -> bool {
self.inherited
}
}
/// The supported kind of one item in a story.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum StoryItemKind {
Paragraph,
Table,
ContentControl,
Field,
Drawing,
PreservedNode,
}
/// Stable owner identity for one operation over an opened document.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct StoryId {
kind: StoryKind,
part_name: String,
owner_index: usize,
fingerprint: u64,
}
impl StoryId {
/// Construct an unfingerprinted owner identity for checked lookup.
///
/// Identities returned by [`Document::stories`] are required for a
/// successful mutation. This constructor lets callers deserialize or
/// validate external paths without granting unchecked access.
pub fn new(kind: StoryKind, part_name: impl Into<String>, owner_index: usize) -> Self {
Self {
kind,
part_name: part_name.into(),
owner_index,
fingerprint: 0,
}
}
/// Construct the conventional main-body identity for checked lookup.
///
/// Prefer an identity returned by [`Document::stories`]. This constructor
/// is useful when validating externally retained locations and deliberately
/// carries no structural fingerprint.
pub fn body() -> Self {
Self::new(StoryKind::Body, "/word/document.xml", 0)
}
pub fn kind(&self) -> StoryKind {
self.kind
}
pub fn part_name(&self) -> &str {
&self.part_name
}
pub fn owner_index(&self) -> usize {
self.owner_index
}
}
/// A checked path to one item inside a story owner.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ContentLocation {
story: StoryId,
item_kind: StoryItemKind,
index_path: Vec<usize>,
is_end: bool,
}
/// Frozen facts for one admitted physical picture in a checked paragraph.
/// Relationship IDs identify media, not drawing occurrences.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PictureOccurrence {
pub location: ContentLocation,
pub ordinal: usize,
pub relationship_id: String,
/// Internal media identity, independent of relationship spelling and bytes.
pub media_part_name: Option<String>,
/// External image source is metadata only and is never fetched.
pub external_url: Option<String>,
pub width: i64,
pub height: i64,
pub name: Option<String>,
pub description: Option<String>,
pub title: Option<String>,
pub decorative: bool,
pub anchored: bool,
pub content_type: String,
pub bytes: Vec<u8>,
source: Vec<u8>,
}
/// Owned direct story content that can be inserted without a second document tree.
#[derive(Debug, Clone)]
pub struct ContentFragment {
kind: StoryItemKind,
xml: Vec<u8>,
source_part_name: Option<String>,
source_story_kind: Option<StoryKind>,
source_owner_index: Option<usize>,
namespace_scope: BTreeMap<String, String>,
}
/// Caller-width layout measurement for one supported story item.
#[derive(Debug, Clone, PartialEq)]
pub struct ContentMeasurement {
/// Total occupied height in points, including paragraph spacing or table rows.
pub height_points: f64,
/// Ordered approximation and fallback diagnostics from production layout.
pub diagnostics: Vec<oxml_layout::Diagnostic>,
}
/// The pair of a [`Document::try_replace_all_expected`] batch whose count
/// differed from the count its caller expected.
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("pair {index}: expected {expected} replacement(s) of \"{placeholder}\", found {found}")]
pub struct ReplacementCountMismatch {
/// Zero-based position of the pair in the batch.
pub index: usize,
/// The literal text the pair searched for.
pub placeholder: String,
/// The count the caller expected.
pub expected: usize,
/// The count the pair found once the pairs before it had run.
pub found: usize,
}
enum ScopedReplacementTarget<'a> {
Item(&'a ContentLocation),
RelatedCell(&'a StoryId),
Cell {
coordinates: (usize, usize, usize),
paragraph: Option<usize>,
},
}
/// An owned story range and the package dependencies it can reach.
#[derive(Debug, Clone)]
pub struct DocumentFragment {
package: Vec<u8>,
include_final_section_properties: bool,
}
/// Conflict choices for one cross-document fragment import.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FragmentConflictPolicy {
reuse_equivalent_styles: bool,
reuse_equivalent_numbering: bool,
reuse_equivalent_related_parts: bool,
}
impl FragmentConflictPolicy {
/// Reuse equivalent supported dependencies and rename non-equivalent ones.
pub const fn reuse_equivalent() -> Self {
Self {
reuse_equivalent_styles: true,
reuse_equivalent_numbering: true,
reuse_equivalent_related_parts: true,
}
}
/// Allocate a fresh destination identity for every supported dependency.
pub const fn rename_all() -> Self {
Self {
reuse_equivalent_styles: false,
reuse_equivalent_numbering: false,
reuse_equivalent_related_parts: false,
}
}
/// Select whether equivalent style definitions are reused.
pub const fn with_style_reuse(mut self, reuse: bool) -> Self {
self.reuse_equivalent_styles = reuse;
self
}
/// Select whether equivalent numbering definitions are reused.
pub const fn with_numbering_reuse(mut self, reuse: bool) -> Self {
self.reuse_equivalent_numbering = reuse;
self
}
/// Select whether equivalent relationship-reached leaf parts are reused.
pub const fn with_related_part_reuse(mut self, reuse: bool) -> Self {
self.reuse_equivalent_related_parts = reuse;
self
}
pub(crate) const fn reuse_styles(self) -> bool {
self.reuse_equivalent_styles
}
pub(crate) const fn reuse_numbering(self) -> bool {
self.reuse_equivalent_numbering
}
pub(crate) const fn reuse_related_parts(self) -> bool {
self.reuse_equivalent_related_parts
}
}
impl Default for FragmentConflictPolicy {
fn default() -> Self {
Self::reuse_equivalent()
}
}
impl DocumentFragment {
pub(crate) fn from_part_content(
document: &Document,
source_part: &str,
selected: Vec<u8>,
) -> Result<Self> {
validate_fragment_block_content(&selected)?;
let mut candidate = document.clone_for_staging();
candidate.prepare_staged_package()?;
candidate.project_fragment_part_content(source_part, selected)?;
let mut output = std::io::Cursor::new(Vec::new());
candidate.package.write_to(&mut output)?;
Ok(Self {
package: output.into_inner(),
include_final_section_properties: false,
})
}
pub(crate) fn import_content_staged(
&self,
candidate: &mut Document,
destination_part: &str,
policy: FragmentConflictPolicy,
) -> Result<Vec<u8>> {
let xml = crate::field::import_document_fragment_content(
candidate,
&self.package,
self.include_final_section_properties,
policy,
destination_part,
)?;
package_authoritative_body_fragment(
&xml,
self.include_final_section_properties,
&BTreeMap::new(),
)
}
pub(crate) fn contains_section_properties(&self) -> bool {
self.include_final_section_properties
}
/// Capture one non-empty, half-open range of direct story items.
///
/// `start` is included and `end` is excluded. Section properties can be
/// included only when `end` is the main body's final boundary.
pub fn from_range(
document: &Document,
start: &ContentLocation,
end: &ContentLocation,
include_final_section_properties: bool,
) -> Result<Self> {
let mut candidate = document.clone_for_staging();
candidate.prepare_staged_package()?;
if start.story != end.story {
return Err(Error::Other(
"document fragment ranges must share one checked story owner".to_owned(),
));
}
let (source, owner) = candidate.fragment_story_source_and_owner(&start.story)?;
let source_xml = source.xml.into_owned();
let (owner, _, start_index, item_count) =
fragment_content_boundary(&source_xml, &owner, start)?;
let (_, checked_owner) = candidate.fragment_story_source_and_owner(&end.story)?;
let (end_owner, _, end_index, _) =
fragment_content_boundary(&source_xml, &checked_owner, end)?;
if owner.full != end_owner.full {
return Err(Error::Other(
"document fragment range splits block ownership".to_owned(),
));
}
if start_index >= end_index {
return Err(Error::Other(
"document fragment range must contain at least one direct body item".to_owned(),
));
}
if include_final_section_properties
&& (start.story.kind != StoryKind::Body
|| start.index_path.len() != 1
|| end_index != item_count)
{
return Err(Error::Other(
"section-inclusive document fragments must end at the main-body boundary"
.to_owned(),
));
}
let items = direct_story_content_items(&source_xml, &owner)?;
let content_start = items
.first()
.map(|item| item.full.start)
.ok_or_else(|| Error::Other("document fragment source body is empty".to_owned()))?;
for item in &items[start_index..end_index] {
let scope = story_namespace_scope_at(&source_xml, item.full.start)?;
let xml = close_content_fragment_namespaces(&source_xml[item.full.clone()], &scope)?;
validate_fragment_block_item(&xml)?;
}
let content_end = story_owner_content_end(&source_xml, &owner)?;
let selected_start = items[start_index].full.start;
let selected_end = if end_index == item_count {
content_end
} else {
items[end_index].full.start
};
if start.story.kind == StoryKind::Body
&& start.story.part_name == candidate.doc_part_name
&& start.index_path.len() == 1
&& (end.is_end || end.index_path.len() == 1)
{
let selected = source_xml[selected_start..selected_end].to_vec();
let mut updated = source_xml;
updated.splice(content_start..content_end, selected);
candidate
.package
.set_part(&candidate.doc_part_name, updated);
} else {
let mut selected = Vec::new();
for item in &items[start_index..end_index] {
let scope = story_namespace_scope_at(&source_xml, item.full.start)?;
selected.extend(close_content_fragment_namespaces(
&source_xml[item.full.clone()],
&scope,
)?);
}
candidate.project_fragment_part_content(&source.part_name, selected)?;
}
let mut output = std::io::Cursor::new(Vec::new());
candidate.package.write_to(&mut output)?;
Ok(Self {
package: output.into_inner(),
include_final_section_properties,
})
}
}
impl ContentFragment {
/// Create a fixed-prefix paragraph fragment.
pub fn paragraph(paragraph: CT_P) -> Result<Self> {
Self::from_body_content(BodyContent::Paragraph(paragraph))
}
/// Create a fixed-prefix table fragment.
pub fn table(table: CT_Tbl) -> Result<Self> {
Self::from_body_content(BodyContent::Table(table))
}
/// Create a fixed-prefix block content-control fragment.
pub fn content_control(control: CT_Sdt) -> Result<Self> {
Self::from_body_content(BodyContent::ContentControl(control))
}
/// Return the direct story-item kind carried by this fragment.
pub fn kind(&self) -> StoryItemKind {
self.kind
}
fn from_body_content(content: BodyContent) -> Result<Self> {
let kind = match &content {
BodyContent::Paragraph(_) => StoryItemKind::Paragraph,
BodyContent::Table(_) => StoryItemKind::Table,
BodyContent::ContentControl(_) => StoryItemKind::ContentControl,
BodyContent::RawXml(_) => StoryItemKind::PreservedNode,
};
let xml = serialize_content_fragment(content)?;
if kind == StoryItemKind::ContentControl {
validate_serialized_block_content_control(&xml)?;
}
Ok(Self {
kind,
xml,
source_part_name: None,
source_story_kind: None,
source_owner_index: None,
namespace_scope: BTreeMap::new(),
})
}
}
impl ContentLocation {
pub fn new(story: StoryId, item_kind: StoryItemKind, index_path: Vec<usize>) -> Self {
Self {
story,
item_kind,
index_path,
is_end: false,
}
}
/// Construct the insertion boundary after a story's final direct content.
///
/// For the document body, this boundary remains before section properties.
pub fn end(story: StoryId) -> Self {
Self {
story,
item_kind: StoryItemKind::PreservedNode,
index_path: Vec::new(),
is_end: true,
}
}
pub fn story(&self) -> &StoryId {
&self.story
}
pub fn item_kind(&self) -> StoryItemKind {
self.item_kind
}
pub fn index_path(&self) -> &[usize] {
&self.index_path
}
}
/// A borrowed view of one supported story item.
///
/// The view owns its checked location. Body and comment items can use owned XML
/// serialized from their typed sources. Other package-backed items can borrow
/// exact subtree slices whose namespace bindings depend on retained ancestors.
/// A complex field uses an owned, namespace-complete paragraph projection
/// because its source can span several sibling runs.
pub struct StoryItemRef<'a> {
document: &'a Document,
location: ContentLocation,
}
/// One exact owner slice used for generated-table discovery and cache edits.
pub(crate) struct GeneratedStoryOwner {
pub(crate) story: StoryId,
pub(crate) range: std::ops::Range<usize>,
pub(crate) xml: Vec<u8>,
pub(crate) namespaces: BTreeMap<String, String>,
}
/// One owned story-item projection materialized from a single story inventory.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoryItemSnapshot {
location: ContentLocation,
direct_body_index: Option<usize>,
direct_child: bool,
text: Option<String>,
xml: Vec<u8>,
}
impl StoryItemSnapshot {
/// Return the checked story location captured by this snapshot.
pub fn location(&self) -> &ContentLocation {
&self.location
}
/// Return the containing direct main-body child when this item has one.
pub fn direct_body_index(&self) -> Option<usize> {
self.direct_body_index
}
/// Return whether this item is a direct child of its story owner.
///
/// An item nested in another item of the same owner, such as an inline
/// content control, a field, or a drawing inside a paragraph, returns
/// `false`. The direct items of one owner never overlap, so their text
/// reads each part of the owner once.
pub fn is_direct_child(&self) -> bool {
self.direct_child
}
/// Return accepted-view text when the item is text bearing.
pub fn text(&self) -> Option<&str> {
self.text.as_deref()
}
/// Return the exact or namespace-complete XML captured for this item.
pub fn xml(&self) -> &[u8] {
&self.xml
}
}
impl<'a> StoryItemRef<'a> {
pub fn location(&self) -> &ContentLocation {
&self.location
}
pub fn kind(&self) -> StoryItemKind {
self.location.item_kind
}
/// Return the containing direct main-body child when this item has one.
///
/// The recursive story-item path remains unchanged. This value is the
/// coordinate accepted by direct-body APIs such as `RunPosition`.
pub fn direct_body_index(&self) -> Result<Option<usize>> {
if self.location.story.kind != StoryKind::Body {
return Ok(None);
}
let (source, owner) = self.document.story_source_and_owner(&self.location.story)?;
let [index] = self.location.index_path.as_slice() else {
return Err(StoryError::InvalidPath {
path: self.location.index_path.clone(),
}
.into());
};
let items = scan_story_items(source.xml.as_ref(), &owner)?;
let item = items.get(*index).ok_or(StoryError::OutOfBounds {
index: *index,
len: items.len(),
})?;
Ok(direct_story_content_items(source.xml.as_ref(), &owner)?
.iter()
.position(|candidate| {
candidate.full.start <= item.full.start && item.full.end <= candidate.full.end
}))
}
pub fn text(&self) -> Result<Option<String>> {
let (source, item) = self.document.story_item_source(&self.location)?;
story_item_text(source.xml.as_ref(), &item, &[], 0..source.xml.len(), 0)
}
/// Return modeled hyperlinks in source order with story-scoped targets.
pub fn links(&self) -> Result<Vec<LinkInfo>> {
let (source, item) = self.document.story_item_source(&self.location)?;
let item_scopes = story_namespace_scopes_at(source.xml.as_ref(), [item.scan.start])?;
let item_scope = item_scopes.get(&item.scan.start).ok_or_else(|| {
Error::Other("story item namespace scope was not inventoried".to_owned())
})?;
let links = scan_story_item_links_with_scope(source.xml.as_ref(), &item, item_scope)?;
let link_scopes = story_namespace_scopes_at(
source.xml.as_ref(),
links.iter().map(|link| link.full.start),
)?;
links
.into_iter()
.map(|link| {
let scope = link_scopes.get(&link.full.start).ok_or_else(|| {
Error::Other("story hyperlink namespace scope was not inventoried".to_owned())
})?;
self.document.story_link_info(
&self.location.story,
source.xml.as_ref(),
link,
scope,
)
})
.collect()
}
/// Returns the XML used by this traversal.
///
/// Typed body and comment sources can return owned subtree bytes. Other
/// package-backed sources can return a borrowed exact slice, which can rely
/// on namespace declarations from ancestors outside that slice. Complex
/// fields return an owned, standalone paragraph with the in-scope namespace
/// declarations materialized.
pub fn xml(&self) -> Result<Cow<'a, [u8]>> {
let (source, item) = self.document.story_item_source(&self.location)?;
if item.complex_field {
return Ok(Cow::Owned(complex_story_field_xml(
source.xml.as_ref(),
&item,
)?));
}
match source.xml {
Cow::Borrowed(xml) => Ok(Cow::Borrowed(&xml[item.full])),
Cow::Owned(xml) => Ok(Cow::Owned(xml[item.full].to_vec())),
}
}
}
/// A location resolution failure from the container-neutral story API.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum StoryError {
OwnerNotFound {
story: StoryId,
},
WrongOwner {
expected: StoryKind,
actual: StoryKind,
},
Stale {
story: StoryId,
},
InvalidPath {
path: Vec<usize>,
},
OutOfBounds {
index: usize,
len: usize,
},
KindMismatch {
expected: StoryItemKind,
actual: StoryItemKind,
},
NotTextBearing {
kind: StoryItemKind,
},
}
impl fmt::Display for StoryError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OwnerNotFound { story } => write!(
formatter,
"story owner {:?} at {}[{}] was not found",
story.kind, story.part_name, story.owner_index
),
Self::WrongOwner { expected, actual } => {
write!(
formatter,
"story owner kind is {actual:?}, expected {expected:?}"
)
}
Self::Stale { story } => write!(
formatter,
"story location for {:?} at {}[{}] is stale",
story.kind, story.part_name, story.owner_index
),
Self::InvalidPath { path } => write!(formatter, "invalid story item path {path:?}"),
Self::OutOfBounds { index, len } => {
write!(
formatter,
"story item index {index} is out of bounds for {len} items"
)
}
Self::KindMismatch { expected, actual } => write!(
formatter,
"story item kind is {actual:?}, expected {expected:?}"
),
Self::NotTextBearing { kind } => {
write!(formatter, "story item kind {kind:?} has no editable text")
}
}
}
}
impl std::error::Error for StoryError {}
const WORD_NAMESPACE: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
const XML_NAMESPACE: &str = "http://www.w3.org/XML/1998/namespace";
enum UnsupportedXmlSource<'a> {
Raw { raw: &'a [u8] },
Modeled,
}
/// A borrowed fact for content the compatibility reader does not model.
pub struct UnsupportedXmlRef<'a> {
source: UnsupportedXmlSource<'a>,
qualified_name: Option<Cow<'a, str>>,
local_name: Cow<'a, str>,
namespace_uri: Option<Cow<'a, str>>,
has_child_content: bool,
}
impl<'a> UnsupportedXmlRef<'a> {
/// Inspect preserved reader-item bytes using the same unsupported XML
/// projection as body compatibility items.
pub fn from_bytes(raw: &'a [u8]) -> Self {
Self::raw(raw, &[])
}
fn raw(raw: &'a [u8], inherited_namespaces: &'a [(String, String)]) -> Self {
let names = raw_element_names(raw);
let namespace_uri = names.as_ref().and_then(|(name, _)| {
let prefix = name.split_once(':').map_or("", |(prefix, _)| prefix);
raw_namespace_declaration(raw, prefix)
.map(Cow::Owned)
.or_else(|| inherited_namespace(inherited_namespaces, prefix).map(Cow::Borrowed))
});
let (qualified_name, local_name) = names.map_or_else(
|| (None, Cow::Borrowed("")),
|(qualified_name, local_name)| {
(Some(Cow::Owned(qualified_name)), Cow::Owned(local_name))
},
);
Self {
source: UnsupportedXmlSource::Raw { raw },
qualified_name,
local_name,
namespace_uri,
has_child_content: raw_has_child_content(raw),
}
}
fn modeled(
qualified_name: &'static str,
namespace_uri: &'static str,
local_name: &'static str,
has_child_content: bool,
) -> Self {
Self {
source: UnsupportedXmlSource::Modeled,
qualified_name: Some(Cow::Borrowed(qualified_name)),
local_name: Cow::Borrowed(local_name),
namespace_uri: Some(Cow::Borrowed(namespace_uri)),
has_child_content,
}
}
/// Original subtree bytes, or `None` for a modeled compatibility fact.
pub fn raw_xml(&self) -> Option<&'a [u8]> {
match self.source {
UnsupportedXmlSource::Raw { raw } => Some(raw),
UnsupportedXmlSource::Modeled => None,
}
}
/// Qualified element name, when the retained XML has a valid root element.
pub fn qualified_name(&self) -> Option<&str> {
self.qualified_name.as_deref()
}
/// Local element name.
pub fn local_name(&self) -> &str {
&self.local_name
}
/// Namespace URI resolved from local or inherited declarations.
pub fn namespace_uri(&self) -> Option<&str> {
self.namespace_uri.as_deref()
}
/// Whether the retained element has a child element or visible text.
pub fn has_child_content(&self) -> bool {
self.has_child_content
}
}
fn raw_element_names(raw: &[u8]) -> Option<(String, String)> {
let mut reader = quick_xml::Reader::from_reader(raw);
reader.config_mut().trim_text(false);
let mut buffer = Vec::new();
loop {
match reader.read_event_into(&mut buffer).ok()? {
Event::Start(element) | Event::Empty(element) => {
let qualified_name = std::str::from_utf8(element.name().as_ref())
.ok()?
.to_owned();
let local_name = std::str::from_utf8(element.local_name().as_ref())
.ok()?
.to_owned();
return Some((qualified_name, local_name));
}
Event::Eof => return None,
_ => buffer.clear(),
}
}
}
fn raw_namespace_declaration(raw: &[u8], prefix: &str) -> Option<String> {
let mut reader = quick_xml::Reader::from_reader(raw);
let mut buffer = Vec::new();
let declaration = if prefix.is_empty() {
"xmlns".to_owned()
} else {
format!("xmlns:{prefix}")
};
loop {
match reader.read_event_into(&mut buffer).ok()? {
Event::Start(element) | Event::Empty(element) => {
let attribute = element
.attributes()
.flatten()
.find(|attribute| attribute.key.as_ref() == declaration.as_bytes())?;
return attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, element.decoder())
.ok()
.map(Cow::into_owned);
}
Event::Eof => return None,
_ => buffer.clear(),
}
}
}
fn inherited_namespace<'a>(namespaces: &'a [(String, String)], prefix: &str) -> Option<&'a str> {
if prefix == "xml" {
return Some(XML_NAMESPACE);
}
let declaration = if prefix.is_empty() {
"xmlns".to_owned()
} else {
format!("xmlns:{prefix}")
};
namespaces
.iter()
.rev()
.find(|(name, _)| name == &declaration)
.map(|(_, uri)| uri.as_str())
}
fn raw_has_child_content(raw: &[u8]) -> bool {
let mut reader = quick_xml::Reader::from_reader(raw);
reader.config_mut().trim_text(false);
let mut buffer = Vec::new();
let mut depth = 0usize;
loop {
match reader.read_event_into(&mut buffer) {
Ok(Event::Start(_)) => {
if depth > 0 {
return true;
}
depth += 1;
}
Ok(Event::Empty(_)) if depth > 0 => return true,
Ok(Event::Text(text)) if depth > 0 && !xml_text_is_whitespace(&text) => {
return true;
}
Ok(Event::CData(text)) if depth > 0 && !xml_bytes_are_whitespace(text.as_ref()) => {
return true;
}
Ok(Event::GeneralRef(reference)) if depth > 0 => match reference.resolve_char_ref() {
Ok(Some(character)) if !character.is_ascii_whitespace() => return true,
Ok(Some(_)) => {}
Ok(None) => return true,
Err(_) => return false,
},
Ok(Event::End(_)) => depth = depth.saturating_sub(1),
Ok(Event::Eof) | Err(_) => return false,
_ => {}
}
buffer.clear();
}
}
fn xml_text_is_whitespace(text: &quick_xml::events::BytesText<'_>) -> bool {
xml_bytes_are_whitespace(text.as_ref())
}
fn xml_bytes_are_whitespace(bytes: &[u8]) -> bool {
bytes.iter().all(|byte| byte.is_ascii_whitespace())
}
fn typed_numbering_leaf_has_unmodeled(raw: &[u8], word_prefixes: &[String]) -> bool {
if raw_has_child_content(raw) {
return true;
}
let mut reader = quick_xml::Reader::from_reader(raw);
let mut buffer = Vec::new();
loop {
match reader.read_event_into(&mut buffer) {
Ok(Event::Start(element)) | Ok(Event::Empty(element)) => {
let mut value_count = 0usize;
for attribute in element.attributes() {
let Ok(attribute) = attribute else {
return true;
};
let name = attribute.key.as_ref();
if name == b"xmlns" || name.starts_with(b"xmlns:") {
continue;
}
let Some(separator) = name.iter().position(|byte| *byte == b':') else {
return true;
};
let prefix = &name[..separator];
let local = &name[separator + 1..];
if local != b"val"
|| !word_prefixes
.iter()
.any(|candidate| candidate.as_bytes() == prefix)
{
return true;
}
value_count += 1;
}
return value_count != 1;
}
Ok(Event::Eof) | Err(_) => return true,
_ => buffer.clear(),
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ParagraphVisibilityScope {
FacadeRecursive,
DirectBody,
}
type ParagraphVisibilityPath = Vec<(&'static [u8], usize)>;
type ParagraphVisibilityOwners<'a> = Vec<(ParagraphVisibilityPath, &'a CT_P)>;
// Pair typed owners with their structural encounter slots. Raw children and
// formatting are not owner slots. Tables directly in body or cells stay opaque.
fn paragraph_visibility_owners(
document: &Document,
scope: ParagraphVisibilityScope,
) -> ParagraphVisibilityOwners<'_> {
fn control<'a>(
sdt: &'a CT_Sdt,
owner: &'static [u8],
path: &[(&'static [u8], usize)],
out: &mut ParagraphVisibilityOwners<'a>,
) {
let mut slot = 0;
for child in &sdt.content {
let name: &'static [u8] = match child {
SdtContent::Paragraph(_) if matches!(owner, b"body" | b"tc") => b"p",
SdtContent::Table(_) if matches!(owner, b"body" | b"tc") => b"tbl",
SdtContent::Row(_) if owner == b"tbl" => b"tr",
SdtContent::Cell(_) if owner == b"tr" => b"tc",
SdtContent::ContentControl(_) => b"sdt",
_ => continue,
};
let mut next = path.to_vec();
next.push((name, slot));
slot += 1;
match child {
SdtContent::Paragraph(p) => out.push((next, p)),
SdtContent::Table(t) => table(t, &next, out),
SdtContent::Row(r) => row(r, &next, out),
SdtContent::Cell(c) => cell(c, &next, out),
SdtContent::ContentControl(c) => control(c, owner, &next, out),
_ => {}
}
}
}
fn table<'a>(
t: &'a CT_Tbl,
path: &[(&'static [u8], usize)],
out: &mut ParagraphVisibilityOwners<'a>,
) {
let mut slot = 0;
for index in 0..=t.rows.len() {
for (_, _, c) in t.content_controls.iter().filter(|(at, _, _)| *at == index) {
let mut next = path.to_vec();
next.push((b"sdt", slot));
slot += 1;
control(c, b"tbl", &next, out);
}
if let Some(r) = t.rows.get(index) {
let mut next = path.to_vec();
next.push((b"tr", slot));
slot += 1;
row(r, &next, out);
}
}
}
fn row<'a>(
r: &'a CT_Row,
path: &[(&'static [u8], usize)],
out: &mut ParagraphVisibilityOwners<'a>,
) {
let mut slot = 0;
for index in 0..=r.cells.len() {
for (_, _, c) in r.content_controls.iter().filter(|(at, _, _)| *at == index) {
let mut next = path.to_vec();
next.push((b"sdt", slot));
slot += 1;
control(c, b"tr", &next, out);
}
if let Some(c) = r.cells.get(index) {
let mut next = path.to_vec();
next.push((b"tc", slot));
slot += 1;
cell(c, &next, out);
}
}
}
fn cell<'a>(
c: &'a CT_Tc,
path: &[(&'static [u8], usize)],
out: &mut ParagraphVisibilityOwners<'a>,
) {
for (slot, child) in c.content.iter().enumerate() {
let name: &'static [u8] = match child {
CellContent::Paragraph(_) => b"p",
CellContent::Table(_) => b"tbl",
CellContent::ContentControl(_) => b"sdt",
};
let mut next = path.to_vec();
next.push((name, slot));
match child {
CellContent::Paragraph(p) => out.push((next, p)),
CellContent::ContentControl(c) => control(c, b"tc", &next, out),
CellContent::Table(_) => {}
}
}
}
let mut out = Vec::new();
let mut slot = 0;
for child in &document.document.body.content {
let name: &'static [u8] = match child {
BodyContent::Paragraph(_) => b"p",
BodyContent::Table(_) => b"tbl",
BodyContent::ContentControl(_) => b"sdt",
BodyContent::RawXml(_) => continue,
};
let path = vec![(name, slot)];
slot += 1;
match child {
BodyContent::Paragraph(p) => out.push((path, p)),
BodyContent::ContentControl(c)
if scope == ParagraphVisibilityScope::FacadeRecursive =>
{
control(c, b"body", &path, &mut out)
}
_ => {}
}
}
out
}
fn paragraph_has_typed_visible_content(paragraph: &CT_P) -> bool {
let mut pending = paragraph.accepted_bookmark_runs();
while let Some(run) = pending.pop() {
if !run.alt_drawings.is_empty() {
return true;
}
for content in &run.content {
match content {
RunContent::Text(text) if !text.text.trim().is_empty() => return true,
RunContent::Tab
| RunContent::Break(_)
| RunContent::Symbol { .. }
| RunContent::Drawing(_)
| RunContent::FootnoteRef { .. }
| RunContent::EndnoteRef { .. } => return true,
RunContent::SpecialCharacter(character)
if !matches!(character, SpecialCharacter::SoftHyphen) =>
{
return true;
}
RunContent::Field(field) => {
if let Some(runs) = field.cached_result_runs() {
// A consistent owned result may contain only deleted text.
// Its public string cache remains a text extraction projection.
pending.extend(runs);
} else if !field.cached_result.trim().is_empty() {
return true;
}
}
_ => {}
}
}
}
false
}
/// Match the facade's body paragraph inventory, including nested block controls,
/// and mark visible nontext children without entering direct body tables or opaque wrappers.
fn paragraph_visibility_from_main_source(
document: &Document,
xml: &[u8],
scope: ParagraphVisibilityScope,
) -> Result<Vec<bool>> {
let owners = paragraph_visibility_owners(document, scope);
let mut owner_paths = Vec::new();
let mut slot_stack: Vec<(ParagraphVisibilityPath, usize)> = Vec::new();
let mut reader = NsReader::from_reader(xml);
// Qualified Word name, admitted path, paragraph boundary, typed content slot.
let mut open: Vec<(Vec<u8>, bool, bool, bool)> = Vec::new();
let mut in_paragraph = false;
let mut visible = Vec::new();
let mut buffer = Vec::new();
loop {
let event_start = usize::try_from(reader.buffer_position())
.map_err(|error| Error::Other(error.to_string()))?;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(rdocx_oxml::OxmlError::from)?;
match event {
Event::Start(ref element) | Event::Empty(ref element) => {
let local = element.local_name();
let word = matches!(namespace, ResolveResult::Bound(Namespace(uri)) if uri == W_NS.as_bytes());
let math = matches!(namespace, ResolveResult::Bound(Namespace(uri)) if uri == M_NS.as_bytes());
let compatibility = matches!(namespace, ResolveResult::Bound(Namespace(uri)) if uri == MC_NS.as_bytes());
let parent = open.last().map(|(name, _, _, _)| name.as_slice());
let admitted_parent = open.last().is_some_and(|(_, admitted, _, _)| *admitted);
// A control inherits its physical container's admitted child kinds.
let container = if parent == Some(b"sdtContent") {
open.iter().rev().find_map(|(name, admitted, _, _)| {
(*admitted && matches!(name.as_slice(), b"body" | b"tbl" | b"tr" | b"tc"))
.then_some(name.as_slice())
})
} else {
parent
};
let typed_control = if word
&& local.as_ref() == b"sdt"
&& admitted_parent
&& matches!(event, Event::Start(_))
{
let prefixes = reader.resolver().bindings().try_fold(
Vec::new(),
|mut scope, (declaration, namespace)| {
let prefix = match declaration {
PrefixDeclaration::Default => "",
PrefixDeclaration::Named(prefix) => std::str::from_utf8(prefix)
.map_err(rdocx_oxml::OxmlError::from)?,
};
// Resolver bindings retain raw namespace attribute bytes.
// The SDT scope carrier and serializer use semantic URIs.
let namespace = quick_xml::events::attributes::Attribute {
key: quick_xml::name::QName(b"xmlns"),
value: Cow::Borrowed(namespace.as_ref()),
}
.decoded_and_normalized_value(
XmlVersion::Implicit1_0,
element.decoder(),
)
.map_err(rdocx_oxml::OxmlError::from)?;
scope.push(format!("\0{prefix}\0{namespace}"));
if namespace == W_NS {
scope.push(prefix.to_owned());
}
Ok::<_, Error>(scope)
},
)?;
let owner = if in_paragraph {
StorySdtOwner::Inline
} else {
match container {
Some(b"tbl") => StorySdtOwner::Table,
Some(b"tr") => StorySdtOwner::Row,
_ => StorySdtOwner::Block,
}
};
let mut probe = reader.clone();
probe
.read_to_end(element.name())
.map_err(rdocx_oxml::OxmlError::from)?;
let end = usize::try_from(probe.buffer_position())
.map_err(|error| Error::Other(error.to_string()))?;
let source = xml.get(event_start..end).ok_or_else(|| {
Error::Other("content-control source span is invalid".to_owned())
})?;
CT_Sdt::story_raw_is_typed(source, &prefixes, owner)
} else {
false
};
let first_content = word
&& local.as_ref() == b"sdtContent"
&& admitted_parent
&& parent == Some(b"sdt")
&& matches!(event, Event::Start(_))
&& open.last().is_some_and(|(_, _, _, seen)| !seen);
let mut paragraph = false;
let admitted = if in_paragraph {
if admitted_parent
&& ((word
&& parent == Some(b"r")
&& matches!(
local.as_ref(),
b"drawing"
| b"pict"
| b"object"
| b"sym"
| b"tab"
| b"br"
| b"cr"
| b"footnoteReference"
| b"endnoteReference"
))
|| (math && matches!(local.as_ref(), b"oMath" | b"oMathPara")))
&& let Some(flag) = visible.last_mut()
{
*flag = true;
}
admitted_parent
&& ((word
&& matches!(
local.as_ref(),
b"t" | b"r"
| b"hyperlink"
| b"ins"
| b"moveTo"
| b"smartTag"
| b"customXml"
| b"sdt"
| b"sdtContent"
| b"dir"
| b"bdo"
)
&& match local.as_ref() {
b"sdt" => typed_control,
b"sdtContent" => first_content,
_ => true,
})
|| (compatibility
&& matches!(
local.as_ref(),
b"AlternateContent" | b"Choice" | b"Fallback"
)))
} else if word {
match local.as_ref() {
b"document" => open.is_empty(),
b"body" => admitted_parent && parent == Some(b"document"),
b"sdt" => {
scope == ParagraphVisibilityScope::FacadeRecursive
&& typed_control
&& matches!(container, Some(b"body" | b"tbl" | b"tr" | b"tc"))
}
b"sdtContent" => first_content,
b"tbl" => {
admitted_parent
&& parent == Some(b"sdtContent")
&& matches!(container, Some(b"body" | b"tc"))
}
b"tr" => admitted_parent && container == Some(b"tbl"),
b"tc" => admitted_parent && container == Some(b"tr"),
b"p" if admitted_parent && matches!(container, Some(b"body" | b"tc")) => {
visible.push(false);
paragraph = true;
if matches!(event, Event::Start(_)) {
in_paragraph = true;
}
true
}
_ => false,
}
} else {
false
};
let structural: Option<&'static [u8]> = if !in_paragraph || paragraph {
match local.as_ref() {
b"p" if paragraph => Some(b"p"),
b"sdt" if word && typed_control && admitted_parent => Some(b"sdt"),
b"tbl"
if word
&& admitted_parent
&& matches!(container, Some(b"body" | b"tc")) =>
{
Some(b"tbl")
}
b"tr" if word && admitted_parent && container == Some(b"tbl") => {
Some(b"tr")
}
b"tc" if word && admitted_parent && container == Some(b"tr") => Some(b"tc"),
_ => None,
}
} else {
None
};
let mut path = slot_stack
.last()
.map(|(path, _)| path.clone())
.unwrap_or_default();
if let Some(name) = structural {
if let Some((_, slot)) = slot_stack.last_mut() {
path.push((name, *slot));
*slot += 1;
}
if paragraph {
owner_paths.push(path.clone());
}
}
if matches!(event, Event::Start(_)) {
// sdtContent shares its control's child scope. Other structural
// containers own a fresh set of direct structural slots.
let slot = if first_content {
slot_stack.last().map_or(0, |(_, slot)| *slot)
} else {
0
};
slot_stack.push((path, slot));
}
if first_content && let Some((_, _, _, seen)) = open.last_mut() {
*seen = true;
}
if matches!(event, Event::Start(_)) {
open.push((
if word {
local.as_ref().to_vec()
} else {
Vec::new()
},
admitted,
paragraph,
false,
));
}
}
Event::End(_) => {
slot_stack.pop();
if open.pop().is_some_and(|(_, _, paragraph, _)| paragraph) {
in_paragraph = false;
}
}
Event::Text(text)
if open
.last()
.is_some_and(|(name, admitted, _, _)| *admitted && name == b"t") =>
{
let text = text
.decode()
.map_err(|error| Error::Other(error.to_string()))?;
let text = quick_xml::escape::unescape(&text)
.map_err(|error| Error::Other(error.to_string()))?;
if !text.trim().is_empty()
&& let Some(flag) = visible.last_mut()
{
*flag = true;
}
}
Event::CData(text)
if open
.last()
.is_some_and(|(name, admitted, _, _)| *admitted && name == b"t") =>
{
let text = text
.decode()
.map_err(|error| Error::Other(error.to_string()))?;
if !text.trim().is_empty()
&& let Some(flag) = visible.last_mut()
{
*flag = true;
}
}
Event::GeneralRef(reference)
if open
.last()
.is_some_and(|(name, admitted, _, _)| *admitted && name == b"t") =>
{
let name = reference
.decode()
.map_err(|error| Error::Other(error.to_string()))?;
let encoded = format!("&{name};");
let text = quick_xml::escape::unescape(&encoded)
.map_err(|error| Error::Other(error.to_string()))?;
if !text.trim().is_empty()
&& let Some(flag) = visible.last_mut()
{
*flag = true;
}
}
Event::Eof if !open.is_empty() => {
return Err(Error::Other(
"main document ends inside an element".to_owned(),
));
}
Event::Eof => {
if owner_paths.len() != owners.len()
|| owner_paths
.iter()
.zip(&owners)
.any(|(source, (model, _))| source != model)
{
return Err(Error::Other(
"main document paragraph owners do not match validation scope".to_owned(),
));
}
return Ok(owners
.iter()
.zip(visible)
.map(|((_, p), nontext)| paragraph_has_typed_visible_content(p) || nontext)
.collect());
}
_ => {}
}
buffer.clear();
}
}
#[derive(Default)]
struct DocumentNamespaceScopes {
root_declarations: Vec<(String, String)>,
body_declarations: Vec<(String, String)>,
body_bindings: Vec<(String, String)>,
}
fn document_namespace_scopes(xml: &[u8]) -> Result<DocumentNamespaceScopes> {
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut buffer = Vec::new();
let mut scopes: Vec<Vec<(String, String)>> = Vec::new();
let mut root_declarations = Vec::new();
loop {
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(rdocx_oxml::OxmlError::from)?;
match event {
Event::Start(ref element) => {
let mut scope = scopes.last().cloned().unwrap_or_default();
let declarations = namespace_declarations(element)?;
apply_namespace_declarations(element, &mut scope)?;
if scopes.is_empty() {
root_declarations = declarations.clone();
}
if matches!(namespace, ResolveResult::Bound(Namespace(uri)) if uri == WORD_NAMESPACE.as_bytes())
&& matches_local_name(element.name().as_ref(), b"body")
{
return Ok(DocumentNamespaceScopes {
root_declarations,
body_declarations: declarations,
body_bindings: scope,
});
}
scopes.push(scope);
}
Event::Empty(ref element) => {
let mut scope = scopes.last().cloned().unwrap_or_default();
let declarations = namespace_declarations(element)?;
apply_namespace_declarations(element, &mut scope)?;
if scopes.is_empty() {
root_declarations = declarations.clone();
}
if matches!(namespace, ResolveResult::Bound(Namespace(uri)) if uri == WORD_NAMESPACE.as_bytes())
&& matches_local_name(element.name().as_ref(), b"body")
{
return Ok(DocumentNamespaceScopes {
root_declarations,
body_declarations: declarations,
body_bindings: scope,
});
}
}
Event::End(_) => {
scopes.pop();
}
Event::Eof => {
return Ok(DocumentNamespaceScopes {
root_declarations,
..DocumentNamespaceScopes::default()
});
}
_ => {}
}
buffer.clear();
}
}
fn namespace_declarations(element: &BytesStart<'_>) -> Result<Vec<(String, String)>> {
let mut declarations = Vec::new();
apply_namespace_declarations(element, &mut declarations)?;
Ok(declarations)
}
/// The declarations on `element` that change a binding `inherited` already
/// holds.
///
/// A declaration that rebinds a prefix to the URI already in scope resolves no
/// name differently from the scope it sits in, so the element owns no
/// namespace and canonical serialization cannot lose one by dropping it. The
/// retained root-attribute record materializes exactly such a redundant
/// binding onto every paragraph and run that keeps a prefixed producer
/// attribute, and treating those as owners makes identical runs
/// indistinguishable to the retained-owner matcher.
fn rebinding_namespace_declarations(
element: &BytesStart<'_>,
inherited: &[(String, String)],
) -> Result<Vec<(String, String)>> {
let mut declarations = namespace_declarations(element)?;
declarations.retain(|(name, value)| {
!inherited
.iter()
.any(|(candidate, bound)| candidate == name && bound == value)
});
Ok(declarations)
}
/// Retain producer root metadata when a style edit requires typed serialization.
/// The serializer owns the canonical `w` and `r` prefixes. Every other root
/// attribute, including compatibility declarations, stays in source order.
fn retain_styles_root_attributes(source: &[u8], serialized: Vec<u8>) -> Result<Vec<u8>> {
let mut reader = quick_xml::Reader::from_reader(source);
let mut buffer = Vec::new();
let root = loop {
match reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("invalid styles root: {error}")))?
{
Event::Start(element) | Event::Empty(element) => break element.into_owned(),
Event::Eof => return Err(Error::Other("styles part has no root".to_owned())),
_ => buffer.clear(),
}
};
let mut retained = Vec::new();
for attribute in root.attributes() {
let attribute = attribute.map_err(rdocx_oxml::OxmlError::from)?;
let name = std::str::from_utf8(attribute.key.as_ref())
.map_err(|error| Error::Other(format!("invalid styles attribute name: {error}")))?
.to_owned();
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, root.decoder())
.map_err(|error| Error::Other(format!("invalid styles attribute: {error}")))?
.into_owned();
if name == "xmlns:w" || name == "xmlns:r" {
let expected = if name == "xmlns:w" {
rdocx_oxml::namespace::W_NS
} else {
drawing_ns::R
};
if value != expected {
return Err(Error::Other(format!(
"cannot serialize styles with a shadowed `{}` namespace",
&name[6..]
)));
}
} else {
retained.push((name, value));
}
}
let mut start = BytesStart::new("w:styles");
start.push_attribute(("xmlns:w", rdocx_oxml::namespace::W_NS));
start.push_attribute(("xmlns:r", drawing_ns::R));
for (name, value) in &retained {
start.push_attribute((name.as_str(), value.as_str()));
}
let mut writer = Writer::new(Vec::new());
writer
.write_event(Event::Start(start))
.map_err(|error| Error::Other(format!("invalid styles root: {error}")))?;
let new_root = writer.into_inner();
let opening = b"<w:styles";
let position = serialized
.windows(opening.len())
.position(|window| window == opening)
.ok_or_else(|| Error::Other("serialized styles root is missing".to_owned()))?;
let end = serialized[position..]
.iter()
.position(|byte| *byte == b'>')
.ok_or_else(|| Error::Other("serialized styles root is incomplete".to_owned()))?
+ position
+ 1;
let mut output = Vec::with_capacity(serialized.len() + new_root.len());
output.extend_from_slice(&serialized[..position]);
output.extend_from_slice(&new_root);
output.extend_from_slice(&serialized[end..]);
Ok(output)
}
fn root_namespace_declarations(xml: &[u8]) -> Result<Vec<(String, String)>> {
let mut reader = quick_xml::Reader::from_reader(xml);
let mut buffer = Vec::new();
loop {
match reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("invalid XML root: {error}")))?
{
Event::Start(element) | Event::Empty(element) => {
return namespace_declarations(&element);
}
Event::Eof => {
return Err(Error::Other(
"XML part has no root namespace owner".to_owned(),
));
}
_ => buffer.clear(),
}
}
}
fn apply_namespace_declarations(
element: &BytesStart<'_>,
scope: &mut Vec<(String, String)>,
) -> Result<()> {
for attribute in element.attributes() {
let attribute = attribute.map_err(rdocx_oxml::OxmlError::from)?;
let name = attribute.key.as_ref();
if name != b"xmlns" && !name.starts_with(b"xmlns:") {
continue;
}
let name = std::str::from_utf8(name)
.map_err(rdocx_oxml::OxmlError::from)?
.to_owned();
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, element.decoder())
.map_err(rdocx_oxml::OxmlError::from)?
.into_owned();
scope.retain(|(candidate, _)| candidate != &name);
scope.push((name, value));
}
Ok(())
}
fn unsafe_serializer_namespace_prefix(
root_declarations: &[(String, String)],
body_declarations: &[(String, String)],
document_xml: Option<&[u8]>,
) -> Option<String> {
if let Some((name, _)) = body_declarations.first() {
return Some(namespace_prefix(name));
}
root_declarations.iter().find_map(|(name, value)| {
let prefix = namespace_prefix(name);
let expected = match canonical_serializer_namespace(&prefix) {
Some(expected) => expected,
None if prefix.is_empty() => {
return match document_xml.and_then(|xml| root_default_namespace_is_used(xml, value))
{
Some(false) => None,
Some(true) | None => Some("default".to_owned()),
};
}
None => return None,
};
(value != expected).then_some(prefix)
})
}
fn root_default_namespace_is_used(xml: &[u8], root_namespace: &str) -> Option<bool> {
let mut reader = quick_xml::Reader::from_reader(xml);
let mut buffer = Vec::new();
let mut root_binding_scopes = Vec::new();
loop {
let event = reader.read_event_into(&mut buffer).ok()?;
match event {
Event::Start(ref element) | Event::Empty(ref element) => {
let mut local_default = None;
for attribute in element.attributes() {
let attribute = attribute.ok()?;
if attribute.key.as_ref() == b"xmlns" {
if local_default.is_some() {
return None;
}
local_default = Some(
attribute
.decoded_and_normalized_value(
XmlVersion::Implicit1_0,
element.decoder(),
)
.ok()?
.into_owned(),
);
}
}
let root_binding_is_active = if root_binding_scopes.is_empty() {
if local_default.as_deref() != Some(root_namespace) {
return None;
}
true
} else if local_default.is_some() {
false
} else {
*root_binding_scopes.last()?
};
if !element.name().as_ref().contains(&b':') && root_binding_is_active {
return Some(true);
}
if matches!(event, Event::Start(_)) {
root_binding_scopes.push(root_binding_is_active);
}
}
Event::End(_) => {
root_binding_scopes.pop()?;
}
Event::Eof => return root_binding_scopes.is_empty().then_some(false),
_ => {}
}
buffer.clear();
}
}
fn canonical_serializer_namespace(prefix: &str) -> Option<&'static str> {
match prefix {
"w" => Some(WORD_NAMESPACE),
"r" => Some("http://schemas.openxmlformats.org/officeDocument/2006/relationships"),
"mc" => Some("http://schemas.openxmlformats.org/markup-compatibility/2006"),
"wp" => Some("http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"),
"a" => Some("http://schemas.openxmlformats.org/drawingml/2006/main"),
"pic" => Some("http://schemas.openxmlformats.org/drawingml/2006/picture"),
"c" => Some("http://schemas.openxmlformats.org/drawingml/2006/chart"),
"w14" => Some("http://schemas.microsoft.com/office/word/2010/wordml"),
"w15" => Some("http://schemas.microsoft.com/office/word/2012/wordml"),
_ => None,
}
}
fn namespace_prefix(declaration_name: &str) -> String {
declaration_name
.strip_prefix("xmlns:")
.unwrap_or("")
.to_owned()
}
#[derive(Debug)]
struct NestedNamespaceOwner {
source: Range<usize>,
local_name: String,
declarations: Vec<(String, String)>,
snapshot: LogicalOwnerSnapshot,
markers: Vec<NamespaceMarker>,
ambiguous_without_namespace: bool,
same_marker_structural_alternate: bool,
same_namespace_structural_alternate: bool,
}
#[derive(Debug)]
struct ModeledOwnerSpan {
local_name: String,
start: usize,
end: usize,
declarations: Vec<(String, String)>,
bindings: Vec<(String, String)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LogicalOwnerSnapshot {
structure: Vec<String>,
semantic: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct NamespaceMarker {
raw: Vec<u8>,
namespace_facts: Vec<(String, Option<String>)>,
}
fn is_modeled_owner(local_name: &[u8]) -> bool {
matches!(
local_name,
b"p" | b"tbl" | b"tc" | b"sdt" | b"hyperlink" | b"r"
)
}
fn is_modeled_word_child(parent: Option<&[u8]>, local_name: &[u8]) -> bool {
match parent {
Some(b"p") => matches!(
local_name,
b"pPr"
| b"r"
| b"fldSimple"
| b"hyperlink"
| b"sdt"
| b"bookmarkStart"
| b"bookmarkEnd"
| b"commentRangeStart"
| b"commentRangeEnd"
| b"ins"
| b"del"
| b"moveFrom"
| b"moveTo"
),
Some(b"r") => matches!(
local_name,
b"rPr"
| b"t"
| b"delText"
| b"tab"
| b"br"
| b"drawing"
| b"fldChar"
| b"instrText"
| b"commentReference"
| b"footnoteReference"
| b"endnoteReference"
),
Some(b"hyperlink") => {
matches!(local_name, b"r" | b"ins" | b"del" | b"moveFrom" | b"moveTo")
}
Some(b"sdt") => matches!(local_name, b"sdtPr" | b"sdtEndPr" | b"sdtContent"),
Some(b"sdtContent") => {
matches!(local_name, b"p" | b"tbl" | b"tr" | b"tc" | b"r" | b"sdt")
}
Some(b"tbl") => matches!(local_name, b"tblPr" | b"tblGrid" | b"tr" | b"sdt"),
Some(b"tblGrid") => local_name == b"gridCol",
Some(b"tr") => matches!(local_name, b"trPr" | b"tc" | b"sdt"),
Some(b"tc") => matches!(local_name, b"tcPr" | b"p" | b"tbl" | b"sdt"),
_ => false,
}
}
fn modeled_owner_spans(xml: &[u8]) -> Result<Vec<ModeledOwnerSpan>> {
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut buffer = Vec::new();
let mut inside_body = false;
let mut depth = 0usize;
let mut owner_stack = Vec::new();
let mut scope_stack: Vec<Vec<(String, String)>> = Vec::new();
let mut spans: Vec<ModeledOwnerSpan> = Vec::new();
loop {
let start = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("invalid nested namespace scope: {error}")))?;
let is_word = matches!(
namespace,
ResolveResult::Bound(Namespace(uri)) if uri == WORD_NAMESPACE.as_bytes()
);
let end = reader.buffer_position() as usize;
match event {
Event::Start(element) => {
let local_name = element.local_name();
let inherited = scope_stack.last().map(Vec::as_slice).unwrap_or_default();
let mut bindings = inherited.to_vec();
apply_namespace_declarations(&element, &mut bindings)?;
if inside_body && is_word && is_modeled_owner(local_name.as_ref()) {
let index = spans.len();
spans.push(ModeledOwnerSpan {
local_name: std::str::from_utf8(local_name.as_ref())
.map_err(|error| {
Error::Other(format!("invalid nested element name: {error}"))
})?
.to_owned(),
start,
end: 0,
declarations: rebinding_namespace_declarations(&element, inherited)?,
bindings: bindings.clone(),
});
owner_stack.push((depth, index));
}
if is_word && local_name.as_ref() == b"body" {
inside_body = true;
}
scope_stack.push(bindings);
depth += 1;
}
Event::Empty(element) => {
let local_name = element.local_name();
let inherited = scope_stack.last().map(Vec::as_slice).unwrap_or_default();
let mut bindings = inherited.to_vec();
apply_namespace_declarations(&element, &mut bindings)?;
if inside_body && is_word && is_modeled_owner(local_name.as_ref()) {
spans.push(ModeledOwnerSpan {
local_name: std::str::from_utf8(local_name.as_ref())
.map_err(|error| {
Error::Other(format!("invalid nested element name: {error}"))
})?
.to_owned(),
start,
end,
declarations: rebinding_namespace_declarations(&element, inherited)?,
bindings,
});
}
}
Event::End(element) => {
depth = depth.saturating_sub(1);
scope_stack.pop();
if owner_stack
.last()
.is_some_and(|(owner_depth, _)| *owner_depth == depth)
{
let (_, index) = owner_stack.pop().expect("checked owner stack");
spans[index].end = end;
}
if inside_body && is_word && matches_local_name(element.name().as_ref(), b"body") {
inside_body = false;
}
}
Event::Eof => {
if spans.iter().any(|span| span.end == 0) {
return Err(Error::Other(
"unterminated modeled namespace owner".to_owned(),
));
}
return Ok(spans);
}
_ => {}
}
buffer.clear();
}
}
fn element_declared_prefixes(
element: &BytesStart<'_>,
declarations: &[(String, String)],
bindings: &[(String, String)],
parent_local_name: Option<&[u8]>,
shadowed_owner_prefixes: &HashSet<String>,
allow_redundant_local_bindings: bool,
) -> Vec<String> {
let prefixes = declarations
.iter()
.map(|(name, value)| (namespace_prefix(name), value.as_str()))
.collect::<Vec<_>>();
let local_declarations = local_namespace_prefixes(element);
let mut used = Vec::new();
let element_is_modeled_word =
is_modeled_word_child(parent_local_name, element.local_name().as_ref());
let qualified_name = element.name();
let element_prefix = std::str::from_utf8(qualified_name.as_ref())
.ok()
.map(|name| name.split_once(':').map_or("", |(prefix, _)| prefix));
if element_prefix.is_some_and(|prefix| {
prefixes.iter().any(|(candidate, value)| {
candidate == prefix
&& !shadowed_owner_prefixes.contains(prefix)
&& inherited_namespace(bindings, prefix) == Some(*value)
&& !(*value == WORD_NAMESPACE && element_is_modeled_word)
}) && (!local_declarations.contains(element_prefix.unwrap_or_default())
|| allow_redundant_local_bindings)
}) {
used.push(element_prefix.unwrap_or_default().to_owned());
}
for attribute in element.attributes().flatten() {
let Some(name) = std::str::from_utf8(attribute.key.as_ref()).ok() else {
continue;
};
let Some((prefix, _)) = name.split_once(':') else {
continue;
};
if prefix != "xmlns"
&& prefixes.iter().any(|(candidate, value)| {
candidate == prefix
&& !shadowed_owner_prefixes.contains(prefix)
&& inherited_namespace(bindings, prefix) == Some(*value)
&& !(*value == WORD_NAMESPACE && element_is_modeled_word)
})
&& (!local_declarations.contains(prefix) || allow_redundant_local_bindings)
&& !used.iter().any(|candidate| candidate == prefix)
{
used.push(prefix.to_owned());
}
}
used
}
fn local_namespace_prefixes(element: &BytesStart<'_>) -> HashSet<String> {
element
.attributes()
.flatten()
.filter_map(|attribute| {
let name = std::str::from_utf8(attribute.key.as_ref()).ok()?;
(name == "xmlns" || name.starts_with("xmlns:")).then(|| namespace_prefix(name))
})
.collect()
}
fn resolved_snapshot_name(
qualified_name: &[u8],
bindings: &[(String, String)],
default_namespace_applies: bool,
) -> Result<String> {
let qualified_name = std::str::from_utf8(qualified_name)
.map_err(|error| Error::Other(format!("invalid logical owner name: {error}")))?;
let (prefix, local_name) = qualified_name
.split_once(':')
.map_or(("", qualified_name), |(prefix, local)| (prefix, local));
let namespace = if prefix == "xml" {
Some(XML_NAMESPACE)
} else if prefix.is_empty() && !default_namespace_applies {
None
} else {
inherited_namespace(bindings, prefix)
};
let namespace = match namespace {
Some(WORD_NAMESPACE) => WORD_NAMESPACE.to_owned(),
Some(XML_NAMESPACE) => XML_NAMESPACE.to_owned(),
_ if !prefix.is_empty() => format!("#prefix:{prefix}"),
Some(namespace) => namespace.to_owned(),
None => String::new(),
};
Ok(format!("{{{namespace}}}{local_name}"))
}
fn logical_owner_snapshot(
owner_xml: &[u8],
inherited_bindings: &[(String, String)],
prospective_declarations: &[(String, String)],
) -> Result<LogicalOwnerSnapshot> {
let mut reader = quick_xml::Reader::from_reader(owner_xml);
reader.config_mut().trim_text(false);
let mut buffer = Vec::new();
let mut owner_bindings = inherited_bindings.to_vec();
owner_bindings.extend(prospective_declarations.iter().cloned());
let mut scope_stack = vec![owner_bindings];
let mut structure = Vec::new();
let mut semantic = Vec::new();
loop {
let event = reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("invalid logical owner XML: {error}")))?;
match event {
event @ (Event::Start(_) | Event::Empty(_)) => {
let is_start = matches!(&event, Event::Start(_));
let element = match event {
Event::Start(element) | Event::Empty(element) => element,
_ => unreachable!(),
};
let mut bindings = scope_stack.last().cloned().unwrap_or_default();
apply_namespace_declarations(&element, &mut bindings)?;
let name = resolved_snapshot_name(element.name().as_ref(), &bindings, true)?;
let mut attribute_names = Vec::new();
let mut attribute_values = Vec::new();
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| {
Error::Other(format!("invalid logical owner attribute: {error}"))
})?;
if attribute.key.as_ref() == b"xmlns"
|| attribute.key.as_ref().starts_with(b"xmlns:")
{
continue;
}
let attribute_name =
resolved_snapshot_name(attribute.key.as_ref(), &bindings, false)?;
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, element.decoder())
.map_err(|error| {
Error::Other(format!("invalid logical owner attribute: {error}"))
})?
.into_owned();
attribute_names.push(attribute_name.clone());
attribute_values.push(format!("{attribute_name}={value}"));
}
attribute_names.sort();
attribute_values.sort();
structure.push(format!("start:{name}:[{}]", attribute_names.join(",")));
semantic.push(format!("start:{name}:[{}]", attribute_values.join(",")));
if is_start {
scope_stack.push(bindings);
} else {
structure.push(format!("end:{name}"));
semantic.push(format!("end:{name}"));
}
}
Event::End(element) => {
let bindings = scope_stack.last().cloned().unwrap_or_default();
let name = resolved_snapshot_name(element.name().as_ref(), &bindings, true)?;
structure.push(format!("end:{name}"));
semantic.push(format!("end:{name}"));
scope_stack.pop();
}
Event::Text(text) => {
if xml_bytes_are_whitespace(text.as_ref()) {
buffer.clear();
continue;
}
structure.push("text".to_owned());
semantic.push(format!("text:{}", String::from_utf8_lossy(text.as_ref())));
}
Event::CData(text) => {
structure.push("cdata".to_owned());
semantic.push(format!("cdata:{}", String::from_utf8_lossy(text.as_ref())));
}
Event::GeneralRef(reference) => {
structure.push("reference".to_owned());
semantic.push(format!(
"reference:{}",
String::from_utf8_lossy(reference.as_ref())
));
}
Event::Comment(comment) => {
structure.push("comment".to_owned());
semantic.push(format!(
"comment:{}",
String::from_utf8_lossy(comment.as_ref())
));
}
Event::PI(instruction) => {
structure.push("processing-instruction".to_owned());
semantic.push(format!(
"processing-instruction:{}",
String::from_utf8_lossy(instruction.as_ref())
));
}
Event::Eof => {
return Ok(LogicalOwnerSnapshot {
structure,
semantic,
});
}
_ => {}
}
buffer.clear();
}
}
fn namespace_marker_without_owner_declarations(
raw: &[u8],
declarations: &[(String, String)],
inherited_bindings: &[(String, String)],
direct_drawing_child: bool,
) -> Result<NamespaceMarker> {
// The drawing reader closes namespaces lost with its drawing wrapper on
// the retained inline/anchor root. Admit only that concrete relocation.
// Every other declaration and every nonnamespace byte stays significant.
let mut reader = quick_xml::Reader::from_reader(raw);
reader.config_mut().trim_text(false);
let mut depth = 0usize;
let mut root_bindings = inherited_bindings.to_vec();
let mut child = None;
let mut relocatable = Vec::new();
let mut closure_facts = Vec::new();
let mut root_seen = false;
let mut qualified = true;
loop {
let before = reader.buffer_position() as usize;
match reader
.read_event()
.map_err(|error| Error::Other(format!("invalid namespace marker XML: {error}")))?
{
Event::Start(element) | Event::Empty(element) => {
let empty = raw[before..reader.buffer_position() as usize].ends_with(b"/>");
if depth == 0 {
if direct_drawing_child
&& matches!(element.local_name().as_ref(), b"inline" | b"anchor")
{
apply_namespace_declarations(&element, &mut root_bindings)?;
let name = element.name();
let name = std::str::from_utf8(name.as_ref()).map_err(|error| {
Error::Other(format!("invalid namespace marker QName: {error}"))
})?;
let prefix = name.split_once(':').map_or("", |(prefix, _)| prefix);
if inherited_namespace(&root_bindings, prefix)
== Some(
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
)
{
let mut removable = declarations.to_vec();
for (prefix, uri) in [
("a", "http://schemas.openxmlformats.org/drawingml/2006/main"),
(
"pic",
"http://schemas.openxmlformats.org/drawingml/2006/picture",
),
] {
closure_facts.push((
prefix.to_owned(),
inherited_namespace(&root_bindings, prefix).map(str::to_owned),
));
if inherited_namespace(&root_bindings, prefix) == Some(uri) {
removable.push((format!("xmlns:{prefix}"), uri.to_owned()));
}
}
return Ok(NamespaceMarker {
raw: strip_marker_root_namespace_declarations(raw, &removable)?,
namespace_facts: closure_facts,
});
}
}
if root_seen || element.local_name().as_ref() != b"drawing" {
qualified = false;
break;
}
root_seen = true;
apply_namespace_declarations(&element, &mut root_bindings)?;
let name = std::str::from_utf8(element.name().as_ref())
.map_err(|error| {
Error::Other(format!("invalid namespace marker QName: {error}"))
})?
.to_owned();
let prefix = name.split_once(':').map_or("", |(prefix, _)| prefix);
qualified &=
inherited_namespace(&root_bindings, prefix) == Some(WORD_NAMESPACE);
qualified &= element.attributes().all(|attribute| {
attribute.is_ok_and(|attribute| {
let key = attribute.key.as_ref();
key == b"xmlns" || key.starts_with(b"xmlns:")
})
});
} else if depth == 1 {
if child.is_some() {
qualified = false;
break;
}
let mut bindings = root_bindings.clone();
apply_namespace_declarations(&element, &mut bindings)?;
let name = std::str::from_utf8(element.name().as_ref())
.map_err(|error| {
Error::Other(format!("invalid namespace marker QName: {error}"))
})?
.to_owned();
let prefix = name.split_once(':').map_or("", |(prefix, _)| prefix);
qualified &= matches!(element.local_name().as_ref(), b"inline" | b"anchor")
&& inherited_namespace(&bindings, prefix)
== Some(
"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing",
);
for (prefix, uri) in [
("a", "http://schemas.openxmlformats.org/drawingml/2006/main"),
(
"pic",
"http://schemas.openxmlformats.org/drawingml/2006/picture",
),
] {
closure_facts.push((
prefix.to_owned(),
inherited_namespace(&bindings, prefix).map(str::to_owned),
));
if inherited_namespace(&bindings, prefix) == Some(uri) {
relocatable.push((format!("xmlns:{prefix}"), uri.to_owned()));
}
}
child = Some((before, empty.then_some(reader.buffer_position() as usize)));
}
if !empty {
depth += 1;
}
}
Event::End(_) => {
depth = depth.saturating_sub(1);
if depth == 1
&& let Some((_, end)) = &mut child
{
*end = Some(reader.buffer_position() as usize);
}
}
Event::Text(text) if depth <= 1 => {
qualified &= text.iter().all(u8::is_ascii_whitespace);
}
Event::Comment(_) | Event::PI(_) | Event::CData(_) | Event::GeneralRef(_)
if depth <= 1 =>
{
qualified = false;
}
Event::Decl(_) | Event::DocType(_) => {
qualified = false;
}
Event::Eof => break,
_ => {}
}
}
if qualified && let Some((start, Some(end))) = child {
let mut root_declarations = declarations.to_vec();
root_declarations.extend(relocatable.iter().cloned());
let mut normalized =
strip_marker_root_namespace_declarations(&raw[..start], &root_declarations)?;
normalized.extend_from_slice(&strip_marker_root_namespace_declarations(
&raw[start..end],
&relocatable,
)?);
normalized.extend_from_slice(&raw[end..]);
return Ok(NamespaceMarker {
raw: normalized,
namespace_facts: closure_facts,
});
}
Ok(NamespaceMarker {
raw: strip_marker_root_namespace_declarations(raw, declarations)?,
namespace_facts: Vec::new(),
})
}
fn strip_marker_root_namespace_declarations(
raw: &[u8],
declarations: &[(String, String)],
) -> Result<Vec<u8>> {
let mut reader = quick_xml::Reader::from_reader(raw);
let mut buffer = Vec::new();
let element = match reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("invalid namespace marker XML: {error}")))?
{
Event::Start(element) | Event::Empty(element) => element,
_ => return Ok(raw.to_vec()),
};
let removable = namespace_declarations(&element)?
.into_iter()
.filter(|candidate| declarations.iter().any(|expected| expected == candidate))
.map(|(name, _)| name.into_bytes())
.collect::<HashSet<_>>();
if removable.is_empty() {
return Ok(raw.to_vec());
}
let mut cursor = 1usize;
while cursor < raw.len()
&& !raw[cursor].is_ascii_whitespace()
&& !matches!(raw[cursor], b'/' | b'>')
{
cursor += 1;
}
let mut removals = Vec::new();
loop {
let whitespace_start = cursor;
while cursor < raw.len() && raw[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor >= raw.len() || matches!(raw[cursor], b'/' | b'>') {
break;
}
let name_start = cursor;
while cursor < raw.len()
&& !raw[cursor].is_ascii_whitespace()
&& !matches!(raw[cursor], b'=' | b'/' | b'>')
{
cursor += 1;
}
let name_end = cursor;
while cursor < raw.len() && raw[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor >= raw.len() || raw[cursor] != b'=' {
return Ok(raw.to_vec());
}
cursor += 1;
while cursor < raw.len() && raw[cursor].is_ascii_whitespace() {
cursor += 1;
}
if cursor >= raw.len() || !matches!(raw[cursor], b'\'' | b'"') {
return Ok(raw.to_vec());
}
let quote = raw[cursor];
cursor += 1;
while cursor < raw.len() && raw[cursor] != quote {
cursor += 1;
}
if cursor >= raw.len() {
return Ok(raw.to_vec());
}
cursor += 1;
if removable.contains(&raw[name_start..name_end]) {
removals.push((whitespace_start, cursor));
}
}
if removals.is_empty() {
return Ok(raw.to_vec());
}
let mut normalized = Vec::with_capacity(raw.len());
let mut copied = 0usize;
for (start, end) in removals {
normalized.extend_from_slice(&raw[copied..start]);
copied = end;
}
normalized.extend_from_slice(&raw[copied..]);
Ok(normalized)
}
fn namespace_owner_markers(
owner_xml: &[u8],
declarations: &[(String, String)],
inherited_bindings: &[(String, String)],
allow_redundant_local_bindings: bool,
) -> Result<Vec<NamespaceMarker>> {
let mut reader = quick_xml::Reader::from_reader(owner_xml);
reader.config_mut().trim_text(false);
let mut buffer = Vec::new();
let mut stack = Vec::new();
let mut element_stack: Vec<(Vec<u8>, String, bool)> = Vec::new();
let mut empty_markers = Vec::new();
let mut expanded_markers = Vec::new();
// Candidate owners are inspected before their retained declarations have
// been replayed. Seed those prospective bindings at the owner boundary so
// descendant uses can still be matched, while declarations encountered
// inside the raw subtree continue to shadow them normally.
let mut owner_bindings = inherited_bindings.to_vec();
owner_bindings.extend(declarations.iter().cloned());
let mut scope_stack = vec![owner_bindings];
let mut shadow_stack = vec![HashSet::<String>::new()];
let mut depth = 0usize;
loop {
let start = reader.buffer_position() as usize;
let event = reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("invalid namespace owner marker: {error}")))?;
let end = reader.buffer_position() as usize;
let direct_drawing_child =
element_stack
.last()
.is_some_and(|(local, prefix, closure_qualified)| {
*closure_qualified
&& local == b"drawing"
&& inherited_namespace(
scope_stack.last().map(Vec::as_slice).unwrap_or_default(),
prefix,
) == Some(WORD_NAMESPACE)
});
match event {
Event::Start(element) => {
let mut bindings = scope_stack.last().cloned().unwrap_or_default();
apply_namespace_declarations(&element, &mut bindings)?;
let mut shadowed = shadow_stack.last().cloned().unwrap_or_default();
if depth > 0 {
shadowed.extend(local_namespace_prefixes(&element));
if allow_redundant_local_bindings {
for (name, value) in namespace_declarations(&element)? {
let prefix = namespace_prefix(&name);
if declarations.iter().any(|(candidate, owner_value)| {
namespace_prefix(candidate) == prefix && value == *owner_value
}) {
shadowed.remove(&prefix);
}
}
}
}
let used_prefixes = if depth > 0 {
element_declared_prefixes(
&element,
declarations,
&bindings,
element_stack.last().map(|element| element.0.as_slice()),
&shadowed,
allow_redundant_local_bindings,
)
} else {
Vec::new()
};
let facts = used_prefixes
.into_iter()
.map(|prefix| {
let namespace = inherited_namespace(&bindings, &prefix).map(str::to_owned);
(prefix, namespace)
})
.collect::<Vec<_>>();
stack.push((
start,
facts,
scope_stack.last().cloned().unwrap_or_default(),
direct_drawing_child,
));
let name = element.name();
let name = std::str::from_utf8(name.as_ref()).map_err(|error| {
Error::Other(format!("invalid namespace marker QName: {error}"))
})?;
let prefix = name.split_once(':').map_or("", |(prefix, _)| prefix);
let closure_qualified = if element.local_name().as_ref() == b"drawing"
&& inherited_namespace(&bindings, prefix) == Some(WORD_NAMESPACE)
{
let end = story_element_end(owner_xml, start)?;
// Child markers may normalize only inside the same actual
// minimal drawing route. A QName alone cannot authorize
// relocation past opaque wrapper attributes or siblings.
!namespace_marker_without_owner_declarations(
&owner_xml[start..end],
declarations,
scope_stack.last().map(Vec::as_slice).unwrap_or_default(),
false,
)?
.namespace_facts
.is_empty()
} else {
false
};
element_stack.push((
element.local_name().as_ref().to_vec(),
prefix.to_owned(),
closure_qualified,
));
scope_stack.push(bindings);
shadow_stack.push(shadowed);
depth += 1;
}
Event::Empty(element) => {
let mut bindings = scope_stack.last().cloned().unwrap_or_default();
apply_namespace_declarations(&element, &mut bindings)?;
let mut shadowed = shadow_stack.last().cloned().unwrap_or_default();
if depth > 0 {
shadowed.extend(local_namespace_prefixes(&element));
if allow_redundant_local_bindings {
for (name, value) in namespace_declarations(&element)? {
let prefix = namespace_prefix(&name);
if declarations.iter().any(|(candidate, owner_value)| {
namespace_prefix(candidate) == prefix && value == *owner_value
}) {
shadowed.remove(&prefix);
}
}
}
}
let used_prefixes = if depth > 0 {
element_declared_prefixes(
&element,
declarations,
&bindings,
element_stack.last().map(|element| element.0.as_slice()),
&shadowed,
allow_redundant_local_bindings,
)
} else {
Vec::new()
};
if !used_prefixes.is_empty() {
let mut namespace_facts = used_prefixes
.into_iter()
.map(|prefix| {
let namespace =
inherited_namespace(&bindings, &prefix).map(str::to_owned);
(prefix, namespace)
})
.collect::<Vec<_>>();
let marker = namespace_marker_without_owner_declarations(
&owner_xml[start..end],
declarations,
scope_stack.last().map(Vec::as_slice).unwrap_or_default(),
direct_drawing_child,
)?;
namespace_facts.extend(marker.namespace_facts);
empty_markers.push(NamespaceMarker {
raw: marker.raw,
namespace_facts,
});
}
}
Event::End(_) => {
depth = depth.saturating_sub(1);
scope_stack.pop();
shadow_stack.pop();
element_stack.pop();
if let Some((
marker_start,
mut namespace_facts,
inherited_bindings,
direct_drawing_child,
)) = stack.pop()
&& !namespace_facts.is_empty()
{
let marker = namespace_marker_without_owner_declarations(
&owner_xml[marker_start..end],
declarations,
&inherited_bindings,
direct_drawing_child,
)?;
namespace_facts.extend(marker.namespace_facts);
expanded_markers.push(NamespaceMarker {
raw: marker.raw,
namespace_facts,
});
}
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
empty_markers.extend(expanded_markers);
Ok(empty_markers)
}
fn candidate_namespace_owner_markers(
owner_xml: &[u8],
declarations: &[(String, String)],
inherited_bindings: &[(String, String)],
expected: &[NamespaceMarker],
) -> Result<Vec<NamespaceMarker>> {
let candidates = namespace_owner_markers(owner_xml, declarations, inherited_bindings, true)?;
let mut matched = vec![false; expected.len()];
Ok(candidates
.into_iter()
.filter(|candidate| {
let expected_index = expected
.iter()
.enumerate()
.position(|(index, marker)| !matched[index] && marker.raw == candidate.raw);
if let Some(index) = expected_index {
matched[index] = true;
true
} else {
false
}
})
.collect())
}
fn marker_multiset_matches(
expected: &[NamespaceMarker],
actual: &[NamespaceMarker],
include_namespace_facts: bool,
) -> bool {
if expected.len() != actual.len() {
return false;
}
let mut matched = vec![false; actual.len()];
expected.iter().all(|marker| {
let candidate = actual.iter().enumerate().position(|(index, candidate)| {
!matched[index]
&& candidate.raw == marker.raw
&& (!include_namespace_facts || candidate.namespace_facts == marker.namespace_facts)
});
if let Some(index) = candidate {
matched[index] = true;
true
} else {
false
}
})
}
fn nested_modeled_namespace_owners(xml: &[u8]) -> Result<Vec<NestedNamespaceOwner>> {
let spans = modeled_owner_spans(xml)?;
let mut owners = Vec::new();
for span in &spans {
let mut declarations = span.declarations.clone();
if declarations.is_empty() {
continue;
}
let markers = namespace_owner_markers(
&xml[span.start..span.end],
&declarations,
&span.bindings,
false,
)?;
if markers.is_empty() {
continue;
}
let used_prefixes = markers
.iter()
.flat_map(|marker| marker.namespace_facts.iter())
.map(|(prefix, _)| prefix.as_str())
.collect::<HashSet<_>>();
declarations.retain(|(name, _)| used_prefixes.contains(namespace_prefix(name).as_str()));
let snapshot =
logical_owner_snapshot(&xml[span.start..span.end], &span.bindings, &declarations)?;
let ambiguous_without_namespace = spans.iter().any(|candidate| {
if candidate.start == span.start || candidate.local_name != span.local_name {
return false;
}
let Ok(candidate_snapshot) = logical_owner_snapshot(
&xml[candidate.start..candidate.end],
&candidate.bindings,
&declarations,
) else {
return true;
};
if candidate_snapshot != snapshot {
return false;
}
let Ok(candidate_markers) = candidate_namespace_owner_markers(
&xml[candidate.start..candidate.end],
&declarations,
&candidate.bindings,
&markers,
) else {
return true;
};
marker_multiset_matches(&markers, &candidate_markers, false)
});
let same_marker_structural_alternate = spans.iter().any(|candidate| {
if candidate.start == span.start || candidate.local_name != span.local_name {
return false;
}
let Ok(candidate_snapshot) = logical_owner_snapshot(
&xml[candidate.start..candidate.end],
&candidate.bindings,
&declarations,
) else {
return true;
};
if candidate_snapshot.structure != snapshot.structure {
return false;
}
let Ok(candidate_markers) = candidate_namespace_owner_markers(
&xml[candidate.start..candidate.end],
&declarations,
&candidate.bindings,
&markers,
) else {
return true;
};
marker_multiset_matches(&markers, &candidate_markers, false)
});
let same_namespace_structural_alternate = spans.iter().any(|candidate| {
if candidate.start == span.start || candidate.local_name != span.local_name {
return false;
}
let Ok(candidate_snapshot) = logical_owner_snapshot(
&xml[candidate.start..candidate.end],
&candidate.bindings,
&declarations,
) else {
return true;
};
if candidate_snapshot.structure != snapshot.structure {
return false;
}
let Ok(candidate_markers) = candidate_namespace_owner_markers(
&xml[candidate.start..candidate.end],
&declarations,
&candidate.bindings,
&markers,
) else {
return true;
};
marker_multiset_matches(&markers, &candidate_markers, true)
});
owners.push(NestedNamespaceOwner {
source: span.start..span.end,
local_name: span.local_name.clone(),
declarations,
snapshot,
markers,
ambiguous_without_namespace,
same_marker_structural_alternate,
same_namespace_structural_alternate,
});
}
Ok(owners)
}
fn unsafe_nested_namespace_prefix(owners: &[NestedNamespaceOwner]) -> Option<String> {
owners.iter().find_map(|owner| {
owner.declarations.iter().find_map(|(name, value)| {
let prefix = namespace_prefix(name);
if owner.local_name == "hyperlink" && prefix == "r" {
return None;
}
canonical_serializer_namespace(&prefix)
.is_some_and(|expected| value != expected)
.then_some(prefix)
})
})
}
fn replay_nested_namespace_declarations(
xml: &[u8],
owners: &[NestedNamespaceOwner],
) -> Result<Vec<u8>> {
if owners.is_empty() {
return Ok(xml.to_vec());
}
let spans = modeled_owner_spans(xml)?;
let mut targets: Vec<(usize, &NestedNamespaceOwner)> = Vec::new();
for owner in owners {
let mut candidates = Vec::new();
for span in spans
.iter()
.filter(|span| span.local_name == owner.local_name)
{
let snapshot = logical_owner_snapshot(
&xml[span.start..span.end],
&span.bindings,
&owner.declarations,
)?;
if snapshot.structure != owner.snapshot.structure {
continue;
}
let markers = candidate_namespace_owner_markers(
&xml[span.start..span.end],
&owner.declarations,
&span.bindings,
&owner.markers,
)?;
if marker_multiset_matches(&owner.markers, &markers, false) {
candidates.push((span, snapshot, markers));
}
}
let namespace_candidates = candidates
.iter()
.filter(|(_, _, markers)| marker_multiset_matches(&owner.markers, markers, true))
.collect::<Vec<_>>();
let target = match namespace_candidates.as_slice() {
[candidate] if !owner.same_namespace_structural_alternate => candidate.0,
[candidate]
if !owner.ambiguous_without_namespace
&& candidate.1.semantic == owner.snapshot.semantic =>
{
candidate.0
}
[] if !owner.ambiguous_without_namespace => {
let semantic_candidates = candidates
.iter()
.filter(|(_, snapshot, _)| snapshot.semantic == owner.snapshot.semantic)
.collect::<Vec<_>>();
match semantic_candidates.as_slice() {
[candidate] => candidate.0,
[] if candidates.len() == 1 && !owner.same_marker_structural_alternate => {
candidates[0].0
}
_ => {
return Err(Error::Other(format!(
"cannot identify retained `{}` nested namespace owner after mutation",
owner.local_name
)));
}
}
}
candidates => {
if owner.ambiguous_without_namespace && owner.same_namespace_structural_alternate {
return Err(Error::Other(format!(
"cannot identify retained `{}` nested namespace owner after mutation",
owner.local_name
)));
}
let semantic_candidates = candidates
.iter()
.filter(|candidate| candidate.1.semantic == owner.snapshot.semantic)
.collect::<Vec<_>>();
let [candidate] = semantic_candidates.as_slice() else {
return Err(Error::Other(format!(
"cannot identify retained `{}` nested namespace owner after mutation",
owner.local_name
)));
};
candidate.0
}
};
if owner.declarations.iter().all(|declaration| {
target
.declarations
.iter()
.any(|candidate| candidate == declaration)
}) {
continue;
}
targets.push((target.start, owner));
}
if targets.is_empty() {
return Ok(xml.to_vec());
}
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut writer = Writer::new(Vec::new());
let mut buffer = Vec::new();
let mut replayed = 0usize;
loop {
let start = reader.buffer_position() as usize;
let (_namespace, event) =
reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| {
Error::Other(format!("invalid serialized namespace scope: {error}"))
})?;
let starts_scope = matches!(&event, Event::Start(_));
match event {
Event::Start(mut element) | Event::Empty(mut element) => {
if let Some((_, owner)) = targets.iter().find(|(target, _)| *target == start) {
let existing = namespace_declarations(&element)?;
for (name, value) in &owner.declarations {
match existing.iter().find(|(candidate, _)| candidate == name) {
Some((_, existing_value)) if existing_value == value => {}
Some(_) => {
return Err(Error::Other(format!(
"cannot replay conflicting `{}` namespace after mutation",
namespace_prefix(name),
)));
}
None => element.push_attribute((name.as_str(), value.as_str())),
}
}
replayed += 1;
}
writer.write_event(if starts_scope {
Event::Start(element)
} else {
Event::Empty(element)
})?;
}
Event::End(element) => writer.write_event(Event::End(element))?,
Event::Eof => {
if replayed < targets.len() {
return Err(Error::Other(
"cannot align retained nested namespace declarations after mutation"
.to_owned(),
));
}
return Ok(writer.into_inner());
}
event => writer.write_event(event)?,
}
buffer.clear();
}
}
/// One body child exposed through the compatibility reader facade.
#[non_exhaustive]
pub enum BodyContentRef<'a> {
/// A body paragraph.
Paragraph(ParagraphRef<'a>),
/// A body table.
Table(TableRef<'a>),
/// Content that the compatibility facade does not model.
UnsupportedXml(UnsupportedXmlRef<'a>),
}
/// An immutable borrowed view of one section in document order.
#[derive(Clone, Copy)]
pub struct SectionRef<'a> {
ordinal: usize,
is_final: bool,
inner: &'a CT_SectPr,
}
impl<'a> SectionRef<'a> {
/// Return this section's zero-based document-order position.
pub fn ordinal(&self) -> usize {
self.ordinal
}
/// Return whether this section owns the schema-final body properties.
pub fn is_final(&self) -> bool {
self.is_final
}
/// Return the explicitly configured page orientation.
pub fn orientation(&self) -> Option<ST_PageOrientation> {
self.inner.orientation
}
/// Return the explicitly configured page width and height.
pub fn page_size(&self) -> Option<(Length, Length)> {
Some((
Length::twips(self.inner.page_width?.0),
Length::twips(self.inner.page_height?.0),
))
}
/// Return the explicitly configured top, right, bottom, and left margins.
pub fn margins(&self) -> Option<(Length, Length, Length, Length)> {
Some((
Length::twips(self.inner.margin_top?.0),
Length::twips(self.inner.margin_right?.0),
Length::twips(self.inner.margin_bottom?.0),
Length::twips(self.inner.margin_left?.0),
))
}
/// Return the explicitly configured gutter.
pub fn gutter(&self) -> Option<Length> {
self.inner.gutter.map(|value| Length::twips(value.0))
}
/// Return the equal-width column count and spacing when modeled by M23.
pub fn columns(&self) -> Option<(u32, Length)> {
let columns = self.inner.columns.as_ref()?;
if columns.equal_width == Some(false) || !columns.columns.is_empty() {
return None;
}
Some((
columns.num.unwrap_or(1),
Length::twips(columns.space.map_or(720, |value| value.0)),
))
}
/// Return the explicit column tracks, as `(width, trailing space)` pairs.
///
/// `None` when the section has no columns or leaves them equal width, which
/// is what `columns` already reports.
pub fn column_widths(&self) -> Option<Vec<(Length, Length)>> {
let columns = self.inner.columns.as_ref()?;
if columns.columns.is_empty() {
return None;
}
Some(
columns
.columns
.iter()
.map(|column| {
(
Length::twips(column.width.map_or(0, |value| value.0)),
Length::twips(column.space.map_or(0, |value| value.0)),
)
})
.collect(),
)
}
/// Return whether a rule is drawn between this section's columns.
pub fn column_separator(&self) -> Option<bool> {
self.inner.columns.as_ref()?.sep
}
/// Borrow this section's footnote configuration.
pub fn footnote_properties(&self) -> Option<&'a CT_NoteProperties> {
self.inner.footnote_pr.as_deref()
}
/// Borrow this section's endnote configuration.
pub fn endnote_properties(&self) -> Option<&'a CT_NoteProperties> {
self.inner.endnote_pr.as_deref()
}
/// Return the printer trays feeding the first page and every later page.
pub fn paper_source(&self) -> Option<(Option<u32>, Option<u32>)> {
let source = self.inner.paper_source.as_deref()?;
Some((source.first, source.other))
}
/// Borrow this section's page border frame.
pub fn page_borders(&self) -> Option<&'a CT_PageBorders> {
self.inner.page_borders.as_deref()
}
/// Borrow this section's margin line numbering.
pub fn line_numbers(&self) -> Option<&'a CT_LineNumber> {
self.inner.line_numbers.as_deref()
}
/// Return the vertical alignment of the body band within the page.
pub fn vertical_alignment(&self) -> Option<ST_VerticalJc> {
self.inner.vertical_alignment
}
/// Return this section's `w:textDirection`, exactly as it was authored.
pub fn text_direction(&self) -> Option<&'a str> {
self.inner.text_direction.as_deref()
}
/// Return this section's `w:docGrid` character grid.
pub fn doc_grid(&self) -> Option<&'a CT_DocGrid> {
self.inner.doc_grid.as_deref()
}
/// Return the explicitly configured page-number restart.
pub fn page_number_start(&self) -> Option<u32> {
self.inner.page_number.as_ref()?.start
}
/// Return the explicitly configured header and footer distances.
pub fn header_footer_distance(&self) -> Option<(Length, Length)> {
Some((
Length::twips(self.inner.header_distance?.0),
Length::twips(self.inner.footer_distance?.0),
))
}
/// Return the explicitly configured title-page state.
pub fn different_first_page(&self) -> Option<bool> {
self.inner.title_pg
}
/// Return the explicitly configured section break type.
pub fn break_type(&self) -> Option<ST_SectionType> {
self.inner.section_type
}
/// Borrow the complete section properties.
pub fn properties(&self) -> &'a CT_SectPr {
self.inner
}
}
/// Severity of a document section validation finding.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ValidationSeverity {
Error,
Warning,
}
/// One stable section validation fact, in document order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationFinding {
pub severity: ValidationSeverity,
pub message: String,
}
/// Read-only validation results over the current document.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationReport {
pub findings: Vec<ValidationFinding>,
}
impl ValidationReport {
/// Warnings do not invalidate a document.
pub fn is_valid(&self) -> bool {
self.findings
.iter()
.all(|finding| finding.severity != ValidationSeverity::Error)
}
}
/// A mutable borrowed view of one section in document order.
pub struct Section<'a> {
ordinal: usize,
is_final: bool,
inner: &'a mut CT_SectPr,
}
impl Section<'_> {
/// Return this section's zero-based document-order position.
pub fn ordinal(&self) -> usize {
self.ordinal
}
/// Return whether this section owns the schema-final body properties.
pub fn is_final(&self) -> bool {
self.is_final
}
/// Return the explicitly configured page orientation.
pub fn orientation(&self) -> Option<ST_PageOrientation> {
self.inner.orientation
}
/// Return the explicitly configured page width and height.
pub fn page_size(&self) -> Option<(Length, Length)> {
SectionRef {
ordinal: self.ordinal,
is_final: self.is_final,
inner: self.inner,
}
.page_size()
}
/// Set this section's positive page dimensions.
pub fn set_page_size(&mut self, width: Length, height: Length) -> Result<()> {
let width = checked_positive_section_twips(width, "page width")?;
let height = checked_positive_section_twips(height, "page height")?;
self.inner.page_width = Some(width);
self.inner.page_height = Some(height);
Ok(())
}
/// Set this section's orientation and normalize its page dimensions.
pub fn set_orientation(&mut self, orientation: ST_PageOrientation) {
self.inner.orientation = Some(orientation);
if let (Some(width), Some(height)) = (self.inner.page_width, self.inner.page_height) {
let dimensions_need_swap = match orientation {
ST_PageOrientation::Landscape => width.0 < height.0,
ST_PageOrientation::Portrait => width.0 > height.0,
};
if dimensions_need_swap {
self.inner.page_width = Some(height);
self.inner.page_height = Some(width);
}
}
}
/// Return the explicitly configured top, right, bottom, and left margins.
pub fn margins(&self) -> Option<(Length, Length, Length, Length)> {
SectionRef {
ordinal: self.ordinal,
is_final: self.is_final,
inner: self.inner,
}
.margins()
}
/// Set this section's top, right, bottom, and left margins.
///
/// Any other `w:pgMar` value the section lacks takes its layout default, so
/// the written element carries every attribute `CT_PageMar` requires.
pub fn set_margins(
&mut self,
top: Length,
right: Length,
bottom: Length,
left: Length,
) -> Result<()> {
let top = checked_section_twips(top, "top margin")?;
let right = checked_section_twips(right, "right margin")?;
let bottom = checked_section_twips(bottom, "bottom margin")?;
let left = checked_section_twips(left, "left margin")?;
self.inner.margin_top = Some(top);
self.inner.margin_right = Some(right);
self.inner.margin_bottom = Some(bottom);
self.inner.margin_left = Some(left);
complete_page_margins(self.inner);
Ok(())
}
/// Return the explicitly configured gutter.
pub fn gutter(&self) -> Option<Length> {
self.inner.gutter.map(|value| Length::twips(value.0))
}
/// Set this section's nonnegative gutter.
///
/// Any other `w:pgMar` value the section lacks takes its layout default, so
/// the written element carries every attribute `CT_PageMar` requires.
pub fn set_gutter(&mut self, gutter: Length) -> Result<()> {
let gutter = checked_nonnegative_section_twips(gutter, "gutter")?;
self.inner.gutter = Some(gutter);
complete_page_margins(self.inner);
Ok(())
}
/// Return the equal-width column count and spacing when modeled by M23.
pub fn columns(&self) -> Option<(u32, Length)> {
SectionRef {
ordinal: self.ordinal,
is_final: self.is_final,
inner: self.inner,
}
.columns()
}
/// Set positive-count equal-width columns with nonnegative spacing.
pub fn set_columns(&mut self, count: u32, spacing: Length) -> Result<()> {
if count == 0 {
return Err(Error::Other(
"section column count must be positive".to_owned(),
));
}
let spacing = checked_nonnegative_section_twips(spacing, "column spacing")?;
let separator = self.inner.columns.as_ref().and_then(|columns| columns.sep);
self.inner.columns = Some(CT_Columns {
num: Some(count),
space: Some(spacing),
equal_width: Some(true),
sep: separator,
columns: Vec::new(),
});
Ok(())
}
/// Return the explicit column tracks, as `(width, trailing space)` pairs.
pub fn column_widths(&self) -> Option<Vec<(Length, Length)>> {
SectionRef {
ordinal: self.ordinal,
is_final: self.is_final,
inner: self.inner,
}
.column_widths()
}
/// Set explicit column tracks of positive width and nonnegative spacing.
///
/// Authoring tracks marks the section unequal width, which is what makes
/// the resolver read the list rather than divide the text measure.
pub fn set_column_widths(&mut self, tracks: &[(Length, Length)]) -> Result<()> {
if tracks.is_empty() {
return Err(Error::Other(
"section column tracks must not be empty".to_owned(),
));
}
let mut columns = Vec::with_capacity(tracks.len());
for (width, space) in tracks {
columns.push(CT_Column {
width: Some(checked_positive_section_twips(*width, "column width")?),
space: Some(checked_nonnegative_section_twips(*space, "column spacing")?),
});
}
let count = u32::try_from(tracks.len())
.map_err(|_| Error::Other("section column count exceeds the range".to_owned()))?;
let separator = self.inner.columns.as_ref().and_then(|columns| columns.sep);
self.inner.columns = Some(CT_Columns {
num: Some(count),
space: None,
equal_width: Some(false),
sep: separator,
columns,
});
Ok(())
}
/// Return whether a rule is drawn between this section's columns.
pub fn column_separator(&self) -> Option<bool> {
SectionRef {
ordinal: self.ordinal,
is_final: self.is_final,
inner: self.inner,
}
.column_separator()
}
/// Configure the rule drawn between this section's columns.
pub fn set_column_separator(&mut self, enabled: bool) {
match &mut self.inner.columns {
Some(columns) => columns.sep = Some(enabled),
None => {
self.inner.columns = Some(CT_Columns {
sep: Some(enabled),
..CT_Columns::default()
});
}
}
}
/// Borrow this section's footnote configuration.
pub fn footnote_properties(&self) -> Option<&CT_NoteProperties> {
self.inner.footnote_pr.as_deref()
}
/// Set this section's footnote configuration.
pub fn set_footnote_properties(&mut self, properties: CT_NoteProperties) {
self.inner.footnote_pr = Some(Box::new(properties));
}
/// Borrow this section's endnote configuration.
pub fn endnote_properties(&self) -> Option<&CT_NoteProperties> {
self.inner.endnote_pr.as_deref()
}
/// Set this section's endnote configuration.
pub fn set_endnote_properties(&mut self, properties: CT_NoteProperties) {
self.inner.endnote_pr = Some(Box::new(properties));
}
/// Set a checked section override while retaining unmodelled note-property children.
pub fn set_note_policy(&mut self, family: NoteFamily, policy: NotePolicy) -> Result<()> {
let checked = policy.properties(family)?;
let slot = match family {
NoteFamily::Footnote => &mut self.inner.footnote_pr,
NoteFamily::Endnote => &mut self.inner.endnote_pr,
};
let properties = slot.get_or_insert_with(Box::default);
properties.pos = checked.pos;
properties.num_fmt = checked.num_fmt;
properties.num_start = checked.num_start;
properties.num_restart = checked.num_restart;
Ok(())
}
/// Remove an explicit section note override so document defaults apply.
pub fn remove_note_policy(&mut self, family: NoteFamily) -> Option<CT_NoteProperties> {
let slot = match family {
NoteFamily::Footnote => &mut self.inner.footnote_pr,
NoteFamily::Endnote => &mut self.inner.endnote_pr,
};
let properties = slot.as_mut()?;
if properties.pos.is_none()
&& properties.num_fmt.is_none()
&& properties.num_start.is_none()
&& properties.num_restart.is_none()
{
return None;
}
let removed = (**properties).clone();
properties.pos = None;
properties.num_fmt = None;
properties.num_start = None;
properties.num_restart = None;
if properties.special_references.is_empty() && properties.extra_xml.is_empty() {
*slot = None;
}
Some(removed)
}
/// Return the printer trays feeding the first page and every later page.
pub fn paper_source(&self) -> Option<(Option<u32>, Option<u32>)> {
SectionRef {
ordinal: self.ordinal,
is_final: self.is_final,
inner: self.inner,
}
.paper_source()
}
/// Select the printer trays this section prints from.
///
/// Tray selection is a print-time choice, so page geometry and page count
/// are identical with and without it.
pub fn set_paper_source(&mut self, first: Option<u32>, other: Option<u32>) {
let retained = self
.inner
.paper_source
.take()
.map(|source| source.extra_attributes)
.unwrap_or_default();
self.inner.paper_source = Some(Box::new(CT_PaperSource {
first,
other,
extra_attributes: retained,
}));
}
/// Borrow this section's page border frame.
pub fn page_borders(&self) -> Option<&CT_PageBorders> {
self.inner.page_borders.as_deref()
}
/// Set this section's page border frame.
pub fn set_page_borders(&mut self, borders: CT_PageBorders) {
self.inner.page_borders = Some(Box::new(borders));
}
/// Edit one page-frame edge without replacing other edges or opaque owners.
/// Width is in eighth-points and spacing in points, as specified by OOXML.
pub fn set_page_border(
&mut self,
edge: &str,
style: &str,
width: u32,
space: u32,
color: &str,
) -> Result<()> {
let style = rdocx_oxml::shared::ST_Border::from_str(style)?;
if !(2..=96).contains(&width) || space > 31 {
return Err(Error::Other(
"page border width must be 2..96 eighth-points and spacing 0..31 points".into(),
));
}
if color.len() != 6 || !color.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(Error::Other(
"page border color requires six hexadecimal digits".into(),
));
}
if !matches!(edge, "top" | "left" | "bottom" | "right") {
return Err(Error::Other(
"page border edge must be top, left, bottom or right".into(),
));
}
let borders = self.inner.page_borders.get_or_insert_with(Box::default);
let slot = match edge {
"top" => &mut borders.top,
"left" => &mut borders.left,
"bottom" => &mut borders.bottom,
"right" => &mut borders.right,
_ => unreachable!(),
};
let mut border = slot
.clone()
.unwrap_or_else(|| rdocx_oxml::borders::CT_BorderEdge::new(style));
border.set_literal_color(color)?;
border.val = style;
border.sz = Some(width);
border.space = Some(space);
*slot = Some(border);
Ok(())
}
/// Borrow this section's margin line numbering.
pub fn line_numbers(&self) -> Option<&CT_LineNumber> {
self.inner.line_numbers.as_deref()
}
/// Set this section's margin line numbering.
pub fn set_line_numbers(&mut self, numbering: CT_LineNumber) {
self.inner.line_numbers = Some(Box::new(numbering));
}
/// Return the vertical alignment of the body band within the page.
pub fn vertical_alignment(&self) -> Option<ST_VerticalJc> {
self.inner.vertical_alignment
}
/// Set the vertical alignment of the body band within the page.
///
/// `ST_VerticalJc::Both` keeps its source value and lays out as `Top`.
/// True vertical distribution is a named follow-up.
pub fn set_vertical_alignment(&mut self, alignment: ST_VerticalJc) {
self.inner.vertical_alignment = Some(alignment);
}
/// Return this section's `w:textDirection`, exactly as it was authored.
pub fn text_direction(&self) -> Option<&str> {
self.inner.text_direction.as_deref()
}
/// Set this section's `w:textDirection`.
///
/// F-269 authors and preserves the value. F-266c owns projecting it onto
/// the rendered page, so nothing here changes what is drawn.
pub fn set_text_direction(&mut self, direction: &str) {
self.inner.text_direction = Some(direction.to_owned());
}
/// Return this section's `w:docGrid` character grid.
pub fn doc_grid(&self) -> Option<&CT_DocGrid> {
self.inner.doc_grid.as_deref()
}
/// Set or clear this section's `w:docGrid` character grid.
///
/// A `default` grid, and an absent one, keep the ordinary line and
/// character metrics. `lines`, `linesAndChars` and `snapToChars` put line
/// advance on the grid pitch, and the last two put character advance on
/// the character space as well.
pub fn set_doc_grid(&mut self, grid: Option<CT_DocGrid>) {
self.inner.doc_grid = grid.map(Box::new);
}
/// Return the explicitly configured page-number restart.
pub fn page_number_start(&self) -> Option<u32> {
self.inner.page_number.as_ref()?.start
}
/// Set the positive displayed page number at which this section starts.
pub fn set_page_number_start(&mut self, start: u32) -> Result<()> {
if start == 0 {
return Err(Error::Other(
"section page-number start must be positive".to_owned(),
));
}
match &mut self.inner.page_number {
Some(page_number) => page_number.start = Some(start),
None => {
self.inner.page_number = Some(rdocx_oxml::document::CT_PageNumberType::new(start))
}
}
Ok(())
}
/// Return the explicitly configured header and footer distances.
pub fn header_footer_distance(&self) -> Option<(Length, Length)> {
SectionRef {
ordinal: self.ordinal,
is_final: self.is_final,
inner: self.inner,
}
.header_footer_distance()
}
/// Set nonnegative header and footer distances from the page edges.
///
/// Any other `w:pgMar` value the section lacks takes its layout default, so
/// the written element carries every attribute `CT_PageMar` requires.
pub fn set_header_footer_distance(&mut self, header: Length, footer: Length) -> Result<()> {
let header = checked_nonnegative_section_twips(header, "header distance")?;
let footer = checked_nonnegative_section_twips(footer, "footer distance")?;
self.inner.header_distance = Some(header);
self.inner.footer_distance = Some(footer);
complete_page_margins(self.inner);
Ok(())
}
/// Return the explicitly configured title-page state.
pub fn different_first_page(&self) -> Option<bool> {
self.inner.title_pg
}
/// Configure whether this section uses first-page header and footer variants.
pub fn set_different_first_page(&mut self, enabled: bool) {
self.inner.title_pg = Some(enabled);
}
/// Return the explicitly configured section break type.
pub fn break_type(&self) -> Option<ST_SectionType> {
self.inner.section_type
}
/// Set this section's break type.
pub fn set_break_type(&mut self, break_type: ST_SectionType) {
self.inner.section_type = Some(break_type);
}
/// Borrow the complete section properties.
pub fn properties(&self) -> &CT_SectPr {
self.inner
}
/// Mutably borrow the complete section properties.
pub fn properties_mut(&mut self) -> &mut CT_SectPr {
self.inner
}
}
/// Give every `w:pgMar` attribute a value once a setter has written one.
///
/// `CT_PageMar` requires all seven attributes. A value the section already has
/// is kept, and an absent one takes the value layout assumes when it is
/// missing, which is the `CT_SectPr::default_letter` value, so the section
/// renders exactly as before.
fn complete_page_margins(properties: &mut CT_SectPr) {
let defaults = CT_SectPr::default_letter();
properties.margin_top = properties.margin_top.or(defaults.margin_top);
properties.margin_right = properties.margin_right.or(defaults.margin_right);
properties.margin_bottom = properties.margin_bottom.or(defaults.margin_bottom);
properties.margin_left = properties.margin_left.or(defaults.margin_left);
properties.gutter = properties.gutter.or(defaults.gutter);
properties.header_distance = properties.header_distance.or(defaults.header_distance);
properties.footer_distance = properties.footer_distance.or(defaults.footer_distance);
}
fn checked_positive_section_twips(value: Length, name: &str) -> Result<Twips> {
if value.to_emu() <= 0 {
return Err(Error::Other(format!("section {name} must be positive")));
}
let twips = checked_section_twips(value, name)?;
if twips.0 == 0 {
return Err(Error::Other(format!(
"section {name} must be at least one twip"
)));
}
Ok(twips)
}
fn checked_nonnegative_section_twips(value: Length, name: &str) -> Result<Twips> {
if value.to_emu() < 0 {
return Err(Error::Other(format!("section {name} must be nonnegative")));
}
checked_section_twips(value, name)
}
fn checked_section_twips(value: Length, name: &str) -> Result<Twips> {
let twips = value.to_emu() / 635;
let twips = i32::try_from(twips)
.map_err(|_| Error::Other(format!("section {name} exceeds the twip range")))?;
Ok(Twips(twips))
}
fn empty_section_properties() -> CT_SectPr {
CT_SectPr {
page_width: None,
page_height: None,
orientation: None,
margin_top: None,
margin_right: None,
margin_bottom: None,
margin_left: None,
gutter: None,
header_distance: None,
footer_distance: None,
section_type: None,
columns: None,
page_number: None,
footnote_pr: None,
endnote_pr: None,
paper_source: None,
page_borders: None,
line_numbers: None,
vertical_alignment: None,
text_direction: None,
doc_grid: None,
title_pg: None,
header_refs: Vec::new(),
footer_refs: Vec::new(),
extra_xml: Vec::new(),
extra_xml_positions: Vec::new(),
change: None,
}
}
/// Package and WordprocessingML identity state owned by the document facade.
///
/// Each set represents one OOXML scope. References are deliberately excluded
/// from definition sets, so a bookmark end, comment anchor, or numbering use
/// does not collide with the definition it names.
#[derive(Clone, Debug)]
pub(crate) struct DocumentIdentifiers {
relationship_ids: HashMap<String, HashSet<String>>,
bookmark_ids: HashSet<i32>,
comment_ids: HashSet<i32>,
drawing_ids: HashSet<u32>,
/// Per part, the `wp:docPr/@id` values the opened XML repeats, with
/// their occurrence count. Producers repeat them and consumers renumber
/// or ignore them, so a scan accepts them. A staged edit keeps the counts
/// of the opened document and may not exceed them.
repeated_drawing_ids: HashMap<String, HashMap<u32, usize>>,
abstract_numbering_ids: HashSet<u32>,
numbering_instance_ids: HashSet<u32>,
part_names: HashSet<String>,
content_type_defaults: HashSet<String>,
content_type_overrides: HashSet<String>,
authored_content_type_defaults: HashSet<String>,
preserved_relationship_ids: HashMap<String, HashSet<String>>,
preserved_bookmark_ids: HashSet<i32>,
preserved_comment_ids: HashSet<i32>,
preserved_drawing_ids: HashSet<u32>,
preserved_abstract_numbering_ids: HashSet<u32>,
preserved_numbering_instance_ids: HashSet<u32>,
preserved_part_names: HashSet<String>,
authored_bookmark_ids: HashSet<i32>,
authored_comment_ids: HashSet<i32>,
authored_toc_bookmark_ids: HashSet<i32>,
authored_bookmark_names: HashSet<String>,
authored_story_parts: HashSet<String>,
authored_story_relationship_ids: HashMap<String, HashSet<String>>,
authored_nested_story_relationship_ids: HashMap<String, HashSet<String>>,
authored_nested_story_drawing_ids: HashMap<String, HashSet<u32>>,
authored_bundle_relationship_ids: HashMap<String, HashSet<String>>,
provisional_relationship_ids: HashMap<String, HashSet<String>>,
}
impl DocumentIdentifiers {
fn scan(package: &OpcPackage) -> Result<Self> {
let mut identifiers = Self {
relationship_ids: HashMap::new(),
bookmark_ids: HashSet::new(),
comment_ids: HashSet::new(),
drawing_ids: HashSet::new(),
repeated_drawing_ids: HashMap::new(),
abstract_numbering_ids: HashSet::new(),
numbering_instance_ids: HashSet::new(),
part_names: package
.parts
.keys()
.map(|part_name| part_name_identity(part_name))
.collect(),
content_type_defaults: package
.content_types
.defaults
.keys()
.map(|extension| extension.to_ascii_lowercase())
.collect(),
content_type_overrides: package
.content_types
.overrides
.keys()
.map(|part_name| part_name.to_ascii_lowercase())
.collect(),
authored_content_type_defaults: HashSet::new(),
preserved_relationship_ids: HashMap::new(),
preserved_bookmark_ids: HashSet::new(),
preserved_comment_ids: HashSet::new(),
preserved_drawing_ids: HashSet::new(),
preserved_abstract_numbering_ids: HashSet::new(),
preserved_numbering_instance_ids: HashSet::new(),
preserved_part_names: HashSet::new(),
authored_bookmark_ids: HashSet::new(),
authored_comment_ids: HashSet::new(),
authored_toc_bookmark_ids: HashSet::new(),
authored_bookmark_names: HashSet::new(),
authored_story_parts: HashSet::new(),
authored_story_relationship_ids: HashMap::new(),
authored_nested_story_relationship_ids: HashMap::new(),
authored_nested_story_drawing_ids: HashMap::new(),
authored_bundle_relationship_ids: HashMap::new(),
provisional_relationship_ids: HashMap::new(),
};
identifiers.part_names.extend(
package
.part_rels
.keys()
.map(|name| part_name_identity(name)),
);
identifiers.part_names.extend(
package
.content_types
.overrides
.keys()
.map(|name| part_name_identity(name)),
);
identifiers.scan_relationships("/", &package.package_rels.items)?;
let mut relationship_owners = package.part_rels.iter().collect::<Vec<_>>();
relationship_owners.sort_by_key(|(owner, _)| owner.as_str());
for (owner, relationships) in relationship_owners {
identifiers.scan_relationships(owner, &relationships.items)?;
}
let mut identity_parts: HashMap<String, HashSet<IdentifierXmlCategory>> = HashMap::new();
if let Some(main_part) = package.main_document_part() {
identity_parts
.entry(part_name_identity(&main_part))
.or_default()
.extend([
IdentifierXmlCategory::Bookmark,
IdentifierXmlCategory::Drawing,
]);
if let Some(relationships) = package.get_part_rels(&main_part) {
for relationship in &relationships.items {
let categories: &[IdentifierXmlCategory] = match relationship.rel_type.as_str()
{
rel_types::HEADER
| rel_types::FOOTER
| rel_types::FOOTNOTES
| rel_types::ENDNOTES
| rel_types::GLOSSARY_DOCUMENT => &[
IdentifierXmlCategory::Bookmark,
IdentifierXmlCategory::Drawing,
],
rel_types::COMMENTS => &[
IdentifierXmlCategory::Bookmark,
IdentifierXmlCategory::Comment,
IdentifierXmlCategory::Drawing,
],
rel_types::NUMBERING => &[
IdentifierXmlCategory::AbstractNumbering,
IdentifierXmlCategory::NumberingInstance,
],
_ => &[],
};
if !categories.is_empty() && relationship_is_internal(relationship) {
identity_parts
.entry(part_name_identity(&OpcPackage::resolve_rel_target(
&main_part,
&relationship.target,
)))
.or_default()
.extend(categories.iter().copied());
}
}
}
}
let mut parts = package.parts.iter().collect::<Vec<_>>();
parts.sort_by_key(|(part_name, _)| part_name.as_str());
for (part_name, bytes) in parts {
if let Some(categories) = identity_parts.get(&part_name_identity(part_name))
&& identifier_xml_is_well_formed(bytes)
{
let mut part_drawing_ids = HashMap::new();
identifiers
.scan_xml_definitions(bytes, categories, &mut part_drawing_ids)
.map_err(|error| {
Error::Other(format!(
"cannot scan identifiers in XML part {part_name}: {error}"
))
})?;
let repeated = part_drawing_ids
.iter()
.filter(|(_, count)| **count > 1)
.map(|(id, count)| (*id, *count))
.collect::<HashMap<_, _>>();
if !repeated.is_empty() {
identifiers
.repeated_drawing_ids
.insert(part_name_identity(part_name), repeated);
}
identifiers.drawing_ids.extend(part_drawing_ids.into_keys());
}
}
identifiers.preserved_relationship_ids = identifiers.relationship_ids.clone();
identifiers.preserved_bookmark_ids = identifiers.bookmark_ids.clone();
identifiers.preserved_comment_ids = identifiers.comment_ids.clone();
identifiers.preserved_drawing_ids = identifiers.drawing_ids.clone();
identifiers.preserved_abstract_numbering_ids = identifiers.abstract_numbering_ids.clone();
identifiers.preserved_numbering_instance_ids = identifiers.numbering_instance_ids.clone();
identifiers.preserved_part_names = identifiers.part_names.clone();
Ok(identifiers)
}
fn scan_relationships(
&mut self,
owner: &str,
relationships: &[oxml_opc::relationship::Relationship],
) -> Result<()> {
let owner_identity = relationship_owner_identity(owner);
for relationship in relationships {
if !self
.relationship_ids
.entry(owner_identity.clone())
.or_default()
.insert(relationship.id.clone())
{
return Err(Error::Other(format!(
"duplicate relationship id {} in owner {owner}",
relationship.id
)));
}
if relationship_is_internal(relationship) {
self.part_names
.insert(part_name_identity(&OpcPackage::resolve_rel_target(
owner,
&relationship.target,
)));
}
}
Ok(())
}
fn scan_xml_definitions(
&mut self,
xml: &[u8],
categories: &HashSet<IdentifierXmlCategory>,
part_drawing_ids: &mut HashMap<u32, usize>,
) -> Result<()> {
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
loop {
let (namespace, event) = match reader.read_resolved_event_into(&mut buffer) {
Ok(value) => value,
Err(error) => return Err(Error::Other(error.to_string())),
};
match event {
Event::Start(ref element) | Event::Empty(ref element) => {
let local = element.local_name();
let is_drawing = matches!(
&namespace,
ResolveResult::Bound(namespace)
if namespace.as_ref() == drawing_ns::WP.as_bytes()
);
let is_word = matches!(
&namespace,
ResolveResult::Bound(namespace)
if namespace.as_ref() == WORD_NAMESPACE.as_bytes()
);
let category = if is_drawing && local.as_ref() == b"docPr" {
Some(IdentifierXmlCategory::Drawing)
} else if is_word && local.as_ref() == b"bookmarkStart" {
Some(IdentifierXmlCategory::Bookmark)
} else if is_word && local.as_ref() == b"comment" {
Some(IdentifierXmlCategory::Comment)
} else if is_word && local.as_ref() == b"abstractNum" {
Some(IdentifierXmlCategory::AbstractNumbering)
} else if is_word && local.as_ref() == b"num" {
Some(IdentifierXmlCategory::NumberingInstance)
} else {
None
};
if let Some(category) = category.filter(|value| categories.contains(value)) {
self.scan_xml_id(&reader, element, category, part_drawing_ids)?;
}
}
Event::Eof => return Ok(()),
_ => {}
}
buffer.clear();
}
}
fn scan_xml_id(
&mut self,
reader: &NsReader<&[u8]>,
element: &BytesStart<'_>,
category: IdentifierXmlCategory,
part_drawing_ids: &mut HashMap<u32, usize>,
) -> Result<()> {
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| Error::Other(error.to_string()))?;
let (namespace, local) = reader.resolver().resolve_attribute(attribute.key);
let accepted = match category {
IdentifierXmlCategory::Drawing => {
matches!(namespace, ResolveResult::Unbound) && local.as_ref() == b"id"
}
IdentifierXmlCategory::Bookmark | IdentifierXmlCategory::Comment => {
matches!(namespace, ResolveResult::Bound(value) if value.as_ref() == WORD_NAMESPACE.as_bytes())
&& local.as_ref() == b"id"
}
IdentifierXmlCategory::AbstractNumbering => {
matches!(namespace, ResolveResult::Bound(value) if value.as_ref() == WORD_NAMESPACE.as_bytes())
&& local.as_ref() == b"abstractNumId"
}
IdentifierXmlCategory::NumberingInstance => {
matches!(namespace, ResolveResult::Bound(value) if value.as_ref() == WORD_NAMESPACE.as_bytes())
&& local.as_ref() == b"numId"
}
};
if !accepted {
continue;
}
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|error| Error::Other(error.to_string()))?;
match category {
IdentifierXmlCategory::Drawing => {
let value = value
.parse::<u32>()
.map_err(|_| Error::Other(format!("invalid drawing id {value}")))?;
*part_drawing_ids.entry(value).or_default() += 1;
}
IdentifierXmlCategory::Bookmark => {
let value = value
.parse::<i32>()
.map_err(|_| Error::Other(format!("invalid bookmark id {value}")))?;
Self::insert_unique(&mut self.bookmark_ids, value, "bookmark")?;
}
IdentifierXmlCategory::Comment => {
let value = value
.parse::<i32>()
.map_err(|_| Error::Other(format!("invalid comment id {value}")))?;
Self::insert_unique(&mut self.comment_ids, value, "comment")?;
}
IdentifierXmlCategory::AbstractNumbering => {
let value = value.parse::<u32>().map_err(|_| {
Error::Other(format!("invalid abstract numbering id {value}"))
})?;
Self::insert_unique(
&mut self.abstract_numbering_ids,
value,
"abstract numbering",
)?;
}
IdentifierXmlCategory::NumberingInstance => {
let value = value.parse::<u32>().map_err(|_| {
Error::Other(format!("invalid numbering instance id {value}"))
})?;
Self::insert_unique(
&mut self.numbering_instance_ids,
value,
"numbering instance",
)?;
}
}
break;
}
Ok(())
}
/// Refuse a `wp:docPr/@id` occurrence that a staged edit added to an id
/// its part already uses. Repetitions the source document had are kept.
fn reject_new_repeated_drawing_ids(&self, source: &Self) -> Result<()> {
let mut added = self
.repeated_drawing_ids
.iter()
.flat_map(|(part_name, repeated)| {
repeated.iter().filter_map(move |(id, count)| {
let kept = source
.repeated_drawing_ids
.get(part_name)
.and_then(|repeated| repeated.get(id))
.copied()
.unwrap_or(1);
(*count > kept).then_some((part_name, *id))
})
})
.collect::<Vec<_>>();
added.sort_unstable();
match added.first() {
Some((part_name, id)) => Err(Error::Other(format!(
"duplicate drawing id {id} in authored XML of part {part_name}"
))),
None => Ok(()),
}
}
fn insert_unique<T: std::hash::Hash + Eq + std::fmt::Display + Copy>(
occupied: &mut HashSet<T>,
value: T,
category: &str,
) -> Result<()> {
if occupied.insert(value) {
Ok(())
} else {
Err(Error::Other(format!(
"duplicate {category} id {value} in imported or preserved XML"
)))
}
}
pub(crate) fn reserve_bookmark_id(&mut self) -> Result<i32> {
let id = reserve_lowest_i32(&mut self.bookmark_ids, 0, "bookmark")?;
self.authored_bookmark_ids.insert(id);
Ok(id)
}
pub(crate) fn reserve_preferred_bookmark_id(&mut self, preferred: i32) -> Result<i32> {
if preferred >= 0 && self.bookmark_ids.insert(preferred) {
Ok(preferred)
} else {
reserve_lowest_i32(&mut self.bookmark_ids, 0, "bookmark")
}
}
pub(crate) fn restore_authored_bookmark(&mut self, id: i32, name: &str) {
self.authored_toc_bookmark_ids.insert(id);
self.authored_bookmark_names.insert(name.to_owned());
}
pub(crate) fn is_authored_bookmark(&self, id: i32) -> bool {
self.authored_toc_bookmark_ids.contains(&id)
}
pub(crate) fn authored_bookmark_names(&self) -> &HashSet<String> {
&self.authored_bookmark_names
}
pub(crate) fn reserve_comment_id(&mut self) -> Result<i32> {
let id = reserve_i32(&mut self.comment_ids, 0, "comment")?;
self.authored_comment_ids.insert(id);
Ok(id)
}
pub(crate) fn reserve_drawing_id(&mut self) -> Result<u32> {
reserve_u32(&mut self.drawing_ids, 1, "drawing")
}
fn reserve_numbering_ids(&mut self) -> Result<(u32, u32)> {
let mut abstract_ids = self.abstract_numbering_ids.clone();
let mut instance_ids = self.numbering_instance_ids.clone();
let abstract_id = reserve_u32(&mut abstract_ids, 0, "abstract numbering")?;
let instance_id = reserve_u32(&mut instance_ids, 1, "numbering instance")?;
self.abstract_numbering_ids = abstract_ids;
self.numbering_instance_ids = instance_ids;
Ok((abstract_id, instance_id))
}
fn reserve_abstract_numbering_id(&mut self) -> Result<u32> {
reserve_u32(&mut self.abstract_numbering_ids, 0, "abstract numbering")
}
fn reserve_numbering_instance_id(&mut self) -> Result<u32> {
reserve_u32(&mut self.numbering_instance_ids, 1, "numbering instance")
}
pub(crate) fn reserve_relationship_id_checked(&mut self, owner: &str) -> Result<String> {
self.ensure_relationship_capacity(owner, 1)?;
let owner = relationship_owner_identity(owner);
let occupied = self.relationship_ids.entry(owner).or_default();
let maximum = occupied
.iter()
.filter_map(|id| id.strip_prefix("rId")?.parse::<u32>().ok())
.max();
let mut candidate = maximum.map_or(Ok(0), |value| {
value
.checked_add(1)
.ok_or_else(|| Error::Other("relationship id range is exhausted".to_owned()))
})?;
loop {
let id = format!("rId{candidate}");
if occupied.insert(id.clone()) {
return Ok(id);
}
candidate = candidate
.checked_add(1)
.ok_or_else(|| Error::Other("relationship id range is exhausted".to_owned()))?;
}
}
fn reserve_bundle_relationship_id_checked(
&mut self,
owner: &str,
rel_type: &str,
) -> Result<String> {
let stem = match rel_type {
rel_types::STYLES => "rdocxDeferredStyles",
rel_types::NUMBERING => "rdocxDeferredNumbering",
rel_types::SETTINGS => "rdocxDeferredSettings",
rel_types::FOOTNOTES => "rdocxDeferredFootnotes",
rel_types::ENDNOTES => "rdocxDeferredEndnotes",
rel_types::GLOSSARY_DOCUMENT => "rdocxDeferredGlossary",
rel_types::COMMENTS => "rdocxDeferredComments",
crate::comments::COMMENTS_EXTENDED_REL_TYPE => "rdocxDeferredCommentsExtended",
_ => {
return Err(Error::Other(format!(
"relationship type {rel_type} is not a document bundle"
)));
}
};
self.ensure_relationship_capacity(owner, 1)?;
let owner = relationship_owner_identity(owner);
let occupied = self.relationship_ids.entry(owner.clone()).or_default();
let mut ordinal = 0u32;
loop {
let id = if ordinal == 0 {
stem.to_owned()
} else {
format!("{stem}{ordinal}")
};
if occupied.insert(id.clone()) {
self.authored_bundle_relationship_ids
.entry(owner.clone())
.or_default()
.insert(id.clone());
self.provisional_relationship_ids
.entry(owner.clone())
.or_default()
.insert(id.clone());
return Ok(id);
}
ordinal = ordinal.checked_add(1).ok_or_else(|| {
Error::Other("bundle relationship id range is exhausted".to_owned())
})?;
}
}
pub(crate) fn reserve_requested_relationship_id_checked(
&mut self,
owner: &str,
requested: &str,
) -> Result<String> {
let owner = relationship_owner_identity(owner);
let numeric_request = requested
.strip_prefix("rId")
.and_then(|value| value.parse::<u32>().ok());
let is_available = !self
.relationship_ids
.get(&owner)
.is_some_and(|occupied| occupied.contains(requested));
if is_available {
if numeric_request.is_some() {
let provisional_count = self
.provisional_relationship_ids
.get(&owner)
.map_or(0, HashSet::len);
let maximum = self
.relationship_ids
.get(&owner)
.into_iter()
.flatten()
.filter_map(|id| id.strip_prefix("rId")?.parse::<u32>().ok())
.chain(numeric_request)
.max();
ensure_numeric_relationship_capacity(maximum, provisional_count)?;
}
self.relationship_ids
.entry(owner.clone())
.or_default()
.insert(requested.to_owned());
Ok(requested.to_owned())
} else {
self.reserve_relationship_id_checked(&owner)
}
}
fn ensure_relationship_capacity(&self, owner: &str, additional: usize) -> Result<()> {
let owner = relationship_owner_identity(owner);
let maximum = self
.relationship_ids
.get(&owner)
.into_iter()
.flatten()
.filter_map(|id| id.strip_prefix("rId")?.parse::<u32>().ok())
.max();
let provisional_count = self
.provisional_relationship_ids
.get(&owner)
.map_or(0, HashSet::len)
.checked_add(additional)
.ok_or_else(|| Error::Other("relationship id range is exhausted".to_owned()))?;
ensure_numeric_relationship_capacity(maximum, provisional_count)
}
fn reserve_part_name(
&mut self,
directory: &str,
stem: &str,
extension: &str,
) -> Result<String> {
let mut namer = oxml_media::MediaNamer::scan(
directory,
stem,
self.part_names.iter().map(String::as_str),
);
loop {
let part_name = namer.next_part_name(extension);
if self.part_names.insert(part_name_identity(&part_name)) {
return Ok(part_name);
}
}
}
pub(crate) fn reserve_preferred_part_name(&mut self, preferred: &str) -> Result<String> {
if !preferred.starts_with('/') {
return Err(Error::Other(format!(
"preferred part name {preferred} is not absolute"
)));
}
let (directory, filename) = preferred.rsplit_once('/').ok_or_else(|| {
Error::Other(format!("preferred part name {preferred} has no directory"))
})?;
let (name, extension) = filename.rsplit_once('.').ok_or_else(|| {
Error::Other(format!("preferred part name {preferred} has no extension"))
})?;
if name.is_empty() || extension.is_empty() {
return Err(Error::Other(format!(
"preferred part name {preferred} has an empty stem or extension"
)));
}
let digit_start = name
.bytes()
.rposition(|byte| !byte.is_ascii_digit())
.map_or(0, |index| index + 1);
let suffix = &name[digit_start..];
let stem = suffix
.parse::<usize>()
.ok()
.filter(|suffix| *suffix > 0)
.map_or(name, |_| &name[..digit_start]);
if stem.is_empty() {
return Err(Error::Other(format!(
"preferred part name {preferred} has no family stem"
)));
}
if self.part_names.insert(part_name_identity(preferred)) {
return Ok(preferred.to_owned());
}
self.reserve_part_name(directory, stem, extension)
}
fn register_content_type_default(&mut self, extension: &str) {
let extension = extension.to_ascii_lowercase();
self.content_type_defaults.insert(extension.clone());
self.authored_content_type_defaults.insert(extension);
}
pub(crate) fn register_content_type_override(&mut self, part_name: &str) {
self.content_type_overrides
.insert(part_name.to_ascii_lowercase());
}
fn register_nested_story_relationship(&mut self, story: &StoryId, relationship_id: String) {
self.authored_nested_story_relationship_ids
.entry(relationship_owner_identity(&story.part_name))
.or_default()
.insert(relationship_id);
}
fn register_nested_story_drawing(&mut self, story: &StoryId, drawing_id: u32) {
self.authored_nested_story_drawing_ids
.entry(relationship_owner_identity(&story.part_name))
.or_default()
.insert(drawing_id);
}
pub(crate) fn retire_authored_story_relationships(
&mut self,
owner: &str,
relationship_ids: impl IntoIterator<Item = String>,
) {
let owner = relationship_owner_identity(owner);
let preserved = self
.preserved_relationship_ids
.get(&owner)
.cloned()
.unwrap_or_default();
let removed = relationship_ids
.into_iter()
.filter(|id| !preserved.contains(id))
.collect::<HashSet<_>>();
if let Some(occupied) = self.relationship_ids.get_mut(&owner) {
occupied.retain(|id| !removed.contains(id));
if occupied.is_empty() {
self.relationship_ids.remove(&owner);
}
}
if let Some(authored) = self.authored_story_relationship_ids.get_mut(&owner) {
authored.retain(|id| !removed.contains(id));
if authored.is_empty() {
self.authored_story_relationship_ids.remove(&owner);
}
}
self.authored_nested_story_relationship_ids
.retain(|nested_owner, authored| {
if nested_owner == &owner {
authored.retain(|id| !removed.contains(id));
}
!authored.is_empty()
});
if let Some(authored) = self.authored_bundle_relationship_ids.get_mut(&owner) {
authored.retain(|id| !removed.contains(id));
if authored.is_empty() {
self.authored_bundle_relationship_ids.remove(&owner);
}
}
if let Some(provisional) = self.provisional_relationship_ids.get_mut(&owner) {
provisional.retain(|id| !removed.contains(id));
if provisional.is_empty() {
self.provisional_relationship_ids.remove(&owner);
}
}
}
pub(crate) fn retire_authored_part(&mut self, part_name: &str) {
let identity = part_name_identity(part_name);
if !self.preserved_part_names.contains(&identity) {
self.part_names.remove(&identity);
}
self.content_type_overrides
.remove(&part_name.to_ascii_lowercase());
}
pub(crate) fn retire_authored_comment_ids(
&mut self,
comment_ids: impl IntoIterator<Item = i32>,
) {
for id in comment_ids {
if self.authored_comment_ids.remove(&id) && !self.preserved_comment_ids.contains(&id) {
self.comment_ids.remove(&id);
}
}
}
pub(crate) fn relationship_is_preserved(&self, owner: &str, id: &str) -> bool {
let owner = relationship_owner_identity(owner);
self.preserved_relationship_ids
.get(&owner)
.is_some_and(|ids| ids.contains(id))
}
fn part_is_preserved(&self, part_name: &str) -> bool {
self.preserved_part_names
.contains(&part_name_identity(part_name))
}
pub(crate) fn reserve_fragment_part_name(&mut self, preferred: &str) -> Result<String> {
if self.part_names.insert(part_name_identity(preferred)) {
return Ok(preferred.to_owned());
}
let (stem, extension) = preferred
.rsplit_once('.')
.map_or((preferred, ""), |(stem, extension)| (stem, extension));
let mut ordinal = 1u64;
loop {
let candidate = if extension.is_empty() {
format!("{stem}-merge-{ordinal}")
} else {
format!("{stem}-merge-{ordinal}.{extension}")
};
if self.part_names.insert(part_name_identity(&candidate)) {
return Ok(candidate);
}
ordinal = ordinal.checked_add(1).ok_or_else(|| {
Error::Other("rich mail merge part-name range is exhausted".to_owned())
})?;
}
}
pub(crate) fn preserve_fragment_part(&mut self, part_name: &str) {
self.preserved_part_names
.insert(part_name_identity(part_name));
}
pub(crate) fn observe_package_graph(&mut self, package: &OpcPackage) -> Result<()> {
for part_name in package
.parts
.keys()
.chain(package.part_rels.keys())
.chain(package.content_types.overrides.keys())
{
let identity = part_name_identity(part_name);
if self.part_names.insert(identity.clone()) {
self.preserved_part_names.insert(identity);
}
}
self.content_type_defaults.extend(
package
.content_types
.defaults
.keys()
.map(|extension| extension.to_ascii_lowercase()),
);
self.content_type_overrides.extend(
package
.content_types
.overrides
.keys()
.map(|part_name| part_name.to_ascii_lowercase()),
);
let mut owners = std::iter::once(("/", &package.package_rels))
.chain(
package
.part_rels
.iter()
.map(|(owner, relationships)| (owner.as_str(), relationships)),
)
.collect::<Vec<_>>();
owners.sort_by_key(|(owner, _)| *owner);
for (owner, relationships) in owners {
let owner_identity = relationship_owner_identity(owner);
let mut seen = HashSet::new();
for relationship in &relationships.items {
if !seen.insert(relationship.id.clone()) {
return Err(Error::Other(format!(
"duplicate relationship id {} in owner {owner}",
relationship.id
)));
}
let occupied = self
.relationship_ids
.entry(owner_identity.clone())
.or_default();
occupied.insert(relationship.id.clone());
if relationship_is_internal(relationship) {
let part_name = part_name_identity(&OpcPackage::resolve_rel_target(
owner,
&relationship.target,
));
if self.part_names.insert(part_name.clone()) {
self.preserved_part_names.insert(part_name);
}
}
}
}
Ok(())
}
fn reconcile_provenance(&mut self, source: &Self) {
self.repeated_drawing_ids
.clone_from(&source.repeated_drawing_ids);
self.preserved_relationship_ids = intersect_relationship_registry(
&source.preserved_relationship_ids,
&self.relationship_ids,
);
self.authored_story_relationship_ids = intersect_relationship_registry(
&source.authored_story_relationship_ids,
&self.relationship_ids,
);
self.authored_nested_story_relationship_ids = source
.authored_nested_story_relationship_ids
.iter()
.filter_map(|(owner, ids)| {
let occupied = self.relationship_ids.get(owner)?;
let retained = ids.intersection(occupied).cloned().collect::<HashSet<_>>();
(!retained.is_empty()).then(|| (owner.clone(), retained))
})
.collect();
self.authored_nested_story_drawing_ids = source
.authored_nested_story_drawing_ids
.iter()
.filter_map(|(owner, ids)| {
let retained = ids
.intersection(&self.drawing_ids)
.copied()
.collect::<HashSet<_>>();
(!retained.is_empty()).then(|| (owner.clone(), retained))
})
.collect();
self.authored_bundle_relationship_ids = intersect_relationship_registry(
&source.authored_bundle_relationship_ids,
&self.relationship_ids,
);
self.provisional_relationship_ids = intersect_relationship_registry(
&source.provisional_relationship_ids,
&self.relationship_ids,
);
self.preserved_bookmark_ids = source
.preserved_bookmark_ids
.intersection(&self.bookmark_ids)
.copied()
.collect();
self.preserved_comment_ids = source
.preserved_comment_ids
.intersection(&self.comment_ids)
.copied()
.collect();
self.preserved_drawing_ids = source
.preserved_drawing_ids
.intersection(&self.drawing_ids)
.copied()
.collect();
self.preserved_abstract_numbering_ids = source
.preserved_abstract_numbering_ids
.intersection(&self.abstract_numbering_ids)
.copied()
.collect();
self.preserved_numbering_instance_ids = source
.preserved_numbering_instance_ids
.intersection(&self.numbering_instance_ids)
.copied()
.collect();
self.preserved_part_names = source
.preserved_part_names
.intersection(&self.part_names)
.cloned()
.collect();
self.authored_bookmark_ids = source
.authored_bookmark_ids
.intersection(&self.bookmark_ids)
.copied()
.collect();
self.authored_comment_ids = source
.authored_comment_ids
.intersection(&self.comment_ids)
.copied()
.collect();
self.authored_toc_bookmark_ids = source
.authored_toc_bookmark_ids
.intersection(&self.bookmark_ids)
.copied()
.collect();
self.authored_bookmark_names = source.authored_bookmark_names.clone();
self.authored_story_parts = source
.authored_story_parts
.iter()
.filter(|part_name| self.part_names.contains(&part_name_identity(part_name)))
.cloned()
.collect();
self.authored_content_type_defaults = source
.authored_content_type_defaults
.intersection(&self.content_type_defaults)
.cloned()
.collect();
}
}
fn intersect_relationship_registry(
source: &HashMap<String, HashSet<String>>,
occupied: &HashMap<String, HashSet<String>>,
) -> HashMap<String, HashSet<String>> {
source
.iter()
.filter_map(|(owner, ids)| {
let owner = relationship_owner_identity(owner);
let retained = ids
.intersection(occupied.get(&owner)?)
.cloned()
.collect::<HashSet<_>>();
(!retained.is_empty()).then_some((owner, retained))
})
.collect()
}
fn identifier_xml_is_well_formed(xml: &[u8]) -> bool {
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
loop {
match reader.read_resolved_event_into(&mut buffer) {
Ok((_, Event::Eof)) => return true,
Ok(_) => buffer.clear(),
Err(_) => return false,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
enum IdentifierXmlCategory {
Drawing,
Bookmark,
Comment,
AbstractNumbering,
NumberingInstance,
}
fn reserve_lowest_i32(occupied: &mut HashSet<i32>, minimum: i32, category: &str) -> Result<i32> {
let candidate = (minimum..=i32::MAX)
.find(|value| !occupied.contains(value))
.ok_or_else(|| Error::Other(format!("{category} id range is exhausted")))?;
if !occupied.insert(candidate) {
return Err(Error::Other(format!(
"pending {category} id collision for {candidate}"
)));
}
Ok(candidate)
}
fn reserve_i32(occupied: &mut HashSet<i32>, minimum: i32, category: &str) -> Result<i32> {
let candidate = occupied
.iter()
.copied()
.max()
.and_then(|value| value.checked_add(1))
.filter(|value| *value >= minimum)
.or_else(|| (minimum..=i32::MAX).find(|value| !occupied.contains(value)))
.ok_or_else(|| Error::Other(format!("{category} id range is exhausted")))?;
if !occupied.insert(candidate) {
return Err(Error::Other(format!(
"pending {category} id collision for {candidate}"
)));
}
Ok(candidate)
}
fn reserve_u32(occupied: &mut HashSet<u32>, minimum: u32, category: &str) -> Result<u32> {
let candidate = occupied
.iter()
.copied()
.max()
.and_then(|value| value.checked_add(1))
.filter(|value| *value >= minimum)
.or_else(|| occupied.is_empty().then_some(minimum))
.ok_or_else(|| Error::Other(format!("{category} id range is exhausted")))?;
if !occupied.insert(candidate) {
return Err(Error::Other(format!(
"pending {category} id collision for {candidate}"
)));
}
Ok(candidate)
}
fn ensure_numeric_relationship_capacity(maximum: Option<u32>, required: usize) -> Result<()> {
let available = maximum.map_or(u64::from(u32::MAX) + 1, |value| u64::from(u32::MAX - value));
if u64::try_from(required).map_or(true, |required| required > available) {
Err(Error::Other(
"relationship id range is exhausted".to_owned(),
))
} else {
Ok(())
}
}
fn reserve_relationship_from_cursor(
occupied: &mut HashSet<String>,
cursor: &mut u32,
) -> Result<String> {
loop {
let id = format!("rId{cursor}");
if occupied.insert(id.clone()) {
if let Some(next) = cursor.checked_add(1) {
*cursor = next;
}
return Ok(id);
}
*cursor = cursor
.checked_add(1)
.ok_or_else(|| Error::Other("relationship id range is exhausted".to_owned()))?;
}
}
fn reserve_part_from_set(
occupied: &mut HashSet<String>,
directory: &str,
stem: &str,
extension: &str,
) -> Result<String> {
let mut namer =
oxml_media::MediaNamer::scan(directory, stem, occupied.iter().map(String::as_str));
loop {
let part = namer.next_part_name(extension);
if occupied.insert(part_name_identity(&part)) {
return Ok(part);
}
}
}
pub(crate) fn part_name_identity(part_name: &str) -> String {
part_name.to_ascii_lowercase()
}
fn relationship_owner_identity(owner: &str) -> String {
if owner == "/" {
"/".to_owned()
} else {
part_name_identity(owner)
}
}
fn hdr_ftr_type_order(value: HdrFtrType) -> u8 {
match value {
HdrFtrType::Default => 0,
HdrFtrType::First => 1,
HdrFtrType::Even => 2,
}
}
pub(crate) fn xml_relationship_ids_in_order(xml: &[u8]) -> Result<Vec<String>> {
xml_relationship_ids_in_order_with_bindings(xml, &[])
}
fn authored_relationship_ids_in_source_order(
xml: &[u8],
relationship_ids: &HashSet<String>,
) -> Option<Vec<String>> {
let mut seen = HashSet::new();
let all = xml_relationship_ids_in_order(xml).ok()?;
let ordered = all
.iter()
.filter(|id| relationship_ids.contains(*id) && seen.insert((*id).clone()))
.cloned()
.collect::<Vec<_>>();
(ordered.len() == relationship_ids.len()).then_some(ordered)
}
fn xml_relationship_ids_in_order_with_bindings(
xml: &[u8],
inherited_bindings: &[(String, String)],
) -> Result<Vec<String>> {
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
let mut ids = Vec::new();
loop {
let (_, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("invalid story XML: {error}")))?;
match event {
Event::Start(element) | Event::Empty(element) => {
for attribute in element.attributes() {
let attribute = attribute
.map_err(|error| Error::Other(format!("invalid story XML: {error}")))?;
let (namespace, _) = reader.resolver().resolve_attribute(attribute.key);
let inherited_relationship_prefix =
matches!(namespace, ResolveResult::Unknown(_))
&& attribute.key.as_ref().contains(&b':')
&& attribute
.key
.as_ref()
.split(|byte| *byte == b':')
.next()
.and_then(|prefix| std::str::from_utf8(prefix).ok())
.is_some_and(|prefix| {
let declaration = format!("xmlns:{prefix}");
inherited_bindings.iter().any(|(name, value)| {
name == &declaration && value == drawing_ns::R
})
});
if matches!(namespace, ResolveResult::Bound(value) if value.as_ref() == drawing_ns::R.as_bytes())
|| inherited_relationship_prefix
{
let id = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|error| {
Error::Other(format!("invalid story relationship id: {error}"))
})?
.into_owned();
if !ids.contains(&id) {
ids.push(id);
}
}
}
}
Event::Eof => return Ok(ids),
_ => {}
}
buffer.clear();
}
}
fn rewrite_authored_doc_pr_ids(xml: &[u8], occupied: &mut HashSet<u32>) -> Result<Vec<u8>> {
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
let mut edits = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let event = reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("invalid story XML: {error}")))?;
let after = reader.buffer_position() as usize;
match event {
Event::Start(element) | Event::Empty(element) => {
let namespace = reader.resolver().resolve_element(element.name()).0;
let is_doc_pr = matches!(namespace, ResolveResult::Bound(value) if value.as_ref() == drawing_ns::WP.as_bytes())
&& element.local_name().as_ref() == b"docPr";
let mut replaced = false;
for attribute in element.attributes() {
let attribute = attribute
.map_err(|error| Error::Other(format!("invalid story XML: {error}")))?;
let (attribute_namespace, local) =
reader.resolver().resolve_attribute(attribute.key);
if is_doc_pr
&& matches!(attribute_namespace, ResolveResult::Unbound)
&& local.as_ref() == b"id"
{
let id = reserve_u32(occupied, 1, "drawing")?;
let Some((start, end)) =
story_attribute_value_span(&xml[before..after], attribute.key.as_ref())
else {
return Err(Error::Other(
"story drawing id source was not found".to_owned(),
));
};
edits.push((before + start, before + end, id.to_string().into_bytes()));
replaced = true;
}
}
if is_doc_pr && !replaced {
return Err(Error::Other(
"wp:docPr requires an unqualified id attribute".to_owned(),
));
}
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
let mut updated = xml.to_vec();
edits.sort_by_key(|(start, _, _)| *start);
for (start, end, replacement) in edits.into_iter().rev() {
updated.splice(start..end, replacement);
}
Ok(updated)
}
enum AuthoredMainDrawing {
Modeled,
Nested,
}
fn authored_drawing_slots_in_source_order(
xml: &[u8],
modeled_ids: &[u32],
nested_image_relationship_ids: &HashSet<String>,
) -> Result<Vec<(AuthoredMainDrawing, Range<usize>, u32)>> {
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
let mut modeled_remaining = HashMap::new();
for id in modeled_ids {
*modeled_remaining.entry(*id).or_insert(0usize) += 1;
}
let mut depth = 0usize;
let mut current_depth = None;
let mut current_doc_pr = None;
let mut current_is_nested = false;
let mut ordered = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let event = reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("invalid story XML: {error}")))?;
let after = reader.buffer_position() as usize;
match event {
Event::Start(ref element) | Event::Empty(ref element) => {
let namespace = reader.resolver().resolve_element(element.name()).0;
let local = element.local_name();
let word_drawing = matches!(namespace, ResolveResult::Bound(Namespace(value)) if value == drawing_ns::WP.as_bytes());
let drawing = matches!(namespace, ResolveResult::Bound(Namespace(value)) if value == drawing_ns::A.as_bytes());
if matches!(&event, Event::Start(_))
&& word_drawing
&& matches!(local.as_ref(), b"inline" | b"anchor")
&& current_depth.is_none()
{
current_depth = Some(depth);
}
if current_depth.is_some() {
if word_drawing && local.as_ref() == b"docPr" {
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| {
Error::Other(format!("invalid story XML: {error}"))
})?;
let (namespace, local) =
reader.resolver().resolve_attribute(attribute.key);
if matches!(namespace, ResolveResult::Unbound)
&& local.as_ref() == b"id"
{
let id = attribute
.decoded_and_normalized_value(
XmlVersion::Implicit1_0,
reader.decoder(),
)
.map_err(|error| {
Error::Other(format!("invalid story drawing id: {error}"))
})?
.parse::<u32>()
.map_err(|_| {
Error::Other("invalid story drawing id".to_owned())
})?;
let (start, end) = story_attribute_value_span(
&xml[before..after],
attribute.key.as_ref(),
)
.ok_or_else(|| {
Error::Other("story drawing id source was not found".to_owned())
})?;
current_doc_pr = Some((id, before + start..before + end));
}
}
}
if drawing && local.as_ref() == b"blip" {
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| {
Error::Other(format!("invalid story XML: {error}"))
})?;
let (namespace, local) =
reader.resolver().resolve_attribute(attribute.key);
if matches!(namespace, ResolveResult::Bound(value) if value.as_ref() == drawing_ns::R.as_bytes())
&& local.as_ref() == b"embed"
{
let id = attribute
.decoded_and_normalized_value(
XmlVersion::Implicit1_0,
reader.decoder(),
)
.map_err(|error| {
Error::Other(format!(
"invalid story relationship id: {error}"
))
})?;
if nested_image_relationship_ids.contains(id.as_ref()) {
current_is_nested = true;
}
}
}
}
}
if matches!(&event, Event::Start(_)) {
depth += 1;
}
}
Event::End(element) => {
depth = depth.saturating_sub(1);
let namespace = reader.resolver().resolve_element(element.name()).0;
let word_drawing = matches!(namespace, ResolveResult::Bound(Namespace(value)) if value == drawing_ns::WP.as_bytes());
if word_drawing
&& matches!(element.local_name().as_ref(), b"inline" | b"anchor")
&& current_depth == Some(depth)
{
current_depth = None;
let Some((id, span)) = current_doc_pr.take() else {
if current_is_nested {
return Err(Error::Other(
"authored picture has no wp:docPr identity".to_owned(),
));
}
buffer.clear();
continue;
};
if current_is_nested {
ordered.push((AuthoredMainDrawing::Nested, span, id));
} else if let Some(remaining) = modeled_remaining.get_mut(&id)
&& *remaining > 0
{
*remaining -= 1;
ordered.push((AuthoredMainDrawing::Modeled, span, id));
}
current_is_nested = false;
}
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
if modeled_remaining.values().any(|remaining| *remaining != 0) {
return Err(Error::Other(
"authored drawing occurrences changed during source-order allocation".to_owned(),
));
}
Ok(ordered)
}
fn drawing_ids_in_xml(xml: &[u8]) -> Result<HashSet<u32>> {
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
let mut ids = HashSet::new();
loop {
match reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("invalid story XML: {error}")))?
{
Event::Start(element) | Event::Empty(element) => {
let namespace = reader.resolver().resolve_element(element.name()).0;
if matches!(namespace, ResolveResult::Bound(value) if value.as_ref() == drawing_ns::WP.as_bytes())
&& element.local_name().as_ref() == b"docPr"
{
for attribute in element.attributes() {
let attribute = attribute
.map_err(|error| Error::Other(format!("invalid story XML: {error}")))?;
let (attribute_namespace, local) =
reader.resolver().resolve_attribute(attribute.key);
if matches!(attribute_namespace, ResolveResult::Unbound)
&& local.as_ref() == b"id"
{
let value = attribute
.decoded_and_normalized_value(
XmlVersion::Implicit1_0,
reader.decoder(),
)
.map_err(|error| {
Error::Other(format!("invalid drawing id: {error}"))
})?
.parse::<u32>()
.map_err(|_| Error::Other("invalid drawing id".to_owned()))?;
ids.insert(value);
}
}
}
}
Event::Eof => return Ok(ids),
_ => {}
}
buffer.clear();
}
}
pub(crate) fn remap_xml_relationship_ids(
xml: &[u8],
remap: &HashMap<String, String>,
) -> Result<Vec<u8>> {
remap_xml_relationship_ids_with_bindings(xml, remap, &[])
}
pub(crate) fn uniquify_drawing_ids_in_xml(xml: &[u8]) -> Result<Vec<u8>> {
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
let mut occurrences = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let event = reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("invalid story XML: {error}")))?;
let after = reader.buffer_position() as usize;
match event {
Event::Start(element) | Event::Empty(element) => {
let namespace = reader.resolver().resolve_element(element.name()).0;
if matches!(namespace, ResolveResult::Bound(value) if value.as_ref() == drawing_ns::WP.as_bytes())
&& element.local_name().as_ref() == b"docPr"
{
for attribute in element.attributes() {
let attribute = attribute
.map_err(|error| Error::Other(format!("invalid story XML: {error}")))?;
let (attribute_namespace, local) =
reader.resolver().resolve_attribute(attribute.key);
if matches!(attribute_namespace, ResolveResult::Unbound)
&& local.as_ref() == b"id"
{
let value = attribute
.decoded_and_normalized_value(
XmlVersion::Implicit1_0,
reader.decoder(),
)
.map_err(|error| {
Error::Other(format!("invalid drawing id: {error}"))
})?
.parse::<u32>()
.map_err(|_| Error::Other("invalid drawing id".to_owned()))?;
let Some((start, end)) = story_attribute_value_span(
&xml[before..after],
attribute.key.as_ref(),
) else {
return Err(Error::Other(
"story drawing id source was not found".to_owned(),
));
};
occurrences.push((before + start, before + end, value));
}
}
}
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
let mut next = occurrences
.iter()
.map(|(_, _, value)| *value)
.max()
.unwrap_or(0)
.checked_add(1)
.ok_or_else(|| Error::Other("drawing id range is exhausted".to_owned()))?;
let mut seen = HashSet::new();
let mut edits = Vec::new();
for (start, end, value) in occurrences {
if seen.insert(value) {
continue;
}
while !seen.insert(next) {
next = next
.checked_add(1)
.ok_or_else(|| Error::Other("drawing id range is exhausted".to_owned()))?;
}
edits.push((start, end, next.to_string().into_bytes()));
next = next
.checked_add(1)
.ok_or_else(|| Error::Other("drawing id range is exhausted".to_owned()))?;
}
let mut updated = xml.to_vec();
for (start, end, replacement) in edits.into_iter().rev() {
updated.splice(start..end, replacement);
}
Ok(updated)
}
fn remap_xml_relationship_ids_with_bindings(
xml: &[u8],
remap: &HashMap<String, String>,
inherited_bindings: &[(String, String)],
) -> Result<Vec<u8>> {
if remap.is_empty() {
return Ok(xml.to_vec());
}
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
let mut edits = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let event = reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("invalid story XML: {error}")))?;
let after = reader.buffer_position() as usize;
match event {
Event::Start(element) | Event::Empty(element) => {
for attribute in element.attributes() {
let attribute = attribute
.map_err(|error| Error::Other(format!("invalid story XML: {error}")))?;
let (namespace, local) = reader.resolver().resolve_attribute(attribute.key);
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|error| {
Error::Other(format!("invalid story relationship id: {error}"))
})?;
let inherited_relationship_prefix =
matches!(namespace, ResolveResult::Unknown(_))
&& attribute.key.as_ref().contains(&b':')
&& attribute
.key
.as_ref()
.split(|byte| *byte == b':')
.next()
.and_then(|prefix| std::str::from_utf8(prefix).ok())
.is_some_and(|prefix| {
let declaration = format!("xmlns:{prefix}");
inherited_bindings.iter().any(|(name, value)| {
name == &declaration && value == drawing_ns::R
})
});
if (matches!(namespace, ResolveResult::Bound(value) if value.as_ref() == drawing_ns::R.as_bytes())
|| inherited_relationship_prefix)
&& matches!(local.as_ref(), b"id" | b"embed" | b"link")
&& let Some(updated) = remap.get(value.as_ref())
{
let Some((start, end)) =
story_attribute_value_span(&xml[before..after], attribute.key.as_ref())
else {
return Err(Error::Other(
"story relationship attribute source was not found".to_owned(),
));
};
edits.push((before + start, before + end, updated.as_bytes().to_vec()));
}
}
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
let mut updated = xml.to_vec();
edits.sort_by_key(|(start, _, _)| *start);
for (start, end, replacement) in edits.into_iter().rev() {
updated.splice(start..end, replacement);
}
Ok(updated)
}
fn story_attribute_value_span(element: &[u8], attribute_name: &[u8]) -> Option<(usize, usize)> {
let mut index = 1usize;
while index < element.len() && !element[index].is_ascii_whitespace() {
index += 1;
}
while index < element.len() {
while index < element.len() && element[index].is_ascii_whitespace() {
index += 1;
}
if index >= element.len() || matches!(element[index], b'>' | b'/') {
return None;
}
let name_start = index;
while index < element.len()
&& !element[index].is_ascii_whitespace()
&& element[index] != b'='
{
index += 1;
}
let name_end = index;
while index < element.len() && element[index].is_ascii_whitespace() {
index += 1;
}
if element.get(index) != Some(&b'=') {
return None;
}
index += 1;
while index < element.len() && element[index].is_ascii_whitespace() {
index += 1;
}
let quote = *element.get(index)?;
if !matches!(quote, b'\'' | b'"') {
return None;
}
index += 1;
let value_start = index;
while index < element.len() && element[index] != quote {
index += 1;
}
let value_end = index;
if &element[name_start..name_end] == attribute_name {
return Some((value_start, value_end));
}
index += 1;
}
None
}
enum DrawingMut<'a> {
Inline(&'a mut CT_Inline),
Anchor(&'a mut CT_Anchor),
}
impl DrawingMut<'_> {
fn remap_relationship(&mut self, remap: &HashMap<String, String>) {
let (embed_id, chart_rel_id) = match self {
Self::Inline(drawing) => (&mut drawing.embed_id, &mut drawing.chart_rel_id),
Self::Anchor(drawing) => (&mut drawing.embed_id, &mut drawing.chart_rel_id),
};
if let Some(updated) = remap.get(embed_id) {
embed_id.clone_from(updated);
}
if let Some(id) = chart_rel_id
&& let Some(updated) = remap.get(id)
{
id.clone_from(updated);
}
}
}
/// A Word document (.docx file).
///
/// This is the main entry point for reading, creating, and modifying
/// DOCX documents.
pub struct Document {
pub(crate) package: OpcPackage,
pub(crate) document: CT_Document,
/// Namespace declarations on the original document root.
root_namespace_declarations: Vec<(String, String)>,
/// Namespace declarations local to the original document body.
body_namespace_declarations: Vec<(String, String)>,
/// Namespace declarations in scope on the original document body.
body_namespace_bindings: Vec<(String, String)>,
pub(crate) styles: CT_Styles,
pub(crate) numbering: Option<CT_Numbering>,
/// Boxed, because debug builds keep many copies of `Document` on deep
/// stacks, and the fifteen-field model would otherwise grow every one.
pub(crate) core_properties: Option<Box<CoreProperties>>,
pub(crate) application_properties: Option<AppProperties>,
pub(crate) custom_properties: Option<CustomProperties>,
/// Package part containing the core properties, resolved from `_rels/.rels`.
core_properties_part_name: Option<String>,
application_properties_part_name: Option<String>,
custom_properties_part_name: Option<String>,
custom_properties_owned: bool,
/// Part name for the main document
pub(crate) doc_part_name: String,
/// Part name the styles were loaded from, and where they are written back.
/// Resolved through the relationship rather than assumed, so a document
/// that keeps its styles somewhere other than `/word/styles.xml` is
/// updated in place instead of gaining an orphaned second part.
styles_part_name: Option<String>,
/// Part name for numbering definitions, resolved the same way.
numbering_part_name: Option<String>,
/// Typed document settings loaded through the main document relationship.
pub(crate) settings: Option<CT_Settings>,
/// Existing settings relationship target. No conventional target is assumed.
settings_part_name: Option<String>,
/// Whether this facade instance created an optional settings graph.
settings_owned: bool,
/// Typed web settings loaded through the main document relationship.
web_settings: Option<CT_WebSettings>,
/// Existing web settings relationship target. No conventional target is
/// assumed.
web_settings_part_name: Option<String>,
/// Whether this facade instance created an optional web settings graph.
web_settings_owned: bool,
/// Typed shared DrawingML theme loaded through the main-document relationship.
theme: Option<oxml_drawing::theme::CT_OfficeStyleSheet>,
/// Existing theme relationship target.
theme_part_name: Option<String>,
/// Whether the theme model must be serialized into the package.
theme_dirty: bool,
/// Typed font table loaded through the main-document relationship.
font_table: Option<FontTable>,
/// Existing font-table relationship target.
font_table_part_name: Option<String>,
/// Whether the font-table model must be serialized into the package.
font_table_dirty: bool,
/// Shared package and WordprocessingML identifier allocation state.
pub(crate) identifiers: DocumentIdentifiers,
/// Typed footnotes loaded through the main document relationship.
pub(crate) footnotes: rdocx_oxml::footnotes::CT_Footnotes,
/// Existing footnotes relationship target. No conventional target is assumed on read.
pub(crate) footnotes_part_name: Option<String>,
/// Producer root namespaces inherited by retained footnote raw XML.
footnotes_root_namespace_declarations: Vec<(String, String)>,
/// Whether a facade mutation requires complete typed footnote serialization.
pub(crate) footnotes_dirty: bool,
/// Typed comments loaded through the main document relationship.
pub(crate) comments: Option<rdocx_oxml::comments::CT_Comments>,
/// Existing comments relationship target. No target is invented on read.
pub(crate) comments_part_name: Option<String>,
/// Whether a typed comment mutation must be published into the package.
pub(crate) comments_dirty: bool,
/// Typed reply linkage and resolved state for comments.
pub(crate) comments_extended: Option<rdocx_oxml::comments_extended::CT_CommentsEx>,
/// Existing comments-extended relationship target.
pub(crate) comments_extended_part_name: Option<String>,
/// Whether this facade created the comments part and may remove it when empty.
pub(crate) comments_owned: bool,
/// Whether this facade created the comments-extended part and may remove it when empty.
pub(crate) comments_extended_owned: bool,
/// Embedded identities whose retained signature evidence is known invalid.
pub(crate) embedded_invalidated_signatures: HashSet<(String, String)>,
/// Whether retained package signature evidence is known invalid.
pub(crate) package_signatures_invalidated: bool,
/// Relationship-resolved glossary and building-block entries.
pub(crate) glossary: Option<rdocx_oxml::glossary::CT_GlossaryDocument>,
/// Existing glossary relationship target.
pub(crate) glossary_part_name: Option<String>,
/// Whether a bounded facade replacement changed the glossary model.
pub(crate) glossary_dirty: bool,
/// Normal layout, including system font discovery, computed on first use.
layout_cache: Mutex<Option<Arc<rdocx_layout::WordLayoutResult>>>,
/// Reusable normal-font engine retained across document edits.
normal_layout_engine: Mutex<Option<rdocx_layout::engine::Engine>>,
/// Bundled-font-only layout used by deterministic rendering.
deterministic_layout_cache: Mutex<Option<Arc<rdocx_layout::WordLayoutResult>>>,
/// Reusable bundled-font engine with caller faces at highest priority.
bundled_fallback_layout_engine: Mutex<Option<rdocx_layout::engine::Engine>>,
}
enum ChartPackageSource<'a> {
#[allow(dead_code)]
Typed {
chart: &'a CT_ChartSpace,
workbook: &'a Workbook,
},
Authored {
kind: ChartKind,
data: &'a ChartData,
},
}
/// Fallback part names used when a document does not already declare one.
const DEFAULT_STYLES_PART: &str = "/word/styles.xml";
const DEFAULT_NUMBERING_PART: &str = "/word/numbering.xml";
const DEFAULT_CORE_PROPERTIES_PART: &str = "/docProps/core.xml";
const DEFAULT_APP_PROPERTIES_PART: &str = "/docProps/app.xml";
const DEFAULT_FONT_TABLE_PART: &str = "/word/fontTable.xml";
const DEFAULT_THEME_PART: &str = "/word/theme/theme1.xml";
const STYLES_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml";
const NUMBERING_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml";
const CORE_PROPERTIES_REL_TYPE: &str =
"http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties";
const SETTINGS_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml";
const DEFAULT_SETTINGS_PART: &str = "/word/settings.xml";
const WEB_SETTINGS_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.webSettings+xml";
const DEFAULT_WEB_SETTINGS_PART: &str = "/word/webSettings.xml";
const FONT_TABLE_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml";
const EMBEDDED_FONT_CONTENT_TYPE: &str =
"application/vnd.openxmlformats-officedocument.obfuscatedFont";
const DEFAULT_FONT_TABLE_XML: &str = concat!(
r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>"#,
r#"<w:fonts xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">"#,
r#"<w:font w:name="Calibri"/><w:font w:name="Times New Roman"/>"#,
r#"</w:fonts>"#,
);
pub(crate) fn relationship_is_internal(
relationship: &oxml_opc::relationship::Relationship,
) -> bool {
matches!(relationship.target_mode.as_deref(), None | Some("Internal"))
}
fn bundle_relationship_order(rel_type: &str) -> Option<u8> {
match rel_type {
rel_types::STYLES => Some(0),
rel_types::SETTINGS => Some(1),
rel_types::NUMBERING => Some(2),
rel_types::FOOTNOTES => Some(3),
rel_types::COMMENTS => Some(4),
crate::comments::COMMENTS_EXTENDED_REL_TYPE => Some(5),
_ => None,
}
}
fn bundle_relationship_precedes_story(rel_type: &str) -> bool {
matches!(rel_type, rel_types::STYLES | rel_types::SETTINGS)
}
#[cfg(test)]
thread_local! {
static LAYOUT_INVOCATIONS: Cell<usize> = const { Cell::new(0) };
static FAIL_NEXT_HEADER_FOOTER_SERIALIZATION: Cell<bool> = const { Cell::new(false) };
static FAIL_NEXT_SCOPED_REOPEN: Cell<bool> = const { Cell::new(false) };
static FAIL_NEXT_STAGED_REOPEN: Cell<bool> = const { Cell::new(false) };
pub(crate) static STORY_SOURCE_BUILDS: Cell<usize> = const { Cell::new(0) };
static STORY_TEXT_PREFIX_BYTES: Cell<usize> = const { Cell::new(0) };
static STORY_OWNER_SCANS: Cell<usize> = const { Cell::new(0) };
static STORY_NAMESPACE_SCANS: Cell<usize> = const { Cell::new(0) };
static FX237_LEGACY_CELL_STEPS: Cell<usize> = const { Cell::new(0) };
static FX237_NAMESPACE_FRAME_COPIES: Cell<usize> = const { Cell::new(0) };
static FX237_ITEM_SCANS: Cell<usize> = const { Cell::new(0) };
static FX237_PREFIX_NAMESPACE_BYTES: Cell<usize> = const { Cell::new(0) };
static FX237_TABLE_VISITS: Cell<usize> = const { Cell::new(0) };
static FX237_CELL_VISITS: Cell<usize> = const { Cell::new(0) };
static FX211_PROFILE: Cell<[usize; 12]> = const { Cell::new([0; 12]) };
static FX211_PROFILE_CONTEXT: Cell<usize> = const { Cell::new(0) };
}
#[cfg(test)]
fn record_layout_invocation() {
LAYOUT_INVOCATIONS.set(LAYOUT_INVOCATIONS.get() + 1);
}
fn owned_font_aliases(font_aliases: &[(&str, &str)]) -> Vec<(String, String)> {
font_aliases
.iter()
.map(|(requested, target)| ((*requested).to_owned(), (*target).to_owned()))
.collect()
}
fn new_word_package(class: WordPackageClass) -> OpcPackage {
let mut package = OpcPackage::with_main_part("word/document.xml", class.content_type());
package
.content_types
.add_override(DEFAULT_STYLES_PART, STYLES_CONTENT_TYPE);
package
}
fn new_word_compatible_package(
class: WordPackageClass,
document: &CT_Document,
styles: &CT_Styles,
settings: &CT_Settings,
core_properties: &CoreProperties,
application_properties: &AppProperties,
) -> OpcPackage {
let mut package = new_word_package(class);
package.set_part(
"/word/document.xml",
document
.to_xml()
.expect("fresh Word document model must serialize"),
);
package.set_part(
DEFAULT_STYLES_PART,
styles
.to_xml()
.expect("fresh Word styles model must serialize"),
);
package.set_part(
DEFAULT_SETTINGS_PART,
settings
.to_xml()
.expect("fresh Word settings model must serialize"),
);
package.set_part(
DEFAULT_CORE_PROPERTIES_PART,
core_properties
.to_xml()
.expect("fresh core properties model must serialize"),
);
package.set_part(
DEFAULT_APP_PROPERTIES_PART,
application_properties
.to_xml()
.expect("fresh application properties model must serialize"),
);
package.set_part(
DEFAULT_THEME_PART,
oxml_drawing::theme::OFFICE_DEFAULT_XML.as_bytes().to_vec(),
);
package.set_part(
DEFAULT_FONT_TABLE_PART,
DEFAULT_FONT_TABLE_XML.as_bytes().to_vec(),
);
package
.content_types
.add_override(DEFAULT_SETTINGS_PART, SETTINGS_CONTENT_TYPE);
package
.content_types
.add_override(DEFAULT_FONT_TABLE_PART, FONT_TABLE_CONTENT_TYPE);
package
.content_types
.add_override(DEFAULT_THEME_PART, content_types::THEME);
package
.content_types
.add_override(DEFAULT_CORE_PROPERTIES_PART, content_types::CORE_PROPERTIES);
package.content_types.add_override(
DEFAULT_APP_PROPERTIES_PART,
content_types::EXTENDED_PROPERTIES,
);
package
.package_rels
.add(rel_types::CORE_PROPERTIES, "docProps/core.xml");
package
.package_rels
.add(rel_types::EXTENDED_PROPERTIES, "docProps/app.xml");
let document_relationships = package.get_or_create_part_rels("/word/document.xml");
document_relationships.add(rel_types::STYLES, "styles.xml");
document_relationships.add_with_id("rdocxSettings", rel_types::SETTINGS, "settings.xml");
document_relationships.add_with_id("rdocxTheme", rel_types::THEME, "theme/theme1.xml");
document_relationships.add_with_id("rdocxFontTable", rel_types::FONT_TABLE, "fontTable.xml");
package
}
fn default_application_properties() -> AppProperties {
let mut properties = AppProperties::default();
properties.application = Some("rdocx".to_owned());
properties.application_version = Some(rdocx_app_version());
properties
}
/// The AppVersion rdocx stamps. ECMA-376 Part 1, 22.2.2.3 fixes AppVersion to
/// the form XX.YYYY, and Word refuses to open a package whose AppVersion has
/// another shape, such as 0.14.0.
fn rdocx_app_version() -> String {
format!(
"{:0>2}.{:0>4}",
env!("CARGO_PKG_VERSION_MAJOR"),
env!("CARGO_PKG_VERSION_MINOR")
)
}
/// Repair an AppVersion Word would refuse, as rdocx 0.14.0 wrote it: rdocx's
/// own properties take the current stamp, and any other producer's value is
/// dropped. A valid AppVersion is left alone, so its part stays byte-identical.
fn repair_application_version(mut properties: AppProperties) -> AppProperties {
if properties
.application_version
.as_deref()
.is_some_and(|version| !is_app_version(version))
{
properties.application_version =
(properties.application.as_deref() == Some("rdocx")).then(rdocx_app_version);
}
properties
}
/// Whether `version` has the `XX.YYYY` form ECMA-376 Part 1, 22.2.2.3 gives AppVersion.
fn is_app_version(version: &str) -> bool {
version.split_once('.').is_some_and(|(major, minor)| {
major.len() == 2
&& minor.len() == 4
&& major
.bytes()
.chain(minor.bytes())
.all(|byte| byte.is_ascii_digit())
})
}
fn equivalent_abstract_numbering(left: &CT_AbstractNum, right: &CT_AbstractNum) -> bool {
let mut left = left.clone();
let mut right = right.clone();
left.abstract_num_id = 0;
right.abstract_num_id = 0;
// Parsed leaf snapshots record prior typed values. Compare the actual
// owned XML projection, including opaque attributes and children, rather
// than the preservation bookkeeping used to produce that projection.
let mut left_xml = quick_xml::Writer::new(Vec::new());
let mut right_xml = quick_xml::Writer::new(Vec::new());
left.to_xml(&mut left_xml).is_ok()
&& right.to_xml(&mut right_xml).is_ok()
&& left_xml.into_inner() == right_xml.into_inner()
}
fn equivalent_numbering_instance(left: &CT_Num, right: &CT_Num, abstract_num_id: u32) -> bool {
if left.abstract_num_id != abstract_num_id {
return false;
}
let mut left = left.clone();
let mut right = right.clone();
left.num_id = 0;
right.num_id = 0;
left.abstract_num_id = abstract_num_id;
right.abstract_num_id = abstract_num_id;
let mut left_xml = quick_xml::Writer::new(Vec::new());
let mut right_xml = quick_xml::Writer::new(Vec::new());
left.to_xml(&mut left_xml).is_ok()
&& right.to_xml(&mut right_xml).is_ok()
&& left_xml.into_inner() == right_xml.into_inner()
}
struct StorySource<'a> {
root_kind: StoryKind,
part_name: String,
xml: Cow<'a, [u8]>,
}
pub(crate) struct TextBoxCacheParagraph {
pub(crate) location: ContentLocation,
pub(crate) xml: Vec<u8>,
pub(crate) owner_index: usize,
pub(crate) paragraph_index: usize,
}
#[derive(Clone)]
struct StoryOwnerSpan {
kind: StoryKind,
owner_index: usize,
full: Range<usize>,
fingerprint: u64,
}
#[derive(Clone)]
struct StoryItemSpan {
kind: StoryItemKind,
full: Range<usize>,
scan: Range<usize>,
direct_owner_child: bool,
complex_field: bool,
complex_ancestors: Vec<usize>,
sdt_context: Option<StorySdtContext>,
}
// Paired source events prove physical correspondence while permitting only
// namespace declarations to differ. Non-namespace source remains exact.
#[allow(clippy::type_complexity)] // Concrete physical map and changed namespace offsets form one proof.
fn comment_namespace_source_boundaries(
canonical: &[u8],
restored: &[u8],
) -> Result<Option<(BTreeMap<usize, usize>, Vec<usize>)>> {
let mut left = NsReader::from_reader(canonical);
let mut right = NsReader::from_reader(restored);
let mut boundaries = BTreeMap::new();
let mut changed_namespace_facts = Vec::new();
loop {
let left_start = left.buffer_position() as usize;
let right_start = right.buffer_position() as usize;
let a = left.read_event().map_err(|e| Error::Other(e.to_string()))?;
let b = right
.read_event()
.map_err(|e| Error::Other(e.to_string()))?;
let same = match (&a, &b) {
(Event::Start(a), Event::Start(b)) | (Event::Empty(a), Event::Empty(b)) => {
let attributes = |element: &BytesStart<'_>| -> Result<Vec<(Vec<u8>, Vec<u8>)>> {
element
.attributes()
.filter_map(|attribute| match attribute {
Ok(attribute)
if attribute.key.as_ref() == b"xmlns"
|| attribute.key.as_ref().starts_with(b"xmlns:") =>
{
None
}
Ok(attribute) => Some(Ok((
attribute.key.as_ref().to_vec(),
attribute.value.as_ref().to_vec(),
))),
Err(error) => Some(Err(Error::Other(error.to_string()))),
})
.collect()
};
let a_attributes = attributes(a)?;
let b_attributes = attributes(b)?;
let same_namespaces = left.resolver().resolve_element(a.name()).0
== right.resolver().resolve_element(b.name()).0
&& a.attributes()
.filter_map(|attribute| attribute.ok())
.filter(|attribute| {
attribute.key.as_ref() != b"xmlns"
&& !attribute.key.as_ref().starts_with(b"xmlns:")
})
.all(|attribute| {
left.resolver().resolve_attribute(attribute.key).0
== right.resolver().resolve_attribute(attribute.key).0
});
if !same_namespaces {
changed_namespace_facts.push(left_start);
}
a.name() == b.name() && a_attributes == b_attributes
}
_ => a == b,
};
if !same {
return Ok(None);
}
boundaries.insert(left_start, right_start);
boundaries.insert(
left.buffer_position() as usize,
right.buffer_position() as usize,
);
if matches!(a, Event::Eof) {
break;
}
}
Ok(Some((boundaries, changed_namespace_facts)))
}
// Existing checked paragraph paths shared by discovery and rich projection.
// Neither consumer invents a second index axis. Every owner of one source and
// every control in it is scanned from its own excerpt, with each family of
// scopes inventoried in one pass, so no owner or control rereads the source
// from its start. One list per owner, in the order given.
#[allow(clippy::type_complexity)] // One located span list per owner.
fn story_range_paragraph_spans(
xml: &[u8],
owners: &[(StoryId, StoryOwnerSpan)],
) -> Result<Vec<Vec<(ContentLocation, Range<usize>)>>> {
let owner_scopes =
story_namespace_scopes_at(xml, owners.iter().map(|(_, owner)| owner.full.start))?;
let mut inventories = Vec::with_capacity(owners.len());
for (_, owner) in owners {
let scope = owner_scopes.get(&owner.full.start).ok_or_else(|| {
Error::Other("story owner namespace scope was not inventoried".to_owned())
})?;
inventories.push(scan_story_items_with_scope(xml, owner, scope)?);
}
let control_scopes = story_namespace_scopes_at(
xml,
inventories
.iter()
.flatten()
.filter(|item| item.kind == StoryItemKind::ContentControl)
.map(|item| item.full.start),
)?;
story_range_paragraph_spans_from_inventory(xml, owners, &inventories, &control_scopes)
}
#[allow(clippy::type_complexity)] // Story-ordered paragraphs retain checked locations and source spans.
fn story_range_paragraph_spans_from_inventory(
xml: &[u8],
owners: &[(StoryId, StoryOwnerSpan)],
inventories: &[Vec<StoryItemSpan>],
control_scopes: &HashMap<usize, BTreeMap<String, String>>,
) -> Result<Vec<Vec<(ContentLocation, Range<usize>)>>> {
let mut spans = Vec::with_capacity(owners.len());
for ((story, _), items) in owners.iter().zip(inventories) {
let mut paragraphs = Vec::new();
for (index, item) in items.iter().enumerate() {
let item_spans = match item.kind {
StoryItemKind::Paragraph => vec![(vec![index], item.full.clone())],
StoryItemKind::ContentControl => {
let scope = control_scopes.get(&item.full.start).ok_or_else(|| {
Error::Other(
"content control namespace scope was not inventoried".to_owned(),
)
})?;
scan_story_control_paragraphs_with_scope(xml, item, scope)?
.into_iter()
.enumerate()
.map(|(paragraph, span)| (vec![index, paragraph], span))
.collect()
}
_ => continue,
};
for (path, span) in item_spans {
paragraphs.push((
ContentLocation::new(story.clone(), StoryItemKind::Paragraph, path),
span,
));
}
}
spans.push(paragraphs);
}
Ok(spans)
}
struct StoryLinkSpan {
full: Range<usize>,
rel_id: Option<String>,
anchor: Option<String>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum StoryNamespace {
Word,
Vml,
Drawing,
DrawingPicture,
WordDrawing,
WordShape,
WordGroup,
MarkupCompatibility,
Other,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum StorySdtContext {
Block,
Table,
Row,
Inline,
}
struct XmlElementFrame {
namespace: StoryNamespace,
local_name: Vec<u8>,
is_word: bool,
full_start: usize,
owner_kind: Option<StoryKind>,
item_kind: Option<StoryItemKind>,
direct_owner_child: bool,
sdt_context: Option<StorySdtContext>,
word_prefixes: Vec<String>,
opaque: bool,
typed_sdt_content_seen: bool,
}
fn word_element(namespace: &ResolveResult<'_>) -> bool {
matches!(
namespace,
ResolveResult::Bound(Namespace(uri)) if *uri == WORD_NAMESPACE.as_bytes()
)
}
fn story_namespace(namespace: &ResolveResult<'_>) -> StoryNamespace {
match namespace {
ResolveResult::Bound(Namespace(uri)) => match *uri {
uri if uri == WORD_NAMESPACE.as_bytes() => StoryNamespace::Word,
b"urn:schemas-microsoft-com:vml" => StoryNamespace::Vml,
b"http://schemas.openxmlformats.org/drawingml/2006/main" => StoryNamespace::Drawing,
b"http://schemas.openxmlformats.org/drawingml/2006/picture" => {
StoryNamespace::DrawingPicture
}
b"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" => {
StoryNamespace::WordDrawing
}
b"http://schemas.microsoft.com/office/word/2010/wordprocessingShape" => {
StoryNamespace::WordShape
}
b"http://schemas.microsoft.com/office/word/2010/wordprocessingGroup" => {
StoryNamespace::WordGroup
}
b"http://schemas.openxmlformats.org/markup-compatibility/2006" => {
StoryNamespace::MarkupCompatibility
}
_ => StoryNamespace::Other,
},
_ => StoryNamespace::Other,
}
}
fn modeled_word_child(parent: &[u8], child: &[u8]) -> bool {
match parent {
b"document" => child == b"body",
b"footnotes" => child == b"footnote",
b"endnotes" => child == b"endnote",
b"comments" => child == b"comment",
b"body" | b"hdr" | b"ftr" | b"footnote" | b"endnote" | b"comment" | b"txbxContent" => {
matches!(child, b"p" | b"tbl" | b"sdt" | b"sectPr")
}
b"tc" => matches!(child, b"tcPr" | b"p" | b"tbl" | b"sdt"),
b"tbl" => matches!(child, b"tblPr" | b"tblGrid" | b"tr" | b"sdt"),
b"tr" => matches!(child, b"trPr" | b"tc" | b"sdt"),
b"p" => matches!(
child,
b"pPr"
| b"r"
| b"sdt"
| b"hyperlink"
| b"fldSimple"
| b"ins"
| b"del"
| b"moveFrom"
| b"moveTo"
| b"bookmarkStart"
| b"bookmarkEnd"
| b"commentRangeStart"
| b"commentRangeEnd"
| b"proofErr"
),
b"r" => matches!(
child,
b"rPr"
| b"t"
| b"delText"
| b"instrText"
| b"delInstrText"
| b"fldChar"
| b"drawing"
| b"pict"
| b"object"
| b"tab"
| b"br"
| b"cr"
| b"sym"
| b"noBreakHyphen"
| b"softHyphen"
| b"footnoteReference"
| b"endnoteReference"
| b"commentReference"
| b"lastRenderedPageBreak"
),
b"sdt" => matches!(child, b"sdtPr" | b"sdtEndPr" | b"sdtContent"),
b"sdtContent" => false,
b"hyperlink" => {
matches!(child, b"r" | b"ins" | b"del" | b"moveFrom" | b"moveTo")
}
b"ins" | b"del" | b"moveFrom" | b"moveTo" => matches!(
child,
b"r" | b"sdt" | b"hyperlink" | b"ins" | b"del" | b"moveFrom" | b"moveTo"
),
b"fldSimple" => child == b"r",
_ => false,
}
}
fn story_sdt_context(stack: &[XmlElementFrame]) -> Option<StorySdtContext> {
let parent = stack.last()?;
if parent.namespace != StoryNamespace::Word {
return None;
}
match parent.local_name.as_slice() {
b"body" | b"hdr" | b"ftr" | b"footnote" | b"endnote" | b"comment" | b"txbxContent"
| b"tc" => Some(StorySdtContext::Block),
b"tbl" => Some(StorySdtContext::Table),
b"tr" => Some(StorySdtContext::Row),
b"p" | b"hyperlink" | b"fldSimple" | b"ins" | b"del" | b"moveFrom" | b"moveTo" => {
Some(StorySdtContext::Inline)
}
b"sdtContent" => parent.sdt_context,
_ => None,
}
}
fn story_element_sdt_context(
stack: &[XmlElementFrame],
namespace: StoryNamespace,
local_name: &[u8],
) -> Option<StorySdtContext> {
if namespace != StoryNamespace::Word {
return None;
}
match local_name {
b"sdt" => story_sdt_context(stack),
b"sdtContent" => stack.last().and_then(|parent| {
(parent.namespace == StoryNamespace::Word && parent.local_name == b"sdt")
.then_some(parent.sdt_context)
.flatten()
}),
_ => None,
}
}
fn modeled_sdt_content_child(context: Option<StorySdtContext>, child: &[u8]) -> bool {
child == b"sdt"
|| match context {
Some(StorySdtContext::Block) => matches!(child, b"p" | b"tbl"),
Some(StorySdtContext::Table) => child == b"tr",
Some(StorySdtContext::Row) => child == b"tc",
Some(StorySdtContext::Inline) => {
matches!(child, b"r" | b"ins" | b"del" | b"moveFrom" | b"moveTo")
}
None => false,
}
}
fn modeled_story_child(
stack: &[XmlElementFrame],
namespace: StoryNamespace,
local_name: &[u8],
) -> bool {
let Some(parent) = stack.last() else {
return namespace == StoryNamespace::Word;
};
match (parent.namespace, namespace) {
(StoryNamespace::Word, StoryNamespace::Word) if parent.local_name == b"sdtContent" => {
modeled_sdt_content_child(parent.sdt_context, local_name)
}
(StoryNamespace::Word, StoryNamespace::Word) => {
modeled_word_child(&parent.local_name, local_name)
}
(StoryNamespace::Word, StoryNamespace::Vml) => {
matches!(parent.local_name.as_slice(), b"pict" | b"object")
&& matches!(
local_name,
b"shape"
| b"group"
| b"rect"
| b"roundrect"
| b"oval"
| b"line"
| b"polyline"
| b"curve"
| b"arc"
)
}
(StoryNamespace::Vml, StoryNamespace::Vml) => match parent.local_name.as_slice() {
b"group" => matches!(
local_name,
b"shape"
| b"group"
| b"rect"
| b"roundrect"
| b"oval"
| b"line"
| b"polyline"
| b"curve"
| b"arc"
),
b"shape" | b"rect" | b"roundrect" | b"oval" | b"line" | b"polyline" | b"curve"
| b"arc" => local_name == b"textbox",
_ => false,
},
(StoryNamespace::Vml, StoryNamespace::Word) => {
parent.local_name == b"textbox" && local_name == b"txbxContent"
}
// Word writes a text box twice in a run, as DrawingML in `mc:Choice` and
// as VML in `mc:Fallback`. One Choice is read, see
// `modeled_story_event`, and the Fallback stays opaque, so the text box
// is one story.
(StoryNamespace::Word, StoryNamespace::MarkupCompatibility) => {
parent.local_name == b"r" && local_name == b"AlternateContent"
}
(StoryNamespace::MarkupCompatibility, StoryNamespace::MarkupCompatibility) => {
parent.local_name == b"AlternateContent" && local_name == b"Choice"
}
(StoryNamespace::MarkupCompatibility, StoryNamespace::Word) => {
parent.local_name == b"Choice" && local_name == b"drawing"
}
(StoryNamespace::Word, StoryNamespace::WordDrawing) => {
parent.local_name == b"drawing" && matches!(local_name, b"inline" | b"anchor")
}
(StoryNamespace::WordDrawing, StoryNamespace::Drawing) => {
matches!(parent.local_name.as_slice(), b"inline" | b"anchor")
&& local_name == b"graphic"
}
(StoryNamespace::Drawing, StoryNamespace::Drawing) => match parent.local_name.as_slice() {
b"graphic" => local_name == b"graphicData",
_ => false,
},
(StoryNamespace::Drawing, StoryNamespace::WordShape) => {
parent.local_name == b"graphicData" && local_name == b"wsp"
}
(StoryNamespace::Drawing, StoryNamespace::WordGroup) => {
parent.local_name == b"graphicData" && local_name == b"wgp"
}
(StoryNamespace::WordGroup, StoryNamespace::WordGroup) => {
matches!(parent.local_name.as_slice(), b"wgp" | b"grpSp") && local_name == b"grpSp"
}
(StoryNamespace::WordGroup, StoryNamespace::WordShape) => {
matches!(parent.local_name.as_slice(), b"wgp" | b"grpSp") && local_name == b"wsp"
}
(StoryNamespace::WordShape, StoryNamespace::WordShape) => {
parent.local_name == b"wsp" && local_name == b"txbx"
}
(StoryNamespace::WordShape, StoryNamespace::Word) => {
parent.local_name == b"txbx" && local_name == b"txbxContent"
}
(StoryNamespace::Drawing, StoryNamespace::DrawingPicture) => {
parent.local_name == b"graphicData" && local_name == b"pic"
}
_ => false,
}
}
fn field_instruction_has_name(instruction: &str) -> bool {
story_field_instruction_has_name(instruction)
}
fn story_word_prefixes_at(
element: &BytesStart<'_>,
inherited: &[String],
is_word_element: bool,
) -> Result<Vec<String>> {
let mut prefixes = inherited.to_vec();
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| {
Error::Other(format!("story namespace declaration scan failed: {error}"))
})?;
let name = attribute.key.as_ref();
let prefix = if name == b"xmlns" {
b"".as_slice()
} else if let Some(prefix) = name.strip_prefix(b"xmlns:") {
prefix
} else {
continue;
};
let prefix = std::str::from_utf8(prefix)
.map_err(|error| Error::Other(format!("story namespace prefix is invalid: {error}")))?
.to_owned();
prefixes.retain(|candidate| candidate != &prefix);
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, element.decoder())
.map_err(|error| {
Error::Other(format!(
"story namespace declaration decode failed: {error}"
))
})?;
if value.as_ref() == WORD_NAMESPACE {
prefixes.push(prefix);
}
}
if is_word_element {
let name = element.name();
let prefix = name
.as_ref()
.split(|byte| *byte == b':')
.next()
.filter(|_| name.as_ref().contains(&b':'))
.unwrap_or(b"");
let prefix = std::str::from_utf8(prefix)
.map_err(|error| Error::Other(format!("story element prefix is invalid: {error}")))?
.to_owned();
if !prefixes.contains(&prefix) {
prefixes.push(prefix);
}
}
Ok(prefixes)
}
// Locate the selected reference's actual run without changing raw attributes.
fn comment_reference_run_span(xml: &[u8], owner: &Range<usize>, id: i32) -> Result<Range<usize>> {
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut stack = Vec::<(bool, Vec<u8>, usize)>::new();
let mut found = Vec::new();
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (ns, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(error.to_string()))?;
let word = word_element(&ns);
drop(ns);
match &event {
Event::Start(element) | Event::Empty(element) => {
if word
&& element.local_name().as_ref() == b"commentReference"
&& owner.contains(&before)
{
let mut selected = false;
for attr in element.attributes() {
let attr = attr.map_err(|error| Error::Other(error.to_string()))?;
let (ns, local) = reader.resolver().resolve_attribute(attr.key);
if word_element(&ns) && local.as_ref() == b"id" {
selected = attr
.decoded_and_normalized_value(
XmlVersion::Implicit1_0,
reader.decoder(),
)
.map_err(|error| Error::Other(error.to_string()))?
.parse::<i32>()
.ok()
== Some(id);
}
}
if selected {
let start = stack
.iter()
.rev()
.find(|(word, name, _)| *word && name == b"r")
.map(|(_, _, start)| *start)
.ok_or_else(|| {
Error::Other("comment reference has no source run".into())
})?;
found.push(start..story_element_end(xml, start)?);
}
}
if matches!(event, Event::Start(_)) {
stack.push((word, element.local_name().as_ref().to_vec(), before));
}
}
Event::End(_) => {
stack.pop();
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
if found.len() != 1 {
return Err(Error::Other(
"comment move requires one qualified reference run".into(),
));
}
Ok(found.remove(0))
}
#[allow(clippy::type_complexity)] // Concrete edit spans, reference bytes and original run span.
fn comment_move_source_edits(
xml: &[u8],
selected: &[Range<usize>],
) -> Result<(Vec<Range<usize>>, Vec<u8>, Range<usize>)> {
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut stack = Vec::<(bool, Vec<u8>, usize)>::new();
let mut reference = None;
let mut controls = Vec::new();
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (ns, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(error.to_string()))?;
let word = word_element(&ns);
drop(ns);
match &event {
Event::Start(element) | Event::Empty(element) => {
if selected.iter().any(|span| span.start == before) {
for (_, _, start) in stack
.iter()
.filter(|(word, name, _)| *word && name == b"sdt")
{
let span = *start..story_element_end(xml, *start)?;
if !controls.contains(&span) {
controls.push(span);
}
}
if word && element.local_name().as_ref() == b"commentReference" {
let start = stack
.iter()
.rev()
.find(|(word, name, _)| *word && name == b"r")
.map(|(_, _, start)| *start)
.ok_or_else(|| {
Error::Other("comment reference has no source run".into())
})?;
if reference
.replace(start..story_element_end(xml, start)?)
.is_some()
{
return Err(Error::Other(
"comment move has duplicate reference runs".into(),
));
}
}
}
if matches!(event, Event::Start(_)) {
stack.push((word, element.local_name().as_ref().to_vec(), before));
}
}
Event::End(_) => {
stack.pop();
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
let reference_span = reference
.ok_or_else(|| Error::Other("comment move has no selected reference run".into()))?;
let scope = story_namespace_scope_at(xml, reference_span.start)?;
let reference = close_content_fragment_namespaces(&xml[reference_span.clone()], &scope)?;
// Only the selected direct reference and run properties travel. Other
// children retain their exact source sequence, even when visible text and
// opaque producer payload share this run with the reference.
let selected_reference = selected
.iter()
.find(|span| reference_span.contains(&span.start))
.ok_or_else(|| Error::Other("comment move reference source was not selected".into()))?;
let mut original_reader = quick_xml::Reader::from_reader(&xml[reference_span.clone()]);
original_reader
.read_event()
.map_err(|error| Error::Other(error.to_string()))?;
let original_open_end = original_reader.buffer_position() as usize;
let mut reader = NsReader::from_reader(reference.as_slice());
let mut depth = 0usize;
let mut reference_found = false;
let mut open_end = 0usize;
let mut close_start = 0usize;
let mut kept = Vec::new();
let mut mixed = false;
let mut properties_count = 0usize;
loop {
let before = reader.buffer_position() as usize;
let (ns, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(error.to_string()))?;
let word = word_element(&ns);
drop(ns);
let after = reader.buffer_position() as usize;
match &event {
Event::Start(element) | Event::Empty(element) => {
if depth == 0 {
open_end = after;
}
if depth == 1 {
let end = if matches!(event, Event::Empty(_)) {
after
} else {
story_element_end(&reference, before)?
};
let is_selected = before
== selected_reference.start - reference_span.start + open_end
- original_open_end;
if is_selected && word && element.local_name().as_ref() == b"commentReference" {
if reference_found {
return Err(Error::Other("comment reference run is ambiguous".into()));
}
reference_found = true;
kept.push(before..end);
} else if word && element.local_name().as_ref() == b"rPr" {
properties_count += 1;
kept.push(before..end);
} else {
mixed = true;
}
}
if matches!(event, Event::Start(_)) {
depth += 1;
}
}
Event::End(_) => {
depth = depth.saturating_sub(1);
if depth == 0 {
close_start = before;
}
}
Event::Text(text) if depth == 1 && !text.iter().all(u8::is_ascii_whitespace) => {
mixed = true;
}
Event::Comment(_) | Event::PI(_) if depth == 1 => {
mixed = true;
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
if !reference_found || properties_count > 1 || close_start == 0 {
return Err(Error::Other(
"comment reference is not a uniquely separable direct run child".into(),
));
}
let mut transport = reference[..open_end].to_vec();
for span in kept {
transport.extend_from_slice(&reference[span]);
}
transport.extend_from_slice(&reference[close_start..]);
let mut edits = selected
.iter()
.filter(|span| !reference_span.contains(&span.start))
.cloned()
.collect::<Vec<_>>();
edits.push(if mixed {
selected_reference.clone()
} else {
reference_span.clone()
});
controls.sort_by_key(|span| std::cmp::Reverse(span.start));
for control in controls {
let mut fragment = xml[control.clone()].to_vec();
let mut contained = edits
.iter()
.filter(|span| control.start <= span.start && span.end <= control.end)
.cloned()
.collect::<Vec<_>>();
contained.sort_by_key(|span| span.start);
for span in contained.iter().rev() {
fragment.drain(span.start - control.start..span.end - control.start);
}
if pure_empty_google_comment_control(
&fragment,
&story_namespace_scope_at(xml, control.start)?,
)? {
edits.retain(|span| !(control.start <= span.start && span.end <= control.end));
edits.push(control);
}
}
edits.sort_by_key(|span| span.start);
if edits.windows(2).any(|pair| pair[0].end > pair[1].start) {
return Err(Error::Other("comment move edits overlap".into()));
}
Ok((edits, transport, reference_span))
}
fn pure_empty_google_comment_control(xml: &[u8], scope: &BTreeMap<String, String>) -> Result<bool> {
// An authored namespace declaration or any other root attribute is payload.
let mut raw_reader = quick_xml::Reader::from_reader(xml);
if !matches!(raw_reader.read_event(), Ok(Event::Start(ref root)) if root.attributes().next().is_none())
{
return Ok(false);
}
let closed = close_content_fragment_namespaces(xml, scope)?;
let mut reader = NsReader::from_reader(closed.as_slice());
let mut stack = Vec::<Vec<u8>>::new();
let mut tag = false;
let mut content = false;
let mut buffer = Vec::new();
loop {
let (ns, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(error.to_string()))?;
let word = word_element(&ns);
drop(ns);
match &event {
Event::Start(element) | Event::Empty(element) => {
let name = element.local_name().as_ref().to_vec();
let parent = stack.last().map(Vec::as_slice);
let allowed = word
&& matches!(
(parent, name.as_slice()),
(None, b"sdt")
| (Some(b"sdt"), b"sdtPr" | b"sdtContent")
| (Some(b"sdtPr"), b"tag")
| (Some(b"sdtContent"), b"p")
);
if !allowed {
return Ok(false);
}
if name == b"sdtContent" {
if content {
return Ok(false);
}
content = true;
}
for attr in element.attributes() {
let attr = attr.map_err(|error| Error::Other(error.to_string()))?;
if stack.is_empty()
&& (attr.key.as_ref() == b"xmlns"
|| attr.key.as_ref().starts_with(b"xmlns:"))
{
continue;
}
let (ns, local) = reader.resolver().resolve_attribute(attr.key);
if name != b"tag" || !word_element(&ns) || local.as_ref() != b"val" || tag {
return Ok(false);
}
tag = attr
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|error| Error::Other(error.to_string()))?
.starts_with("goog_rdk_");
if !tag {
return Ok(false);
}
}
if matches!(event, Event::Start(_)) {
stack.push(name);
}
}
Event::End(_) => {
stack.pop();
}
Event::Text(text) if text.iter().all(u8::is_ascii_whitespace) => {}
Event::Eof => break,
_ => return Ok(false),
}
buffer.clear();
}
Ok(tag && content)
}
fn story_element_end(xml: &[u8], start: usize) -> Result<usize> {
let fragment = xml
.get(start..)
.ok_or_else(|| Error::Other("story element begins outside its source part".to_owned()))?;
let mut reader = quick_xml::Reader::from_reader(fragment);
reader.config_mut().trim_text(false);
let mut depth = 0usize;
let mut buffer = Vec::new();
loop {
match reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story element scan failed: {error}")))?
{
Event::Start(_) => depth += 1,
Event::Empty(_) if depth == 0 => {
return Ok(start + reader.buffer_position() as usize);
}
Event::End(_) => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Ok(start + reader.buffer_position() as usize);
}
}
Event::Eof => {
return Err(Error::Other(
"story element has no matching closing element".to_owned(),
));
}
_ => {}
}
buffer.clear();
}
}
// Most elements inherit an unchanged map. Stop at the first declaration, then
// let the original decoder apply the whole element to a full inherited copy.
// Stopping here preserves namespace-value errors before later attribute errors.
fn changed_story_namespace_scope(
inherited: &BTreeMap<String, String>,
element: &BytesStart<'_>,
) -> Result<Option<BTreeMap<String, String>>> {
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| {
Error::Other(format!("story namespace scope attribute failed: {error}"))
})?;
let name = attribute.key.as_ref();
if name == b"xmlns" || name.starts_with(b"xmlns:") {
#[cfg(test)]
FX237_NAMESPACE_FRAME_COPIES.set(FX237_NAMESPACE_FRAME_COPIES.get() + 1);
let mut scope = inherited.clone();
update_story_namespace_scope(&mut scope, element)?;
return Ok(Some(scope));
}
}
Ok(None)
}
// This is the finite edited CT_P boundary, not a recursive Word schema.
// ECMA-376 fifth edition wml.xsd CT_P/EG_PContent expands to these 35 names.
fn edited_paragraph_schema_child(namespace: &str, local: &str) -> bool {
(namespace == M_NS && matches!(local, "oMath" | "oMathPara"))
|| (namespace == W_NS
&& matches!(
local,
"pPr"
| "customXml"
| "smartTag"
| "sdt"
| "dir"
| "bdo"
| "r"
| "fldSimple"
| "hyperlink"
| "subDoc"
| "proofErr"
| "permStart"
| "permEnd"
| "bookmarkStart"
| "bookmarkEnd"
| "moveFromRangeStart"
| "moveFromRangeEnd"
| "moveToRangeStart"
| "moveToRangeEnd"
| "commentRangeStart"
| "commentRangeEnd"
| "customXmlInsRangeStart"
| "customXmlInsRangeEnd"
| "customXmlDelRangeStart"
| "customXmlDelRangeEnd"
| "customXmlMoveFromRangeStart"
| "customXmlMoveFromRangeEnd"
| "customXmlMoveToRangeStart"
| "customXmlMoveToRangeEnd"
| "ins"
| "del"
| "moveFrom"
| "moveTo"
))
}
#[cfg(test)]
thread_local! {
static FX252_RETAINED_ROUTE_ENTRIES: Cell<usize> = const { Cell::new(0) };
static FX252_RETAINED_SCOPE_ENTRIES: Cell<usize> = const { Cell::new(0) };
static FX252_RETAINED_NAMESPACE_BYTES: Cell<usize> = const { Cell::new(0) };
}
#[derive(Clone, Default)]
struct EditedParagraphContext {
namespaces: BTreeMap<String, String>,
ignorable: HashSet<String>,
}
#[derive(Default)]
struct EditedParagraphScopeChanges {
namespaces: Vec<(String, Option<String>)>,
ignorable: Vec<String>,
}
impl EditedParagraphContext {
fn expanded_name(&self, name: &[u8], attribute: bool) -> Result<(&str, String)> {
let name = std::str::from_utf8(name).map_err(|error| Error::Other(error.to_string()))?;
let (prefix, local) = name.split_once(':').unwrap_or(("", name));
let uri = if prefix == "xml" {
"http://www.w3.org/XML/1998/namespace"
} else if prefix.is_empty() && attribute {
""
} else {
match self.namespaces.get(prefix) {
Some(uri) => uri,
None if prefix.is_empty() => "",
None => {
return Err(Error::Other(format!(
"replace_paragraph_xml has an unbound prefix in {name}"
)));
}
}
};
Ok((uri, local.to_owned()))
}
fn enter(&mut self, element: &BytesStart<'_>) -> Result<EditedParagraphScopeChanges> {
let mut changes = EditedParagraphScopeChanges::default();
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| Error::Other(error.to_string()))?;
let name = attribute.key.as_ref();
let prefix = if name == b"xmlns" {
""
} else if let Some(prefix) = name.strip_prefix(b"xmlns:") {
std::str::from_utf8(prefix).map_err(|error| Error::Other(error.to_string()))?
} else {
continue;
};
changes
.namespaces
.push((prefix.to_owned(), self.namespaces.remove(prefix)));
}
update_story_namespace_scope(&mut self.namespaces, element)?;
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| Error::Other(error.to_string()))?;
if attribute.key.as_ref() == b"xmlns" || attribute.key.as_ref().starts_with(b"xmlns:") {
continue;
}
let (uri, local) = self.expanded_name(attribute.key.as_ref(), true)?;
if uri == MC_NS && local == "Ignorable" {
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, element.decoder())
.map_err(|error| Error::Other(error.to_string()))?;
for prefix in value.split_ascii_whitespace() {
let uri = self
.namespaces
.get(prefix)
.filter(|uri| uri.as_str() != MC_NS)
.ok_or_else(|| {
Error::Other(format!(
"replace_paragraph_xml has invalid mc:Ignorable prefix {prefix}"
))
})?;
if !self.ignorable.contains(uri) {
self.ignorable.insert(uri.clone());
changes.ignorable.push(uri.clone());
}
}
}
}
Ok(changes)
}
fn leave(&mut self, changes: EditedParagraphScopeChanges) {
for uri in changes.ignorable {
self.ignorable.remove(&uri);
}
for (prefix, previous) in changes.namespaces.into_iter().rev() {
self.namespaces.remove(&prefix);
if let Some(uri) = previous {
self.namespaces.insert(prefix, uri);
}
}
}
}
// Original and replacement scans share these IDs. A route keeps one parent ID,
// expanded name and sibling ordinal, never a copy of every ancestor route.
#[derive(Default)]
struct EditedParagraphRoutes {
namespaces: HashMap<String, usize>,
routes: HashMap<(usize, usize, String, usize), usize>,
}
impl EditedParagraphRoutes {
fn namespace(&mut self, uri: &str) -> usize {
let next = self.namespaces.len();
*self.namespaces.entry(uri.to_owned()).or_insert(next)
}
fn child(&mut self, parent: usize, uri: &str, local: &str, ordinal: usize) -> usize {
let namespace = self.namespace(uri);
let next = self.routes.len() + 1;
*self
.routes
.entry((parent, namespace, local.to_owned(), ordinal))
.or_insert(next)
}
}
fn edited_paragraph_ancestor_context(xml: &[u8], offset: usize) -> Result<EditedParagraphContext> {
let mut reader = quick_xml::Reader::from_reader(xml);
let mut ancestors = Vec::new();
loop {
let before = reader.buffer_position() as usize;
match reader.read_event().map_err(rdocx_oxml::OxmlError::from)? {
Event::Start(element) | Event::Empty(element) if before == offset => {
// Do not inherit the original paragraph's own declarations into
// a replacement that removed or rebound them.
let _ = element;
let mut context = EditedParagraphContext::default();
for ancestor in &ancestors {
context.enter(ancestor)?;
}
return Ok(context);
}
Event::Start(element) => {
ancestors.push(element.into_owned());
}
Event::Empty(_) => {}
Event::End(_) => {
ancestors.pop();
}
Event::Eof => {
return Err(Error::Other(
"replace_paragraph_xml source ancestry was not found".into(),
));
}
_ => {}
}
}
}
#[derive(PartialEq, Eq, Hash)]
struct EditedParagraphIsland {
// Expanded ancestor names and ordered element occurrences belong to this
// selected physical owner. Equal raw bytes elsewhere are no authority.
route: usize,
name: (usize, String),
raw: Vec<u8>,
meaning: Vec<(usize, String, bool)>,
authored_ignorable: bool,
}
fn edited_paragraph_complex_mc(
element: &BytesStart<'_>,
context: &EditedParagraphContext,
) -> Result<bool> {
let (uri, _) = context.expanded_name(element.name().as_ref(), false)?;
if uri == MC_NS {
return Ok(true);
}
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| Error::Other(error.to_string()))?;
if attribute.key.as_ref() == b"xmlns" || attribute.key.as_ref().starts_with(b"xmlns:") {
continue;
}
let (uri, local) = context.expanded_name(attribute.key.as_ref(), true)?;
if uri == MC_NS && local != "Ignorable" {
return Ok(true);
}
}
Ok(false)
}
struct EditedParagraphMeaning {
names: Vec<(usize, String, bool)>,
complex_mc: bool,
}
fn edited_paragraph_island_meaning(
xml: &[u8],
context: &mut EditedParagraphContext,
routes: &mut EditedParagraphRoutes,
) -> Result<EditedParagraphMeaning> {
let mut reader = quick_xml::Reader::from_reader(xml);
let mut scopes = Vec::new();
let mut meaning = Vec::new();
let mut complex = false;
loop {
let event = reader.read_event().map_err(rdocx_oxml::OxmlError::from)?;
match event {
Event::Start(ref element) | Event::Empty(ref element) => {
let changes = context.enter(element)?;
let (uri, local) = context.expanded_name(element.name().as_ref(), false)?;
let mc_choice = uri == MC_NS && local == "Choice";
meaning.push((
routes.namespace(uri),
local,
context.ignorable.contains(uri),
));
complex |= edited_paragraph_complex_mc(element, context)?;
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| Error::Other(error.to_string()))?;
if attribute.key.as_ref() == b"xmlns"
|| attribute.key.as_ref().starts_with(b"xmlns:")
{
continue;
}
let (uri, local) = context.expanded_name(attribute.key.as_ref(), true)?;
meaning.push((
routes.namespace(uri),
format!("@{local}"),
context.ignorable.contains(uri),
));
// Opaque QName-valued producer attributes retain any
// in-scope prefix meanings they actually mention.
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, element.decoder())
.map_err(|error| Error::Other(error.to_string()))?;
for token in value.split_ascii_whitespace() {
let prefix = token.split_once(':').map(|(prefix, _)| prefix);
if let Some(prefix) = prefix
&& let Some(uri) = context.namespaces.get(prefix)
{
meaning.push((routes.namespace(uri), format!("value:{prefix}"), false));
}
if (uri == MC_NS || (mc_choice && uri.is_empty() && local == "Requires"))
&& let Some(uri) = context.namespaces.get(token)
{
meaning.push((routes.namespace(uri), format!("mc:{token}"), false));
}
}
}
if matches!(event, Event::Start(_)) {
scopes.push(changes);
} else {
context.leave(changes);
}
}
Event::End(_) => {
context.leave(scopes.pop().expect("open island element"));
}
Event::Eof => {
return Ok(EditedParagraphMeaning {
names: meaning,
complex_mc: complex,
});
}
_ => {}
}
}
}
#[derive(PartialEq, Eq, Hash)]
struct EditedParagraphMcAttribute {
route: usize,
name: String,
raw_value: Vec<u8>,
meaning: Vec<(String, Option<usize>, bool)>,
}
struct EditedParagraphAdmission {
islands: Vec<EditedParagraphIsland>,
mc_attributes: Vec<EditedParagraphMcAttribute>,
}
fn edited_paragraph_admission(
xml: &[u8],
inherited: &EditedParagraphContext,
routes: &mut EditedParagraphRoutes,
) -> Result<EditedParagraphAdmission> {
let mut reader = quick_xml::Reader::from_reader(xml);
let mut context = inherited.clone();
let mut frames = vec![(EditedParagraphScopeChanges::default(), 0usize, 0usize)];
let mut islands = Vec::new();
let mut mc_attributes = Vec::new();
let mut ppr = false;
let mut content = false;
loop {
let start = reader.buffer_position() as usize;
let event = reader.read_event().map_err(rdocx_oxml::OxmlError::from)?;
match event {
Event::Start(ref element) | Event::Empty(ref element) => {
let (_, parent_route, next) = frames.last_mut().expect("initial frame");
let changes = context.enter(element)?;
let (uri, local) = context.expanded_name(element.name().as_ref(), false)?;
let route = routes.child(*parent_route, uri, &local, *next);
*next += 1;
let direct = frames.len() == 2;
let schema = edited_paragraph_schema_child(uri, &local);
if direct && schema {
if uri == W_NS && local == "pPr" {
if ppr || content {
return Err(Error::Other(format!(
"replace_paragraph_xml requires a single first {{{W_NS}}}pPr child"
)));
}
ppr = true;
} else {
content = true;
}
}
if (!direct
&& uri == W_NS
&& matches!(local.as_str(), "drawing" | "pict" | "object"))
|| (schema && uri == M_NS)
{
// Existing native providers own supported foreign payloads,
// math and their established compatibility routes.
if matches!(event, Event::Start(_)) {
reader
.read_to_end(element.name())
.map_err(rdocx_oxml::OxmlError::from)?;
}
context.leave(changes);
continue;
}
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| Error::Other(error.to_string()))?;
if attribute.key.as_ref() == b"xmlns"
|| attribute.key.as_ref().starts_with(b"xmlns:")
{
continue;
}
let (attribute_uri, attribute_local) =
context.expanded_name(attribute.key.as_ref(), true)?;
if attribute_uri != MC_NS || attribute_local == "Ignorable" {
continue;
}
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, element.decoder())
.map_err(|error| Error::Other(error.to_string()))?;
let meaning = value
.split_ascii_whitespace()
.map(|token| {
let prefix = token.split_once(':').map_or(token, |(prefix, _)| prefix);
let uri = if prefix == "xml" {
Some("http://www.w3.org/XML/1998/namespace")
} else {
context.namespaces.get(prefix).map(String::as_str)
};
let ignored = uri.is_some_and(|uri| context.ignorable.contains(uri));
(
token.to_owned(),
uri.map(|uri| routes.namespace(uri)),
ignored,
)
})
.collect();
mc_attributes.push(EditedParagraphMcAttribute {
route,
name: attribute_local,
raw_value: attribute.value.as_ref().to_vec(),
meaning,
});
}
if (direct && !schema) || uri == MC_NS {
let end = story_element_end(xml, start)?;
let raw = &xml[start..end];
let ignored_foreign = !uri.is_empty()
&& uri != W_NS
&& uri != M_NS
&& uri != MC_NS
&& context.ignorable.contains(uri);
let name = (routes.namespace(uri), local);
let meaning = edited_paragraph_island_meaning(raw, &mut context, routes)?;
let authored_ignorable = ignored_foreign && !meaning.complex_mc;
islands.push(EditedParagraphIsland {
route,
name,
raw: raw.to_vec(),
meaning: meaning.names,
authored_ignorable,
});
if matches!(event, Event::Start(_)) {
reader
.read_to_end(element.name())
.map_err(rdocx_oxml::OxmlError::from)?;
}
} else if matches!(event, Event::Start(_)) {
frames.push((changes, route, 0));
continue;
}
context.leave(changes);
}
Event::End(_) => {
let (changes, _, _) = frames.pop().expect("open paragraph element");
context.leave(changes);
}
Event::Eof => {
return Ok(EditedParagraphAdmission {
islands,
mc_attributes,
});
}
_ => {}
}
#[cfg(test)]
{
let retained = frames.len() + routes.routes.len() + islands.len() + mc_attributes.len();
let namespace_bytes = routes.namespaces.keys().map(String::len).sum::<usize>()
+ islands
.iter()
.map(|island| std::mem::size_of_val(&island.name.0))
.sum::<usize>();
FX252_RETAINED_NAMESPACE_BYTES
.set(FX252_RETAINED_NAMESPACE_BYTES.get().max(namespace_bytes));
FX252_RETAINED_ROUTE_ENTRIES.set(FX252_RETAINED_ROUTE_ENTRIES.get().max(retained));
let scope_entries = context.namespaces.len()
+ context.ignorable.len()
+ frames
.iter()
.map(|(changes, _, _)| changes.namespaces.len() + changes.ignorable.len())
.sum::<usize>();
FX252_RETAINED_SCOPE_ENTRIES.set(FX252_RETAINED_SCOPE_ENTRIES.get().max(scope_entries));
}
}
}
fn admit_edited_paragraph(
original: &[u8],
incoming: &[u8],
inherited: &EditedParagraphContext,
) -> Result<()> {
let mut routes = EditedParagraphRoutes::default();
let original = edited_paragraph_admission(original, inherited, &mut routes)?;
let incoming = edited_paragraph_admission(incoming, inherited, &mut routes)?;
let mut original_counts = HashMap::new();
let mut incoming_counts = HashMap::new();
for island in &original.islands {
*original_counts.entry(island).or_insert(0usize) += 1;
}
for island in &incoming.islands {
*incoming_counts.entry(island).or_insert(0usize) += 1;
}
for island in original
.islands
.iter()
.chain(incoming.islands.iter())
.filter(|island| !island.authored_ignorable)
{
if original_counts.get(island) != Some(&1) || incoming_counts.get(island) != Some(&1) {
let (namespace, local) = &island.name;
let uri = routes
.namespaces
.iter()
.find_map(|(uri, id)| (id == namespace).then_some(uri))
.expect("interned island namespace");
return Err(Error::Other(format!(
"replace_paragraph_xml refuses new or reinterpreted opaque child {{{uri}}}{local}"
)));
}
}
let mut original_attributes = HashMap::new();
let mut incoming_attributes = HashMap::new();
for attribute in &original.mc_attributes {
*original_attributes.entry(attribute).or_insert(0usize) += 1;
}
for attribute in &incoming.mc_attributes {
*incoming_attributes.entry(attribute).or_insert(0usize) += 1;
}
for attribute in original
.mc_attributes
.iter()
.chain(incoming.mc_attributes.iter())
{
if original_attributes.get(attribute) != Some(&1)
|| incoming_attributes.get(attribute) != Some(&1)
{
return Err(Error::Other(format!(
"replace_paragraph_xml refuses changed unsupported {{{MC_NS}}}{} declaration",
attribute.name
)));
}
}
Ok(())
}
pub(crate) fn story_namespace_scope_at(
xml: &[u8],
offset: usize,
) -> Result<BTreeMap<String, String>> {
#[cfg(test)]
FX237_PREFIX_NAMESPACE_BYTES.set(FX237_PREFIX_NAMESPACE_BYTES.get() + offset.min(xml.len()));
let mut reader = quick_xml::Reader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut scopes = vec![BTreeMap::<String, String>::new()];
let mut changed_frames = Vec::new();
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
match reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story namespace scope scan failed: {error}")))?
{
Event::Start(element) => {
let inherited = scopes.last().expect("initial namespace scope");
let changed = changed_story_namespace_scope(inherited, &element)?;
if before == offset {
return Ok(changed.unwrap_or_else(|| inherited.clone()));
}
changed_frames.push(changed.is_some());
if let Some(scope) = changed {
scopes.push(scope);
}
}
Event::Empty(element) => {
if before == offset {
let inherited = scopes.last().expect("initial namespace scope");
return Ok(changed_story_namespace_scope(inherited, &element)?
.unwrap_or_else(|| inherited.clone()));
}
}
Event::End(_) => {
if changed_frames.pop() == Some(true) {
scopes.pop();
}
}
Event::Eof => {
return Err(Error::Other(
"story item namespace scope was not found".to_owned(),
));
}
_ => {}
}
buffer.clear();
}
}
fn story_namespace_scopes_at(
xml: &[u8],
offsets: impl IntoIterator<Item = usize>,
) -> Result<HashMap<usize, BTreeMap<String, String>>> {
let requested = offsets.into_iter().collect::<HashSet<_>>();
let mut found = HashMap::with_capacity(requested.len());
if requested.is_empty() {
return Ok(found);
}
#[cfg(test)]
STORY_NAMESPACE_SCANS.set(STORY_NAMESPACE_SCANS.get() + 1);
let mut reader = quick_xml::Reader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut scopes = vec![BTreeMap::<String, String>::new()];
let mut changed_frames = Vec::new();
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
match reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story namespace scope scan failed: {error}")))?
{
Event::Start(element) => {
let inherited = scopes.last().expect("initial namespace scope");
// Snapshot inherited context and preserve the original early
// return before this target's declarations are validated.
if requested.contains(&before) {
found.insert(before, inherited.clone());
if found.len() == requested.len() {
return Ok(found);
}
}
let changed = changed_story_namespace_scope(inherited, &element)?;
changed_frames.push(changed.is_some());
if let Some(scope) = changed {
scopes.push(scope);
}
}
Event::Empty(_) => {
if requested.contains(&before) {
found.insert(
before,
scopes.last().expect("initial namespace scope").clone(),
);
if found.len() == requested.len() {
return Ok(found);
}
}
}
Event::End(_) => {
if changed_frames.pop() == Some(true) {
scopes.pop();
}
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
let missing = requested
.into_iter()
.filter(|offset| !found.contains_key(offset))
.collect::<Vec<_>>();
if missing.is_empty() {
Ok(found)
} else {
Err(Error::Other(format!(
"story namespace scopes were not found at offsets {missing:?}"
)))
}
}
#[cfg(test)]
fn fx237_legacy_namespace_scope_at(xml: &[u8], offset: usize) -> Result<BTreeMap<String, String>> {
#[cfg(test)]
FX237_PREFIX_NAMESPACE_BYTES.set(FX237_PREFIX_NAMESPACE_BYTES.get() + offset.min(xml.len()));
let mut reader = quick_xml::Reader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut scopes = vec![BTreeMap::<String, String>::new()];
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
match reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story namespace scope scan failed: {error}")))?
{
Event::Start(element) => {
let mut scope = scopes.last().cloned().unwrap_or_default();
update_story_namespace_scope(&mut scope, &element)?;
if before == offset {
return Ok(scope);
}
scopes.push(scope);
}
Event::Empty(element) => {
if before == offset {
let mut scope = scopes.last().cloned().unwrap_or_default();
update_story_namespace_scope(&mut scope, &element)?;
return Ok(scope);
}
}
Event::End(_) => {
if scopes.len() > 1 {
scopes.pop();
}
}
Event::Eof => {
return Err(Error::Other(
"story item namespace scope was not found".to_owned(),
));
}
_ => {}
}
buffer.clear();
}
}
#[cfg(test)]
fn fx237_legacy_namespace_scopes_at(
xml: &[u8],
offsets: impl IntoIterator<Item = usize>,
) -> Result<HashMap<usize, BTreeMap<String, String>>> {
let requested = offsets.into_iter().collect::<HashSet<_>>();
let mut found = HashMap::with_capacity(requested.len());
if requested.is_empty() {
return Ok(found);
}
#[cfg(test)]
STORY_NAMESPACE_SCANS.set(STORY_NAMESPACE_SCANS.get() + 1);
let mut reader = quick_xml::Reader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut scopes = vec![BTreeMap::<String, String>::new()];
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
match reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story namespace scope scan failed: {error}")))?
{
Event::Start(element) => {
let mut scope = scopes.last().cloned().unwrap_or_default();
// Excerpts retain their own declarations on the original elements.
// A sibling sequence inherits only the context outside its first element.
if requested.contains(&before) {
found.insert(before, scope.clone());
if found.len() == requested.len() {
return Ok(found);
}
}
update_story_namespace_scope(&mut scope, &element)?;
scopes.push(scope);
}
Event::Empty(_) => {
if requested.contains(&before) {
let scope = scopes.last().cloned().unwrap_or_default();
found.insert(before, scope);
if found.len() == requested.len() {
return Ok(found);
}
}
}
Event::End(_) => {
if scopes.len() > 1 {
scopes.pop();
}
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
let missing = requested
.into_iter()
.filter(|offset| !found.contains_key(offset))
.collect::<Vec<_>>();
if missing.is_empty() {
Ok(found)
} else {
Err(Error::Other(format!(
"story namespace scopes were not found at offsets {missing:?}"
)))
}
}
fn scoped_story_fragment(
xml: &[u8],
range: Range<usize>,
scope: &BTreeMap<String, String>,
) -> Result<(Vec<u8>, usize)> {
let original = xml
.get(range)
.ok_or_else(|| Error::Other("story fragment lies outside its source part".to_owned()))?;
let closed = close_content_fragment_namespaces(original, scope)?;
let added = closed
.len()
.checked_sub(original.len())
.ok_or_else(|| Error::Other("namespace closure shortened a story fragment".to_owned()))?;
Ok((closed, added))
}
fn scan_story_items_with_scope(
xml: &[u8],
owner: &StoryOwnerSpan,
scope: &BTreeMap<String, String>,
) -> Result<Vec<StoryItemSpan>> {
let (closed, added) = scoped_story_fragment(xml, owner.full.clone(), scope)?;
let mut local_owner = owner.clone();
local_owner.full = 0..closed.len();
let mut items = scan_story_items(&closed, &local_owner)?;
let map = |position: usize| {
if position == 0 {
owner.full.start
} else {
owner.full.start + position - added
}
};
for item in &mut items {
item.full = map(item.full.start)..map(item.full.end);
item.scan = map(item.scan.start)..map(item.scan.end);
for ancestor in &mut item.complex_ancestors {
*ancestor = map(*ancestor);
}
}
Ok(items)
}
fn scoped_story_item(
xml: &[u8],
item: &StoryItemSpan,
scope: &BTreeMap<String, String>,
) -> Result<(Vec<u8>, StoryItemSpan, usize)> {
let (closed, added, scan) = if item.complex_field {
// A complex field spans sibling runs. Bind their shared context on an
// inert envelope, leaving every source byte and local declaration intact.
let original = xml.get(item.scan.clone()).ok_or_else(|| {
Error::Other("complex story field lies outside its source part".to_owned())
})?;
let mut closed = b"<rdocx_story_excerpt".to_vec();
for (prefix, namespace) in scope {
closed.extend_from_slice(b" xmlns");
if !prefix.is_empty() {
closed.push(b':');
closed.extend_from_slice(prefix.as_bytes());
}
closed.extend_from_slice(b"=\"");
closed.extend_from_slice(quick_xml::escape::escape(namespace).as_bytes());
closed.push(b'"');
}
closed.push(b'>');
let added = closed.len();
closed.extend_from_slice(original);
let end = closed.len();
closed.extend_from_slice(b"</rdocx_story_excerpt>");
(closed, added, added..end)
} else {
let (closed, added) = scoped_story_fragment(xml, item.scan.clone(), scope)?;
let end = closed.len();
(closed, added, 0..end)
};
let source_len = item
.scan
.end
.checked_sub(item.scan.start)
.ok_or_else(|| Error::Other("story item has an inverted scan span".to_owned()))?;
let local = |position: usize| -> Result<usize> {
if !item.complex_field && position == item.scan.start {
return Ok(0);
}
position
.checked_sub(item.scan.start)
.filter(|relative| *relative <= source_len)
.and_then(|relative| relative.checked_add(added))
.ok_or_else(|| Error::Other("story item lies outside its scan span".to_owned()))
};
let full = local(item.full.start)?..local(item.full.end)?;
if full.start > full.end {
return Err(Error::Other(
"story item has an inverted source span".to_owned(),
));
}
let local = StoryItemSpan {
kind: item.kind,
full,
scan,
direct_owner_child: item.direct_owner_child,
complex_field: item.complex_field,
// Ancestors gate typed discovery in scan_story_items. Neither excerpt
// scanner consumes them, and an external ancestor has no local offset.
complex_ancestors: Vec::new(),
sdt_context: item.sdt_context,
};
Ok((closed, local, added))
}
fn story_item_text_with_scope(
xml: &[u8],
item: &StoryItemSpan,
scope: &BTreeMap<String, String>,
qualified_items: &[StoryItemSpan],
) -> Result<Option<String>> {
let (closed, local, added) = scoped_story_item(xml, item, scope)?;
story_item_text(&closed, &local, qualified_items, item.scan.clone(), added)
}
fn scan_story_item_links_with_scope(
xml: &[u8],
item: &StoryItemSpan,
scope: &BTreeMap<String, String>,
) -> Result<Vec<StoryLinkSpan>> {
let (closed, local, added) = scoped_story_item(xml, item, scope)?;
let links = scan_story_item_links(&closed, &local)?;
let map = |position: usize| -> Result<usize> {
if !item.complex_field && position == 0 {
return Ok(item.scan.start);
}
position
.checked_sub(added)
.and_then(|relative| item.scan.start.checked_add(relative))
.filter(|source| item.scan.start <= *source && *source <= item.scan.end)
.ok_or_else(|| Error::Other("story hyperlink lies outside its source span".to_owned()))
};
links
.into_iter()
.map(|link| {
let full = map(link.full.start)?..map(link.full.end)?;
if full.start > full.end {
return Err(Error::Other(
"story hyperlink has an inverted source span".to_owned(),
));
}
Ok(StoryLinkSpan {
full,
rel_id: link.rel_id,
anchor: link.anchor,
})
})
.collect()
}
/// Scan one story's hyperlinks in physical source order, each paired with the
/// smallest item that owns it. [`Document::story_links`] numbers this list.
fn story_link_spans(
xml: &[u8],
owner: &StoryOwnerSpan,
story: &StoryId,
) -> Result<Vec<(ContentLocation, StoryLinkSpan)>> {
let items = scan_story_items(xml, owner)?;
let item_scopes = story_namespace_scopes_at(xml, items.iter().map(|item| item.scan.start))?;
ordered_story_link_spans(xml, story, items, &item_scopes)
}
/// Pair the hyperlinks of one story's scanned items with the smallest item
/// that owns each, in physical source order.
fn ordered_story_link_spans(
xml: &[u8],
story: &StoryId,
items: Vec<StoryItemSpan>,
item_scopes: &HashMap<usize, BTreeMap<String, String>>,
) -> Result<Vec<(ContentLocation, StoryLinkSpan)>> {
let mut links = Vec::new();
for (index, item) in items.iter().enumerate() {
let scope = item_scopes.get(&item.scan.start).ok_or_else(|| {
Error::Other("story item namespace scope was not inventoried".to_owned())
})?;
let owner_width = item.full.end - item.full.start;
for link in scan_story_item_links_with_scope(xml, item, scope)? {
let location = ContentLocation {
story: story.clone(),
item_kind: item.kind,
index_path: vec![index],
is_end: false,
};
links.push((owner_width, location, link));
}
}
links.sort_by_key(|(owner_width, _, link)| (link.full.start, *owner_width));
links.dedup_by(|left, right| left.2.full == right.2.full);
Ok(links
.into_iter()
.map(|(_, location, link)| (location, link))
.collect())
}
fn content_fragment_root_is_section_properties(xml: &[u8], item: &StoryItemSpan) -> Result<bool> {
let mut scope = story_namespace_scope_at(xml, item.full.start)?;
let mut reader = quick_xml::Reader::from_reader(&xml[item.full.clone()]);
let mut buffer = Vec::new();
loop {
match reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("content fragment scan failed: {error}")))?
{
Event::Start(element) | Event::Empty(element) => {
update_story_namespace_scope(&mut scope, &element)?;
let name = element.name();
let bytes = name.as_ref();
let (prefix, local) =
if let Some(colon) = bytes.iter().position(|byte| *byte == b':') {
(
std::str::from_utf8(&bytes[..colon]).map_err(|error| {
Error::Other(format!(
"content fragment namespace prefix is invalid: {error}"
))
})?,
&bytes[colon + 1..],
)
} else {
("", bytes)
};
return Ok(local == b"sectPr"
&& scope
.get(prefix)
.is_some_and(|value| value == WORD_NAMESPACE));
}
Event::Eof => {
return Err(Error::Other(
"content fragment has no root element".to_owned(),
));
}
_ => buffer.clear(),
}
}
}
fn direct_story_content_items(xml: &[u8], owner: &StoryOwnerSpan) -> Result<Vec<StoryItemSpan>> {
let mut items = Vec::new();
for item in scan_story_items(xml, owner)? {
if !item.direct_owner_child {
continue;
}
if owner.kind == StoryKind::Body
&& item.kind == StoryItemKind::PreservedNode
&& content_fragment_root_is_section_properties(xml, &item)?
{
continue;
}
items.push(item);
}
Ok(items)
}
pub(crate) fn validate_fragment_block_content(content: &[u8]) -> Result<()> {
let mut xml = format!(
r#"<w:document xmlns:w="{}"><w:body>"#,
rdocx_oxml::namespace::W_NS
)
.into_bytes();
xml.extend_from_slice(content);
xml.extend_from_slice(b"</w:body></w:document>");
let owner = scan_story_owners(&xml, StoryKind::Body)?
.into_iter()
.find(|owner| owner.kind == StoryKind::Body)
.ok_or_else(|| Error::Other("fragment content has no body owner".to_owned()))?;
for item in direct_story_content_items(&xml, &owner)? {
let scope = story_namespace_scope_at(&xml, item.full.start)?;
let item_xml = close_content_fragment_namespaces(&xml[item.full], &scope)?;
validate_fragment_block_item(&item_xml)?;
}
Ok(())
}
fn validate_fragment_block_item(xml: &[u8]) -> Result<()> {
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
if let (namespace, Event::Start(element) | Event::Empty(element)) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("invalid document fragment item: {error}")))?
&& word_element(&namespace)
{
match element.local_name().as_ref() {
b"sdt" => validate_serialized_block_content_control(xml)?,
b"r" | b"hyperlink" | b"fldSimple" | b"drawing" | b"pict" => {
return Err(Error::Other(
"document fragment boundaries admit block content only".to_owned(),
));
}
_ => {}
}
}
Ok(())
}
fn story_owner_content_end(xml: &[u8], owner: &StoryOwnerSpan) -> Result<usize> {
if owner.kind == StoryKind::Body {
for item in scan_story_items(xml, owner)? {
if item.direct_owner_child
&& item.kind == StoryItemKind::PreservedNode
&& content_fragment_root_is_section_properties(xml, &item)?
{
return Ok(item.full.start);
}
}
}
let mut reader = quick_xml::Reader::from_reader(&xml[owner.full.clone()]);
reader.config_mut().trim_text(false);
let mut depth = 0usize;
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
match reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story owner boundary scan failed: {error}")))?
{
Event::Start(_) => depth += 1,
Event::Empty(_) if depth == 0 => {
return (owner.full.end >= owner.full.start + 2)
.then_some(owner.full.end - 2)
.ok_or_else(|| Error::Other("empty story owner is incomplete".to_owned()));
}
Event::End(_) if depth == 1 => return Ok(owner.full.start + before),
Event::End(_) => depth = depth.saturating_sub(1),
Event::Eof => {
return Err(Error::Other(
"story owner has no closing boundary".to_owned(),
));
}
_ => {}
}
buffer.clear();
}
}
pub(crate) fn package_authoritative_body_fragment(
xml: &[u8],
include_final_section_properties: bool,
destination_scope: &BTreeMap<String, String>,
) -> Result<Vec<u8>> {
let owner = scan_story_owners(xml, StoryKind::Body)?
.into_iter()
.find(|owner| owner.kind == StoryKind::Body)
.ok_or_else(|| Error::Other("document fragment has no main body".to_owned()))?;
let mut output = Vec::new();
let items = direct_story_content_items(xml, &owner)?;
let mut cursor = items
.first()
.map(|item| item.full.start)
.ok_or_else(|| Error::Other("document fragment main body is empty".to_owned()))?;
for item in items {
output.extend_from_slice(&xml[cursor..item.full.start]);
let scope = story_namespace_scope_at(xml, item.full.start)?;
let required = scope
.into_iter()
.filter(|(prefix, namespace)| destination_scope.get(prefix) != Some(namespace))
.collect();
output.extend(close_content_fragment_namespaces(
&xml[item.full.clone()],
&required,
)?);
cursor = item.full.end;
}
output.extend_from_slice(&xml[cursor..story_owner_content_end(xml, &owner)?]);
if include_final_section_properties {
let mut section = None;
for item in scan_story_items(xml, &owner)? {
if item.direct_owner_child
&& item.kind == StoryItemKind::PreservedNode
&& content_fragment_root_is_section_properties(xml, &item)?
{
section = Some(item);
break;
}
}
let section = section.ok_or_else(|| {
Error::Other("section-inclusive document fragment has no final section".to_owned())
})?;
let scope = story_namespace_scope_at(xml, section.full.start)?;
let required = scope
.into_iter()
.filter(|(prefix, namespace)| destination_scope.get(prefix) != Some(namespace))
.collect();
let section = close_content_fragment_namespaces(&xml[section.full], &required)?;
output.extend_from_slice(format!(r#"<w:p xmlns:w="{WORD_NAMESPACE}"><w:pPr>"#).as_bytes());
output.extend(section);
output.extend_from_slice(b"</w:pPr></w:p>");
}
Ok(output)
}
pub(crate) fn close_content_fragment_namespaces(
xml: &[u8],
scope: &BTreeMap<String, String>,
) -> Result<Vec<u8>> {
let mut reader = quick_xml::Reader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut buffer = Vec::new();
let (opening_end, empty, existing) = loop {
let event = reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("content fragment scan failed: {error}")))?;
match event {
Event::Start(element) => {
let mut existing = HashSet::new();
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| {
Error::Other(format!("content fragment attribute scan failed: {error}"))
})?;
let name = attribute.key.as_ref();
if name == b"xmlns" {
existing.insert(String::new());
} else if let Some(prefix) = name.strip_prefix(b"xmlns:") {
existing.insert(
std::str::from_utf8(prefix)
.map_err(|error| {
Error::Other(format!(
"content fragment namespace prefix is invalid: {error}"
))
})?
.to_owned(),
);
}
}
break (reader.buffer_position() as usize, false, existing);
}
Event::Empty(element) => {
let mut existing = HashSet::new();
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| {
Error::Other(format!("content fragment attribute scan failed: {error}"))
})?;
let name = attribute.key.as_ref();
if name == b"xmlns" {
existing.insert(String::new());
} else if let Some(prefix) = name.strip_prefix(b"xmlns:") {
existing.insert(
std::str::from_utf8(prefix)
.map_err(|error| {
Error::Other(format!(
"content fragment namespace prefix is invalid: {error}"
))
})?
.to_owned(),
);
}
}
break (reader.buffer_position() as usize, true, existing);
}
Event::Eof => {
return Err(Error::Other(
"content fragment has no root element".to_owned(),
));
}
_ => buffer.clear(),
}
};
let mut declarations = Vec::new();
for (prefix, namespace) in scope {
if prefix == "xml" || existing.contains(prefix) {
continue;
}
declarations.extend_from_slice(b" xmlns");
if !prefix.is_empty() {
declarations.push(b':');
declarations.extend_from_slice(prefix.as_bytes());
}
declarations.extend_from_slice(b"=\"");
declarations.extend_from_slice(quick_xml::escape::escape(namespace).as_bytes());
declarations.push(b'"');
}
if declarations.is_empty() {
return Ok(xml.to_vec());
}
let insertion = opening_end
.checked_sub(if empty { 2 } else { 1 })
.ok_or_else(|| Error::Other("content fragment opening tag is incomplete".to_owned()))?;
let mut closed = Vec::with_capacity(xml.len() + declarations.len());
closed.extend_from_slice(&xml[..insertion]);
closed.extend_from_slice(&declarations);
closed.extend_from_slice(&xml[insertion..]);
Ok(closed)
}
fn reject_section_owning_content_fragment(fragment: &ContentFragment) -> Result<()> {
let xml = close_content_fragment_namespaces(&fragment.xml, &fragment.namespace_scope)?;
let mut wrapped = format!(r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body>"#).into_bytes();
wrapped.extend_from_slice(&xml);
wrapped.extend_from_slice(b"</w:body></w:document>");
let document = CT_Document::from_xml(&wrapped)?;
let mut owns_section = false;
visit_body_paragraphs(&document.body.content, false, &mut |paragraph| {
owns_section |= paragraph
.properties
.as_ref()
.is_some_and(|properties| properties.sect_pr.is_some());
});
if owns_section {
return Err(Error::Other(
"generic content operations cannot mutate a section-owning paragraph".to_owned(),
));
}
Ok(())
}
fn serialize_content_fragment(content: BodyContent) -> Result<Vec<u8>> {
let mut document = CT_Document::new();
document.body.content = vec![content];
document.body.sect_pr = None;
let xml = document.to_xml()?;
let owner = scan_story_owners(&xml, StoryKind::Body)?
.into_iter()
.find(|owner| owner.kind == StoryKind::Body)
.ok_or_else(|| Error::Other("serialized content fragment has no body owner".to_owned()))?;
let items = direct_story_content_items(&xml, &owner)?;
let [item] = items.as_slice() else {
return Err(Error::Other(
"serialized content fragment must contain exactly one direct item".to_owned(),
));
};
let scope = story_namespace_scope_at(&xml, item.full.start)?;
close_content_fragment_namespaces(&xml[item.full.clone()], &scope)
}
const BLOCK_SDT_PROPERTIES_SLOT: u8 = 1;
const BLOCK_SDT_END_PROPERTIES_SLOT: u8 = 2;
const BLOCK_SDT_CONTENT_SLOT: u8 = 4;
fn validate_serialized_block_content_control(xml: &[u8]) -> Result<()> {
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut buffer = Vec::new();
let mut depth = 0usize;
let mut root_seen = false;
let mut controls: Vec<(usize, Option<usize>, u8, u8)> = Vec::new();
loop {
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(rdocx_oxml::OxmlError::from)?;
match event {
Event::Start(element) => {
validate_block_control_element(
&namespace,
&element,
depth,
false,
&mut root_seen,
&mut controls,
)?;
depth += 1;
}
Event::Empty(element) => validate_block_control_element(
&namespace,
&element,
depth,
true,
&mut root_seen,
&mut controls,
)?,
Event::End(element) => {
if depth == 0 {
return Err(Error::Other(
"block content control has an unmatched end element".to_owned(),
));
}
if controls.last().is_some_and(|control| {
control.0 == depth
&& word_element(&namespace)
&& matches_local_name(element.name().as_ref(), b"sdt")
}) {
controls.pop();
}
depth -= 1;
}
Event::Text(text) => {
let bytes: &[u8] = text.as_ref();
if block_control_text_is_direct(depth, &controls)
&& !block_control_bytes_are_xml_whitespace(bytes)?
{
return Err(Error::Other(
"block content control has direct non-whitespace text".to_owned(),
));
}
}
Event::CData(text) => {
let bytes: &[u8] = text.as_ref();
if block_control_text_is_direct(depth, &controls)
&& !block_control_bytes_are_xml_whitespace(bytes)?
{
return Err(Error::Other(
"block content control has direct non-whitespace CDATA".to_owned(),
));
}
}
Event::GeneralRef(reference) if block_control_text_is_direct(depth, &controls) => {
if !block_control_reference_is_whitespace(&reference)? {
return Err(Error::Other(
"block content control has a direct non-whitespace reference".to_owned(),
));
}
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
if !root_seen || depth != 0 || !controls.is_empty() {
return Err(Error::Other(
"block content control is structurally incomplete".to_owned(),
));
}
Ok(())
}
fn block_control_reference_is_whitespace(
reference: &quick_xml::events::BytesRef<'_>,
) -> Result<bool> {
if let Some(character) = reference.resolve_char_ref().map_err(|error| {
Error::Other(format!(
"block content control has an invalid character reference: {error}"
))
})? {
if !xml_1_0_character_is_valid(character) {
return Err(Error::Other(
"block content control has an XML 1.0-forbidden character reference".to_owned(),
));
}
return Ok(xml_1_0_whitespace(character));
}
let name = reference.decode().map_err(|error| {
Error::Other(format!(
"block content control has an invalid entity reference: {error}"
))
})?;
let value = resolve_xml_entity(&name).ok_or_else(|| {
Error::Other(format!(
"block content control has an unresolved entity reference: &{name};"
))
})?;
if !value.chars().all(xml_1_0_character_is_valid) {
return Err(Error::Other(format!(
"block content control entity reference contains an XML 1.0-forbidden character: &{name};"
)));
}
Ok(value.chars().all(xml_1_0_whitespace))
}
fn block_control_bytes_are_xml_whitespace(bytes: &[u8]) -> Result<bool> {
let text = std::str::from_utf8(bytes).map_err(|error| {
Error::Other(format!(
"block content control has invalid UTF-8 text: {error}"
))
})?;
Ok(text.chars().all(xml_1_0_whitespace))
}
fn xml_1_0_whitespace(character: char) -> bool {
matches!(character, '\u{20}' | '\u{9}' | '\u{A}' | '\u{D}')
}
fn xml_1_0_character_is_valid(character: char) -> bool {
matches!(character, '\t' | '\n' | '\r')
|| ('\u{20}'..='\u{D7FF}').contains(&character)
|| ('\u{E000}'..='\u{FFFD}').contains(&character)
|| ('\u{10000}'..='\u{10FFFF}').contains(&character)
}
fn validate_block_control_element(
namespace: &ResolveResult<'_>,
element: &BytesStart<'_>,
parent_depth: usize,
empty: bool,
root_seen: &mut bool,
controls: &mut Vec<(usize, Option<usize>, u8, u8)>,
) -> Result<()> {
let is_word = word_element(namespace);
let name = element.name();
let local = name.as_ref();
if controls.is_empty() {
if *root_seen || !is_word || !matches_local_name(local, b"sdt") {
return Err(Error::Other(
"block content control must contain exactly one Word sdt root".to_owned(),
));
}
*root_seen = true;
if !empty {
controls.push((parent_depth + 1, None, 0, 0));
}
return Ok(());
}
let (root_depth, content_depth) = {
let control = controls
.last()
.expect("active block content control was checked above");
(control.0, control.1)
};
if parent_depth == root_depth {
if !is_word {
return Ok(());
}
let (slot, order) = if matches_local_name(local, b"sdtPr") {
(BLOCK_SDT_PROPERTIES_SLOT, 0)
} else if matches_local_name(local, b"sdtEndPr") {
(BLOCK_SDT_END_PROPERTIES_SLOT, 1)
} else if matches_local_name(local, b"sdtContent") {
(BLOCK_SDT_CONTENT_SLOT, 2)
} else {
return Err(Error::Other(
"block content control has an invalid Word root child".to_owned(),
));
};
let control = controls
.last_mut()
.expect("active block content control was checked above");
if control.3 & slot != 0 || control.2 > order {
return Err(Error::Other(
"block content control root children are duplicated or out of order".to_owned(),
));
}
control.2 = order;
control.3 |= slot;
if slot == BLOCK_SDT_CONTENT_SLOT && !empty {
control.1 = Some(parent_depth + 1);
}
return Ok(());
}
if content_depth != Some(parent_depth) {
return Ok(());
}
if !is_word {
return Ok(());
}
if matches_local_name(local, b"p") || matches_local_name(local, b"tbl") {
return Ok(());
}
if matches_local_name(local, b"sdt") {
if !empty {
controls.push((parent_depth + 1, None, 0, 0));
}
return Ok(());
}
Err(Error::Other(
"block content control has a non-block Word content child".to_owned(),
))
}
fn block_control_text_is_direct(depth: usize, controls: &[(usize, Option<usize>, u8, u8)]) -> bool {
controls
.last()
.is_some_and(|control| depth == control.0 || Some(depth) == control.1)
}
fn validated_direct_content_item(
xml: &[u8],
owner: &StoryOwnerSpan,
location: &ContentLocation,
) -> Result<StoryItemSpan> {
if location.index_path.len() != 1 {
return Err(StoryError::InvalidPath {
path: location.index_path.clone(),
}
.into());
}
let items = scan_story_items(xml, owner)?;
let index = location.index_path[0];
let item = items.get(index).cloned().ok_or(StoryError::OutOfBounds {
index,
len: items.len(),
})?;
if item.kind != location.item_kind {
return Err(StoryError::KindMismatch {
expected: location.item_kind,
actual: item.kind,
}
.into());
}
if !item.direct_owner_child
|| owner.kind == StoryKind::Body && content_fragment_root_is_section_properties(xml, &item)?
{
return Err(StoryError::InvalidPath {
path: location.index_path.clone(),
}
.into());
}
Ok(item)
}
// Nested paragraph paths use the enclosing block control's content as the
// effective block owner while keeping the underlying part's relationship scope.
fn fragment_content_boundary(
xml: &[u8],
owner: &StoryOwnerSpan,
location: &ContentLocation,
) -> Result<(StoryOwnerSpan, usize, usize, usize)> {
if location.is_end || location.index_path.len() == 1 {
let (boundary, index, count) = validated_content_boundary(xml, owner, location)?;
return Ok((owner.clone(), boundary, index, count));
}
let [control_index, paragraph_index] = location.index_path.as_slice() else {
return Err(StoryError::InvalidPath {
path: location.index_path.clone(),
}
.into());
};
if location.item_kind != StoryItemKind::Paragraph {
return Err(StoryError::InvalidPath {
path: location.index_path.clone(),
}
.into());
}
let items = scan_story_items(xml, owner)?;
let control = items.get(*control_index).ok_or(StoryError::OutOfBounds {
index: *control_index,
len: items.len(),
})?;
if control.kind != StoryItemKind::ContentControl
|| control.sdt_context != Some(StorySdtContext::Block)
{
return Err(StoryError::InvalidPath {
path: location.index_path.clone(),
}
.into());
}
let paragraphs = scan_story_control_paragraphs(xml, control)?;
let paragraph = paragraphs
.get(*paragraph_index)
.ok_or(StoryError::OutOfBounds {
index: *paragraph_index,
len: paragraphs.len(),
})?;
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
let mut stack = Vec::new();
let mut enclosing = Vec::new();
loop {
let before = reader.buffer_position() as usize;
match reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("fragment control owner scan failed: {error}")))?
{
Event::Start(element) => {
let (namespace, local) = reader.resolver().resolve_element(element.name());
stack.push((
word_element(&namespace) && local.as_ref() == b"sdtContent",
before,
));
}
Event::End(_) => {
if let Some((true, start)) = stack.pop() {
let end = reader.buffer_position() as usize;
if start >= control.full.start
&& end <= control.full.end
&& start < paragraph.start
&& end > paragraph.end
{
enclosing.push(start..end);
}
}
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
let full = enclosing
.into_iter()
.min_by_key(|span| span.end - span.start)
.ok_or_else(|| Error::Other("fragment paragraph has no block control owner".to_owned()))?;
let effective = StoryOwnerSpan {
full,
..owner.clone()
};
let direct = direct_story_content_items(xml, &effective)?;
let index = direct
.iter()
.position(|item| item.kind == StoryItemKind::Paragraph && item.full == *paragraph)
.ok_or_else(|| {
Error::Other("fragment paragraph is not a direct block control child".to_owned())
})?;
Ok((effective, paragraph.start, index, direct.len()))
}
fn validated_content_boundary(
xml: &[u8],
owner: &StoryOwnerSpan,
location: &ContentLocation,
) -> Result<(usize, usize, usize)> {
let items = direct_story_content_items(xml, owner)?;
if location.is_end {
return Ok((
story_owner_content_end(xml, owner)?,
items.len(),
items.len(),
));
}
if location.index_path.len() != 1 {
return Err(StoryError::InvalidPath {
path: location.index_path.clone(),
}
.into());
}
let projected = scan_story_items(xml, owner)?;
let index = location.index_path[0];
let item = projected.get(index).ok_or(StoryError::OutOfBounds {
index,
len: projected.len(),
})?;
if item.kind != location.item_kind {
return Err(StoryError::KindMismatch {
expected: location.item_kind,
actual: item.kind,
}
.into());
}
if !item.direct_owner_child
|| owner.kind == StoryKind::Body && content_fragment_root_is_section_properties(xml, item)?
{
return Err(StoryError::InvalidPath {
path: location.index_path.clone(),
}
.into());
}
let direct_index = items
.iter()
.position(|candidate| candidate.full == item.full)
.ok_or_else(|| Error::Other("direct content destination was not found".to_owned()))?;
Ok((item.full.start, direct_index, items.len()))
}
fn insert_story_fragment(
source: &mut Vec<u8>,
owner: &StoryOwnerSpan,
boundary: usize,
fragment: Vec<u8>,
) -> Result<()> {
let mut reader = quick_xml::Reader::from_reader(&source[owner.full.clone()]);
let mut buffer = Vec::new();
let empty_name = loop {
match reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story owner insertion scan failed: {error}")))?
{
Event::Start(_) => break None,
Event::Empty(element) => break Some(element.name().as_ref().to_vec()),
Event::Eof => {
return Err(Error::Other(
"story owner has no insertion boundary".to_owned(),
));
}
_ => buffer.clear(),
}
};
if let Some(name) = empty_name {
if boundary + 2 != owner.full.end {
return Err(Error::Other(
"empty story owner insertion boundary is invalid".to_owned(),
));
}
let mut replacement = Vec::with_capacity(fragment.len() + name.len() + 4);
replacement.push(b'>');
replacement.extend(fragment);
replacement.extend_from_slice(b"</");
replacement.extend(name);
replacement.push(b'>');
source.splice(boundary..owner.full.end, replacement);
} else {
source.splice(boundary..boundary, fragment);
}
Ok(())
}
fn story_scope_matches_fragment(destination: &StoryId, fragment: &ContentFragment) -> bool {
fragment.source_part_name.as_deref() == Some(destination.part_name.as_str())
&& fragment.source_story_kind == Some(destination.kind)
&& fragment.source_owner_index == Some(destination.owner_index)
}
/// Concrete typed owners used only while qualifying replacement XML.
/// Raw preservation payloads never acquire relationship type authority.
enum WordXmlOwner<'a> {
Paragraph(&'a CT_P),
Run(&'a CT_R),
Table(&'a CT_Tbl),
Row(&'a CT_Row),
Cell(&'a CT_Tc),
Control(&'a CT_Sdt),
}
/// Qualify changed bookmark and comment identities through existing physical
/// source inventories. Unchanged selected identities retain their owner proof.
fn validate_element_xml_identity_change(
document: &Document,
story: &StoryId,
previous: &[u8],
incoming: &[u8],
) -> Result<()> {
let old_comments = crate::comments::comment_source_markers(previous)?;
let new_comments = crate::comments::comment_source_markers(incoming)?;
if !old_comments
.iter()
.map(|marker| (marker.id, marker.family))
.eq(new_comments.iter().map(|marker| (marker.id, marker.family)))
{
let counts = |markers: &[crate::comments::CommentSourceMarker]| -> Result<BTreeMap<i32, [usize; 3]>> {
let mut counts = BTreeMap::new();
for marker in markers {
let entry = counts.entry(marker.id).or_insert([0usize; 3]);
entry[marker.family] = entry[marker.family].checked_add(1).ok_or_else(|| Error::Other("element XML comment count overflow".into()))?;
}
Ok(counts)
};
let before = counts(&old_comments)?;
let after = counts(&new_comments)?;
let mut old_order = BTreeMap::<i32, Vec<usize>>::new();
let mut new_order = BTreeMap::<i32, Vec<usize>>::new();
for marker in &old_comments {
old_order.entry(marker.id).or_default().push(marker.family);
}
for marker in &new_comments {
new_order.entry(marker.id).or_default().push(marker.family);
}
let changed = old_order
.keys()
.chain(new_order.keys())
.copied()
.filter(|id| old_order.get(id) != new_order.get(id))
.collect::<HashSet<_>>();
let mut prepared = document.clone_for_staging();
prepared.flush_to_package()?;
let inventory = prepared.comment_source_inventory_at(&prepared.doc_part_name)?;
if !inventory.contains_key(&story.part_name) {
return Err(Error::Other(
"element XML comment relationship scope is unsupported".into(),
));
}
let mut total = BTreeMap::<i32, [usize; 3]>::new();
for markers in inventory.values() {
for (id, counts) in counts(markers)? {
let entry = total.entry(id).or_default();
for family in 0..3 {
entry[family] = entry[family].checked_add(counts[family]).ok_or_else(|| {
Error::Other("element XML comment inventory overflow".into())
})?;
}
}
}
for id in &changed {
let id = *id;
let old = before.get(&id).copied().unwrap_or_default();
let new = after.get(&id).copied().unwrap_or_default();
if new.iter().any(|count| *count > 1) || new[0] != new[1] {
return Err(Error::Other(
"element XML changed comment span outside the selected owner is unsupported"
.into(),
));
}
if new != [0; 3] {
prepared.fragment_comment_dependency(id)?;
}
let all = total.get(&id).copied().unwrap_or_default();
let mut combined = [0usize; 3];
for family in 0..3 {
combined[family] = all[family]
.checked_sub(old[family])
.and_then(|outside| outside.checked_add(new[family]))
.ok_or_else(|| {
Error::Other("element XML comment owner correspondence is invalid".into())
})?;
}
if combined.iter().any(|count| *count > 1) || combined[0] != combined[1] {
return Err(Error::Other(
"element XML comment ID collides or leaves an unmatched physical owner".into(),
));
}
}
let mut started = HashSet::new();
for marker in &new_comments {
if !changed.contains(&marker.id) {
continue;
}
if marker.family == 0 {
started.insert(marker.id);
} else if marker.family == 1 && !started.contains(&marker.id) {
return Err(Error::Other(
"element XML comment end precedes its start".into(),
));
}
}
}
let counts = |markers: &[BookmarkMarkerSpan]| -> Result<BTreeMap<i32, [usize; 2]>> {
let mut counts = BTreeMap::new();
for marker in markers {
let id = marker
.id
.parse::<i32>()
.map_err(|_| Error::Other("element XML bookmark has an invalid ID".into()))?;
let count = counts.entry(id).or_insert([0usize; 2]);
let family = usize::from(!marker.start);
count[family] = count[family]
.checked_add(1)
.ok_or_else(|| Error::Other("element XML bookmark count overflow".into()))?;
}
Ok(counts)
};
let previous_markers = bookmark_marker_spans(previous)?;
let incoming_markers = bookmark_marker_spans(incoming)?;
let before = counts(&previous_markers)?;
let after = counts(&incoming_markers)?;
let mut old_order = BTreeMap::<i32, Vec<bool>>::new();
let mut new_order = BTreeMap::<i32, Vec<bool>>::new();
for marker in &previous_markers {
old_order
.entry(marker.id.parse().unwrap())
.or_default()
.push(marker.start);
}
for marker in &incoming_markers {
new_order
.entry(marker.id.parse().unwrap())
.or_default()
.push(marker.start);
}
let changed = old_order
.keys()
.chain(new_order.keys())
.copied()
.filter(|id| old_order.get(id) != new_order.get(id))
.collect::<HashSet<_>>();
if changed.is_empty() {
return Ok(());
}
let (source, _) = document.story_source_and_owner(story)?;
let total = counts(&bookmark_marker_spans(&source.xml)?)?;
for id in &changed {
let id = *id;
let old = before.get(&id).copied().unwrap_or_default();
let new = after.get(&id).copied().unwrap_or_default();
if new != [0, 0] && new != [1, 1] {
return Err(Error::Other(
"element XML changed bookmark span outside the selected owner is unsupported"
.into(),
));
}
let total = total.get(&id).copied().unwrap_or_default();
let mut combined = [0usize; 2];
for family in 0..2 {
combined[family] = total[family]
.checked_sub(old[family])
.and_then(|outside| outside.checked_add(new[family]))
.ok_or_else(|| {
Error::Other("element XML bookmark owner correspondence is invalid".into())
})?;
}
if combined != [0, 0] && combined != [1, 1] {
return Err(Error::Other(
"element XML bookmark ID collides or leaves an unmatched physical owner".into(),
));
}
}
let mut started = HashSet::new();
for marker in &incoming_markers {
let id = marker
.id
.parse::<i32>()
.map_err(|_| Error::Other("element XML bookmark has an invalid ID".into()))?;
if !changed.contains(&id) {
continue;
}
if marker.start {
started.insert(id);
} else if !started.contains(&id) {
return Err(Error::Other(
"element XML bookmark end precedes its start".into(),
));
}
}
Ok(())
}
fn validate_element_owner_relationships(
document: &Document,
story: &StoryId,
root: WordXmlOwner<'_>,
) -> Result<()> {
let mut pending = vec![root];
while let Some(owner) = pending.pop() {
match owner {
WordXmlOwner::Paragraph(paragraph) => {
for hyperlink in ¶graph.hyperlinks {
if let Some(id) = &hyperlink.rel_id {
document.hyperlink_url_for_story(story, id)?;
}
}
pending.extend(paragraph.runs.iter().map(WordXmlOwner::Run));
pending.extend(
paragraph
.content_controls
.iter()
.map(|(_, _, _, control)| WordXmlOwner::Control(control)),
);
for (_, _, revision) in ¶graph.revisions {
if let Some(paragraph) = revision.content_paragraph() {
pending.push(WordXmlOwner::Paragraph(paragraph));
}
}
for ruby in ¶graph.rubies {
pending.extend(ruby.ruby_text.iter().map(WordXmlOwner::Run));
}
}
WordXmlOwner::Run(run) => {
for content in &run.content {
match content {
RunContent::Field(field) => {
field.validate_for_attachment()?;
if let Some(runs) = field.cached_result_runs() {
pending.extend(runs.iter().map(WordXmlOwner::Run));
}
}
RunContent::FootnoteRef { id, .. } | RunContent::EndnoteRef { id, .. } => {
if document.glossary_part_name.as_deref()
== Some(story.part_name.as_str())
{
return Err(Error::Other(
"element XML glossary note reference scope is unsupported"
.into(),
));
}
let kind = if matches!(content, RunContent::FootnoteRef { .. }) {
StoryKind::Footnote
} else {
StoryKind::Endnote
};
document.fragment_note_dependency(kind, *id)?;
}
RunContent::Drawing(drawing) => {
if drawing.inline.is_some() && drawing.anchor.is_some() {
return Err(Error::Other(
"element XML drawing has two modeled owners".into(),
));
}
let relationships = drawing
.inline
.as_ref()
.map(|inline| {
(
&inline.embed_id,
inline.link_id.as_deref(),
inline.chart_rel_id.as_deref(),
)
})
.or_else(|| {
drawing.anchor.as_ref().map(|anchor| {
(
&anchor.embed_id,
anchor.link_id.as_deref(),
anchor.chart_rel_id.as_deref(),
)
})
});
if let Some((embed, link, chart)) = relationships {
if !embed.is_empty() {
let target = document
.validate_internal_relationship_for_story(
story,
embed,
rel_types::IMAGE,
)?;
if !document
.package
.content_types
.content_type_for(&target)
.is_some_and(|kind| kind.starts_with("image/"))
{
return Err(Error::Other("element XML image relationship targets a non-image part".into()));
}
}
if let Some(id) = link {
let relationship = document
.package
.get_part_rels(&story.part_name)
.and_then(|relationships| relationships.get_by_id(id))
.ok_or_else(|| {
Error::Other(
"element XML linked image relationship is missing"
.into(),
)
})?;
if relationship.rel_type != rel_types::IMAGE
|| relationship.target_mode.as_deref() != Some("External")
{
return Err(Error::Other("element XML linked image requires an external image relationship".into()));
}
}
if let Some(id) = chart {
let target = document
.validate_internal_relationship_for_story(
story,
id,
rel_types::CHART,
)?;
let source =
document.package.get_part(&target).ok_or_else(|| {
Error::Other(
"element XML chart target is missing".into(),
)
})?;
if document.package.content_types.content_type_for(&target)
!= Some(content_types::CHART)
{
return Err(Error::Other("element XML chart relationship targets a non-chart part".into()));
}
CT_ChartSpace::from_xml(source).map_err(|error| {
Error::Other(format!("element XML chart target: {error}"))
})?;
}
}
if let Some(shape) = drawing
.anchor
.as_ref()
.and_then(|anchor| anchor.shape.as_ref())
{
if let Some(body) = &shape.text_body {
for content in &body.content {
match content {
BodyContent::Paragraph(paragraph) => {
pending.push(WordXmlOwner::Paragraph(paragraph))
}
BodyContent::Table(table) => {
pending.push(WordXmlOwner::Table(table))
}
BodyContent::ContentControl(control) => {
pending.push(WordXmlOwner::Control(control))
}
BodyContent::RawXml(_) => {}
}
}
} else {
pending.extend(shape.text.iter().map(WordXmlOwner::Paragraph));
}
}
}
_ => {}
}
}
}
WordXmlOwner::Table(table) => {
validate_table_topology(table)?;
pending.extend(table.rows.iter().map(WordXmlOwner::Row));
pending.extend(
table
.content_controls
.iter()
.map(|(_, _, control)| WordXmlOwner::Control(control)),
);
}
WordXmlOwner::Row(row) => {
pending.extend(row.cells.iter().map(WordXmlOwner::Cell));
pending.extend(
row.content_controls
.iter()
.map(|(_, _, control)| WordXmlOwner::Control(control)),
);
}
WordXmlOwner::Cell(cell) => {
for content in &cell.content {
match content {
CellContent::Paragraph(paragraph) => {
pending.push(WordXmlOwner::Paragraph(paragraph))
}
CellContent::Table(table) => pending.push(WordXmlOwner::Table(table)),
CellContent::ContentControl(control) => {
pending.push(WordXmlOwner::Control(control))
}
}
}
}
WordXmlOwner::Control(control) => {
for content in &control.content {
match content {
SdtContent::Paragraph(paragraph) => {
pending.push(WordXmlOwner::Paragraph(paragraph))
}
SdtContent::Run(run) => pending.push(WordXmlOwner::Run(run)),
SdtContent::Table(table) => pending.push(WordXmlOwner::Table(table)),
SdtContent::Row(row) => pending.push(WordXmlOwner::Row(row)),
SdtContent::Cell(cell) => pending.push(WordXmlOwner::Cell(cell)),
SdtContent::ContentControl(control) => {
pending.push(WordXmlOwner::Control(control))
}
SdtContent::RawXml(_) => {}
}
}
for (_, revision) in control.revisions() {
if let Some(paragraph) = revision.content_paragraph() {
pending.push(WordXmlOwner::Paragraph(paragraph));
}
}
}
}
}
Ok(())
}
fn validate_fragment_relationships(
document: &Document,
destination: &StoryId,
fragment: &ContentFragment,
) -> Result<()> {
let inherited = fragment
.namespace_scope
.iter()
.map(|(prefix, namespace)| {
let name = if prefix.is_empty() {
"xmlns".to_owned()
} else {
format!("xmlns:{prefix}")
};
(name, namespace.clone())
})
.collect::<Vec<_>>();
let relationship_ids = xml_relationship_ids_in_order_with_bindings(&fragment.xml, &inherited)?;
if relationship_ids.is_empty() {
return Ok(());
}
if !story_scope_matches_fragment(destination, fragment) {
return Err(Error::Other(
"content fragment relationship references require the unchanged story owner".to_owned(),
));
}
let relationships = document
.package
.get_part_rels(&destination.part_name)
.ok_or_else(|| {
Error::Other(format!(
"content fragment owner {} has no relationship scope",
destination.part_name
))
})?;
for id in relationship_ids {
if relationships.get_by_id(&id).is_none() {
return Err(Error::Other(format!(
"content fragment references missing relationship {id} in {}",
destination.part_name
)));
}
}
Ok(())
}
fn content_fragment_for_insertion(
document: &Document,
destination: &StoryId,
fragment: &ContentFragment,
) -> Result<Vec<u8>> {
validate_fragment_relationships(document, destination, fragment)?;
if fragment.source_part_name.is_none() || story_scope_matches_fragment(destination, fragment) {
Ok(fragment.xml.clone())
} else {
close_content_fragment_namespaces(&fragment.xml, &fragment.namespace_scope)
}
}
fn freshen_content_fragment_identities(
document: &mut Document,
fragment: &ContentFragment,
) -> Result<Vec<u8>> {
let mut wrapper_prefix = "rdocxw".to_owned();
let mut suffix = 1usize;
while fragment.namespace_scope.contains_key(&wrapper_prefix) {
wrapper_prefix = format!("rdocxw{suffix}");
suffix = suffix
.checked_add(1)
.ok_or_else(|| Error::Other("content wrapper prefix space exhausted".to_owned()))?;
}
let mut prefix =
format!(r#"<{wrapper_prefix}:document xmlns:{wrapper_prefix}="{WORD_NAMESPACE}""#)
.into_bytes();
for (namespace_prefix, namespace) in &fragment.namespace_scope {
if namespace_prefix == "xml" || namespace_prefix == &wrapper_prefix {
continue;
}
prefix.extend_from_slice(b" xmlns");
if !namespace_prefix.is_empty() {
prefix.push(b':');
prefix.extend_from_slice(namespace_prefix.as_bytes());
}
prefix.extend_from_slice(b"=\"");
prefix.extend_from_slice(quick_xml::escape::escape(namespace).as_bytes());
prefix.push(b'"');
}
prefix.extend_from_slice(format!("><{wrapper_prefix}:body>").as_bytes());
let suffix = format!("</{wrapper_prefix}:body></{wrapper_prefix}:document>");
let mut wrapped = prefix;
wrapped.extend_from_slice(&fragment.xml);
wrapped.extend_from_slice(suffix.as_bytes());
let updated = crate::field::freshen_content_fragment_identities(document, &wrapped)?;
let updated = remove_comment_anchors_from_fragment(&updated)?;
let owner = scan_story_owners(&updated, StoryKind::Body)?
.into_iter()
.find(|owner| owner.kind == StoryKind::Body)
.ok_or_else(|| Error::Other("rewritten content wrapper has no body".to_owned()))?;
let items = direct_story_content_items(&updated, &owner)?;
let [item] = items.as_slice() else {
return Err(Error::Other(
"rewritten content wrapper must contain exactly one item".to_owned(),
));
};
Ok(updated[item.full.clone()].to_vec())
}
fn remove_comment_anchors_from_fragment(xml: &[u8]) -> Result<Vec<u8>> {
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut ranges = Vec::new();
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("comment anchor scan failed: {error}")))?;
let is_word = word_element(&namespace);
drop(namespace);
let after = reader.buffer_position() as usize;
match event {
Event::Empty(element)
if is_word
&& matches!(
element.local_name().as_ref(),
b"commentRangeStart" | b"commentRangeEnd" | b"commentReference"
) =>
{
ranges.push(before..after);
}
Event::Start(element)
if is_word
&& matches!(
element.local_name().as_ref(),
b"commentRangeStart" | b"commentRangeEnd" | b"commentReference"
) =>
{
ranges.push(before..story_element_end(xml, before)?);
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
let mut result = Vec::with_capacity(xml.len());
let mut cursor = 0usize;
for range in ranges {
result.extend_from_slice(&xml[cursor..range.start]);
cursor = range.end;
}
result.extend_from_slice(&xml[cursor..]);
Ok(result)
}
/// Hand vertical merges started by `row_index` to the row below before removal.
fn restart_merges_continued_below(table: &mut CT_Tbl, row_index: usize) -> Result<()> {
let grid_columns = table
.grid
.as_ref()
.map(|grid| grid.columns.len())
.ok_or_else(|| Error::Other("table has no active grid".to_owned()))?;
let Some([row, below]) = table.rows.get_mut(row_index..row_index + 2) else {
return Ok(());
};
let restarted = row_cell_ranges(row, grid_columns)?
.into_iter()
.zip(&row.cells)
.filter_map(|(range, cell)| {
(cell
.properties
.as_ref()
.and_then(|properties| properties.v_merge.as_ref())
== Some(&VMerge::Restart))
.then_some(range)
})
.collect::<HashSet<_>>();
for (range, cell) in row_cell_ranges(below, grid_columns)?
.into_iter()
.zip(&mut below.cells)
{
if restarted.contains(&range)
&& let Some(properties) = cell.properties.as_mut()
&& properties.v_merge == Some(VMerge::Continue)
{
properties.v_merge = Some(VMerge::Restart);
}
}
Ok(())
}
pub(crate) fn set_story_source_xml(
document: &mut Document,
part_name: &str,
xml: Vec<u8>,
) -> Result<()> {
if part_name == document.doc_part_name {
// The main-part story source is canonical XML. It drops a root
// default namespace and every body declaration, and binds the root
// `w`, `r` and `mc` prefixes to their canonical URIs. The save path's
// check on those declarations keeps retained content of the replaced
// part in its namespace.
let rewritten_root_declarations: Vec<_> = document
.root_namespace_declarations
.iter()
.filter(|(name, _)| {
matches!(name.as_str(), "xmlns" | "xmlns:w" | "xmlns:r" | "xmlns:mc")
})
.cloned()
.collect();
if let Some(prefix) = unsafe_serializer_namespace_prefix(
&rewritten_root_declarations,
&document.body_namespace_declarations,
document.package.get_part(part_name),
) {
return Err(Error::Other(format!(
"cannot serialize a modified document with a shadowed `{prefix}` namespace"
)));
}
let namespace_scopes = document_namespace_scopes(&xml)?;
document.document = CT_Document::from_xml(&xml)?;
document.package.set_part(part_name, xml);
document.root_namespace_declarations = namespace_scopes.root_declarations;
document.body_namespace_declarations = namespace_scopes.body_declarations;
document.body_namespace_bindings = namespace_scopes.body_bindings;
} else if document.comments_part_name.as_deref() == Some(part_name) {
document.comments = Some(rdocx_oxml::comments::CT_Comments::from_xml(&xml)?);
document.package.set_part(part_name, xml);
document.comments_dirty = false;
} else if document.footnotes_part_name.as_deref() == Some(part_name) {
document.footnotes = rdocx_oxml::footnotes::CT_Footnotes::from_xml(&xml)?;
document.package.set_part(part_name, xml);
document.footnotes_dirty = false;
} else if document.glossary_part_name.as_deref() == Some(part_name) {
document.glossary = Some(rdocx_oxml::glossary::CT_GlossaryDocument::from_xml(&xml)?);
document.package.set_part(part_name, xml);
document.glossary_dirty = false;
} else {
document.package.set_part(part_name, xml);
}
Ok(())
}
fn serialized_note_fragment(
note: &rdocx_oxml::footnotes::CT_Footnote,
kind: StoryKind,
) -> Result<Vec<u8>> {
let notes = rdocx_oxml::footnotes::CT_Footnotes {
footnotes: vec![note.clone()],
};
let serialized = match kind {
StoryKind::Footnote => notes.to_xml_footnotes()?,
StoryKind::Endnote => notes.to_xml_endnotes()?,
_ => return Err(Error::Other("invalid note story kind".to_owned())),
};
let owner = scan_story_owners_with_special(&serialized, kind, true)?
.into_iter()
.next()
.ok_or_else(|| Error::Other("serialized note has no story owner".to_owned()))?;
let scope = story_namespace_scope_at(&serialized, owner.full.start)?;
close_content_fragment_namespaces(&serialized[owner.full], &scope)
}
fn serialized_footnote_fragment(footnote: &rdocx_oxml::footnotes::CT_Footnote) -> Result<Vec<u8>> {
serialized_note_fragment(footnote, StoryKind::Footnote)
}
fn remove_note_references(xml: &[u8], id: i32, kind: StoryKind) -> Result<Vec<u8>> {
let local_name = match kind {
StoryKind::Footnote => b"footnoteReference".as_slice(),
StoryKind::Endnote => b"endnoteReference".as_slice(),
_ => return Err(Error::Other("invalid note story kind".to_owned())),
};
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut buffer = Vec::new();
let mut ranges = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("footnote reference scan failed: {error}")))?;
let start = match &event {
Event::Start(start) | Event::Empty(start)
if word_element(&namespace) && start.local_name().as_ref() == local_name =>
{
start
}
Event::Eof => break,
_ => {
buffer.clear();
continue;
}
};
let mut reference_id = None;
for attribute in start.attributes() {
let attribute = attribute.map_err(|error| {
Error::Other(format!("footnote reference attribute scan failed: {error}"))
})?;
let (namespace, local) = reader.resolver().resolve_attribute(attribute.key);
if word_element(&namespace) && local.as_ref() == b"id" {
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|error| {
Error::Other(format!("footnote reference ID is invalid: {error}"))
})?;
reference_id = value.parse::<i32>().ok();
}
}
if reference_id == Some(id) {
let end = if matches!(event, Event::Empty(_)) {
reader.buffer_position() as usize
} else {
story_element_end(xml, before)?
};
ranges.push(before..end);
}
buffer.clear();
}
let mut updated = xml.to_vec();
for range in ranges.into_iter().rev() {
updated.drain(range);
}
Ok(updated)
}
fn append_story_fragments_to_root(
xml: &[u8],
root_local_name: &[u8],
item_local_name: &[u8],
fragments: &[Vec<u8>],
) -> Result<Vec<u8>> {
if fragments.is_empty() {
return Ok(xml.to_vec());
}
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
let mut depth = 0usize;
let mut saw_root = false;
let mut after_last_item = None;
let boundary = loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("invalid footnotes XML: {error}")))?;
let is_word = word_element(&namespace);
drop(namespace);
let after = reader.buffer_position() as usize;
match event {
Event::Start(element) => {
if !saw_root {
if is_word && element.local_name().as_ref() == root_local_name {
saw_root = true;
depth = 1;
}
} else {
if depth == 1 && is_word && element.local_name().as_ref() == item_local_name {
after_last_item = Some(story_element_end(xml, before)?);
}
depth += 1;
}
}
Event::Empty(element) => {
if !saw_root && is_word && element.local_name().as_ref() == root_local_name {
let name = element.name().as_ref().to_vec();
let insertion = after.checked_sub(2).ok_or_else(|| {
Error::Other("empty story root opening tag is incomplete".to_owned())
})?;
let mut updated = Vec::new();
updated.extend_from_slice(&xml[..insertion]);
updated.push(b'>');
updated.extend(
fragments
.iter()
.flat_map(|fragment| fragment.iter().copied()),
);
updated.extend_from_slice(b"</");
updated.extend_from_slice(&name);
updated.push(b'>');
updated.extend_from_slice(&xml[after..]);
return Ok(updated);
}
if saw_root
&& depth == 1
&& is_word
&& element.local_name().as_ref() == item_local_name
{
after_last_item = Some(after);
}
}
Event::End(element) => {
if saw_root
&& depth == 1
&& is_word
&& element.local_name().as_ref() == root_local_name
{
break after_last_item.unwrap_or(before);
}
depth = depth.saturating_sub(1);
}
Event::Eof => {
return Err(Error::Other(format!(
"story XML has no closing {} root element",
String::from_utf8_lossy(root_local_name)
)));
}
_ => {}
}
buffer.clear();
};
let mut updated = xml.to_vec();
updated.splice(
boundary..boundary,
fragments
.iter()
.flat_map(|fragment| fragment.iter().copied()),
);
Ok(updated)
}
fn update_story_namespace_scope(
scope: &mut BTreeMap<String, String>,
element: &BytesStart<'_>,
) -> Result<()> {
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| {
Error::Other(format!("story namespace scope attribute failed: {error}"))
})?;
let name = attribute.key.as_ref();
let prefix = if name == b"xmlns" {
""
} else if let Some(prefix) = name.strip_prefix(b"xmlns:") {
std::str::from_utf8(prefix).map_err(|error| {
Error::Other(format!("story namespace scope prefix is invalid: {error}"))
})?
} else {
continue;
};
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, element.decoder())
.map_err(|error| {
Error::Other(format!("story namespace scope value is invalid: {error}"))
})?
.into_owned();
if value.is_empty() {
scope.remove(prefix);
} else {
scope.insert(prefix.to_owned(), value);
}
}
Ok(())
}
fn story_word_prefix_for_scope(scope: &BTreeMap<String, String>) -> String {
scope
.iter()
.find_map(|(prefix, namespace)| (namespace == WORD_NAMESPACE).then_some(prefix.clone()))
.unwrap_or_else(|| {
let mut suffix = 0usize;
loop {
let candidate = if suffix == 0 {
"rdocxw".to_owned()
} else {
format!("rdocxw{suffix}")
};
if !scope.contains_key(&candidate) {
break candidate;
}
suffix += 1;
}
})
}
fn complex_story_field_xml(xml: &[u8], item: &StoryItemSpan) -> Result<Vec<u8>> {
let scope = story_namespace_scope_at(xml, item.scan.start)?;
let word_prefix = story_word_prefix_for_scope(&scope);
let mut bindings = scope;
bindings
.entry(word_prefix.clone())
.or_insert_with(|| WORD_NAMESPACE.to_owned());
let qualified_paragraph = if word_prefix.is_empty() {
"p".to_owned()
} else {
format!("{word_prefix}:p")
};
let mut projection = format!("<{qualified_paragraph}").into_bytes();
for (prefix, namespace) in bindings {
projection.extend_from_slice(b" xmlns");
if !prefix.is_empty() {
projection.push(b':');
projection.extend_from_slice(prefix.as_bytes());
}
projection.extend_from_slice(b"=\"");
projection.extend_from_slice(quick_xml::escape::escape(&namespace).as_bytes());
projection.push(b'"');
}
projection.push(b'>');
projection.extend_from_slice(&complex_story_field_runs(xml, item)?);
projection.extend_from_slice(format!("</{qualified_paragraph}>").as_bytes());
Ok(projection)
}
struct StoryRunShell {
open_end: usize,
close_start: usize,
close_end: usize,
}
fn story_run_shell(xml: &[u8], start: usize) -> Result<StoryRunShell> {
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut active = false;
let mut depth = 0usize;
let mut open_end = None;
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (_, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story run shell scan failed: {error}")))?;
let after = reader.buffer_position() as usize;
if !active {
if before != start {
if matches!(event, Event::Eof) {
break;
}
buffer.clear();
continue;
}
if !matches!(event, Event::Start(_)) {
return Err(Error::Other(
"complex story field does not begin in a run".to_owned(),
));
}
active = true;
depth = 1;
open_end = Some(after);
} else {
match event {
Event::Start(_) => depth += 1,
Event::End(_) => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Ok(StoryRunShell {
open_end: open_end.unwrap_or(after),
close_start: before,
close_end: after,
});
}
}
Event::Eof => break,
_ => {}
}
}
buffer.clear();
}
Err(Error::Other(
"complex story field run has no matching close".to_owned(),
))
}
fn complex_story_field_runs(xml: &[u8], item: &StoryItemSpan) -> Result<Vec<u8>> {
let first = story_run_shell(xml, item.scan.start)?;
let mut last_start = item.scan.start;
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut depth = 0usize;
let mut active = false;
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("complex story run scan failed: {error}")))?;
let is_word_namespace = word_element(&namespace);
drop(namespace);
let after = reader.buffer_position() as usize;
if !active {
if before != item.scan.start {
if matches!(event, Event::Eof) {
break;
}
buffer.clear();
continue;
}
active = true;
}
match event {
Event::Start(element) => {
if depth == 0 && is_word_namespace && element.local_name().as_ref() == b"r" {
last_start = before;
}
depth += 1;
}
Event::End(_) => depth = depth.saturating_sub(1),
Event::Eof => break,
_ => {}
}
if after == item.scan.end {
break;
}
buffer.clear();
}
let last = story_run_shell(xml, last_start)?;
let mut result = Vec::new();
if last_start == item.scan.start {
result.extend_from_slice(&xml[item.scan.start..first.open_end]);
result.extend_from_slice(&xml[item.full.clone()]);
result.extend_from_slice(&xml[first.close_start..first.close_end]);
return Ok(result);
}
result.extend_from_slice(&xml[item.scan.start..first.open_end]);
result.extend_from_slice(&xml[item.full.start..first.close_end]);
result.extend_from_slice(&xml[first.close_end..last_start]);
result.extend_from_slice(&xml[last_start..last.open_end]);
result.extend_from_slice(&xml[last.open_end..item.full.end]);
result.extend_from_slice(&xml[last.close_start..last.close_end]);
Ok(result)
}
// Match CT_Sdt's actual first nonempty content slot. Empty content and
// subsequent content are retained raw, never visible modeled descendants.
fn admit_story_sdt_content_slot(
stack: &mut [XmlElementFrame],
namespace: StoryNamespace,
local_name: &[u8],
empty: bool,
) -> bool {
if namespace != StoryNamespace::Word || local_name != b"sdtContent" {
return true;
}
let Some(parent) = stack.last_mut() else {
return true;
};
if parent.namespace != StoryNamespace::Word || parent.local_name != b"sdt" {
return true;
}
if empty || parent.typed_sdt_content_seen {
return false;
}
parent.typed_sdt_content_seen = true;
true
}
fn modeled_story_event(
stack: &mut [XmlElementFrame],
namespace: StoryNamespace,
local_name: &[u8],
element: &BytesStart<'_>,
empty: bool,
xml: &[u8],
before: usize,
after: usize,
) -> Result<bool> {
if !modeled_story_child(stack, namespace, local_name) {
return Ok(false);
}
if !admit_story_sdt_content_slot(stack, namespace, local_name, empty) {
return Ok(false);
}
if namespace == StoryNamespace::MarkupCompatibility && local_name == b"Choice" {
// The text box is read from the first Choice that holds one, as layout
// draws the first Choice drawing. Any other Choice stays opaque, as the
// Fallback does.
let Some(alternate) = stack.last() else {
return Ok(false);
};
let Some(text_box) = first_text_box_start(xml, alternate.full_start)? else {
return Ok(false);
};
return Ok(!empty && before < text_box && text_box < story_element_end(xml, before)?);
}
if namespace != StoryNamespace::Word {
return Ok(true);
}
if matches!(
local_name,
b"sdt" | b"ins" | b"del" | b"moveFrom" | b"moveTo" | b"fldSimple"
) {
let inherited = stack
.last()
.map_or(&[][..], |frame| frame.word_prefixes.as_slice());
let word_prefixes = story_word_prefixes_at(element, inherited, true)?;
let raw_end = if empty {
after
} else {
story_element_end(xml, before)?
};
let raw = xml
.get(before..raw_end)
.ok_or_else(|| Error::Other("story element lies outside its source part".to_owned()))?;
if local_name == b"sdt" {
let owner = match story_sdt_context(stack) {
Some(StorySdtContext::Block) => StorySdtOwner::Block,
Some(StorySdtContext::Table) => StorySdtOwner::Table,
Some(StorySdtContext::Row) => StorySdtOwner::Row,
Some(StorySdtContext::Inline) => StorySdtOwner::Inline,
None => return Ok(false),
};
#[cfg(test)]
{
let slot = match FX211_PROFILE_CONTEXT.get() {
1 => Some(2),
2 => Some(4),
3 => Some(8),
4 => Some(10),
_ => None,
};
if let Some(slot) = slot {
let mut counts = FX211_PROFILE.get();
counts[slot] += 1;
counts[slot + 1] += raw.len();
FX211_PROFILE.set(counts);
}
}
return Ok(CT_Sdt::story_raw_is_typed(raw, &word_prefixes, owner));
}
if matches!(local_name, b"ins" | b"del" | b"moveFrom" | b"moveTo") {
return Ok(CT_Revision::story_raw_is_typed(raw, &word_prefixes));
}
return Ok(!empty && story_simple_field_is_typed(raw, &word_prefixes));
}
if empty && matches!(local_name, b"drawing" | b"pict" | b"sdt" | b"hyperlink") {
return Ok(false);
}
Ok(true)
}
/// Where the first `txbxContent` element inside the element that starts at
/// `start` begins, whatever its prefix.
fn first_text_box_start(xml: &[u8], start: usize) -> Result<Option<usize>> {
let fragment = xml
.get(start..)
.ok_or_else(|| Error::Other("story element begins outside its source part".to_owned()))?;
let mut reader = quick_xml::Reader::from_reader(fragment);
reader.config_mut().trim_text(false);
let mut depth = 0usize;
let mut buffer = Vec::new();
loop {
let before = start + reader.buffer_position() as usize;
match reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story text box scan failed: {error}")))?
{
Event::Start(element) | Event::Empty(element)
if depth > 0 && element.local_name().as_ref() == b"txbxContent" =>
{
return Ok(Some(before));
}
Event::Start(_) => depth += 1,
Event::Empty(_) if depth == 0 => return Ok(None),
Event::End(_) => {
depth = depth.saturating_sub(1);
if depth == 0 {
return Ok(None);
}
}
Event::Eof => return Ok(None),
_ => {}
}
buffer.clear();
}
}
struct PairedTextBoxCopy {
alternate: usize,
branch: usize,
range: Range<usize>,
}
fn scoped_alternate_text_box_copies(xml: &[u8]) -> Result<Vec<PairedTextBoxCopy>> {
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
let mut stack = Vec::<XmlElementFrame>::new();
let mut copies = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let event = reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("alternate text-box scan failed: {error}")))?;
let empty = matches!(event, Event::Empty(_));
match event {
Event::Start(element) | Event::Empty(element) => {
let (namespace, local) = reader.resolver().resolve_element(element.name());
let namespace = story_namespace(&namespace);
let local = local.as_ref();
// Inspect both representations using the same qualified structural
// routes as story discovery, without selecting or rewriting Fallback.
let alternate_branch = namespace == StoryNamespace::MarkupCompatibility
&& matches!(local, b"Choice" | b"Fallback")
&& stack.last().is_some_and(|parent| {
parent.namespace == StoryNamespace::MarkupCompatibility
&& parent.local_name == b"AlternateContent"
});
let branch_content = namespace == StoryNamespace::Word
&& matches!(local, b"drawing" | b"pict")
&& stack.last().is_some_and(|parent| {
parent.namespace == StoryNamespace::MarkupCompatibility
&& matches!(parent.local_name.as_slice(), b"Choice" | b"Fallback")
});
let modeled = stack.last().is_none_or(|parent| !parent.opaque)
&& (alternate_branch
|| branch_content
|| modeled_story_child(&stack, namespace, local))
&& admit_story_sdt_content_slot(&mut stack, namespace, local, empty);
if modeled && namespace == StoryNamespace::Word && local == b"txbxContent" {
let end = if empty {
reader.buffer_position() as usize
} else {
story_element_end(xml, before)?
};
for (index, alternate) in stack.iter().enumerate() {
if alternate.namespace != StoryNamespace::MarkupCompatibility
|| alternate.local_name != b"AlternateContent"
|| alternate.opaque
{
continue;
}
if let Some(branch) = stack.get(index + 1)
&& branch.namespace == StoryNamespace::MarkupCompatibility
&& matches!(branch.local_name.as_slice(), b"Choice" | b"Fallback")
{
copies.push(PairedTextBoxCopy {
alternate: alternate.full_start,
branch: branch.full_start,
range: before..end,
});
}
}
}
if !empty {
let sdt_context = story_element_sdt_context(&stack, namespace, local);
stack.push(XmlElementFrame {
namespace,
local_name: local.to_vec(),
is_word: namespace == StoryNamespace::Word,
full_start: before,
owner_kind: None,
item_kind: None,
direct_owner_child: false,
sdt_context,
word_prefixes: Vec::new(),
opaque: !modeled,
typed_sdt_content_seen: false,
});
}
}
Event::End(_) => {
stack.pop();
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
Ok(copies)
}
struct PairedTextEdit {
range: Range<usize>,
raw: Vec<u8>,
}
// Bounded physical correspondence inside one admitted AlternateContent.
struct PairedTextBoxProjection {
signature: Vec<String>,
slots: Vec<(Vec<usize>, Range<usize>)>,
}
fn paired_text_box_projection(xml: &[u8], range: Range<usize>) -> Result<PairedTextBoxProjection> {
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
let mut stack = Vec::<XmlElementFrame>::new();
let mut paths = Vec::<Option<Vec<usize>>>::new();
let mut children = Vec::<usize>::new();
let mut signature = Vec::new();
let mut slots = Vec::new();
let mut active = false;
loop {
let before = reader.buffer_position() as usize;
let event = reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("paired text-box projection failed: {error}")))?;
let after = reader.buffer_position() as usize;
if !active {
if before != range.start {
if matches!(event, Event::Eof) {
break;
}
buffer.clear();
continue;
}
active = true;
}
let empty = matches!(event, Event::Empty(_));
match event {
Event::Start(element) | Event::Empty(element) => {
let (namespace, local) = reader.resolver().resolve_element(element.name());
let namespace = story_namespace(&namespace);
let local = local.as_ref();
let inherited = if stack.is_empty() {
story_namespace_scope_at(xml, before)?
.into_iter()
.filter(|(_, uri)| uri == rdocx_oxml::namespace::W_NS)
.map(|(prefix, _)| prefix)
.collect::<Vec<_>>()
} else {
stack.last().unwrap().word_prefixes.clone()
};
let word_prefixes = story_word_prefixes_at(
&element,
&inherited,
namespace == StoryNamespace::Word,
)?;
let modeled = !stack.last().is_some_and(|frame| frame.opaque)
&& modeled_story_event(
&mut stack, namespace, local, &element, empty, xml, before, after,
)?;
if !stack.is_empty()
&& modeled
&& ((namespace == StoryNamespace::Word && local == b"txbxContent")
|| namespace == StoryNamespace::MarkupCompatibility)
{
return Err(Error::Other(
"nested alternate/text-box correspondence is unsupported".into(),
));
}
let structural = modeled
&& namespace == StoryNamespace::Word
&& matches!(
local,
b"txbxContent" | b"p" | b"tbl" | b"tr" | b"tc" | b"sdt" | b"sdtContent"
);
let path = if structural {
let parent = paths.iter().rposition(Option::is_some);
let mut path = parent.and_then(|i| paths[i].clone()).unwrap_or_default();
if let Some(parent) = parent {
path.push(children[parent]);
children[parent] += 1;
}
let end = if empty {
after
} else {
story_element_end(xml, before)?
};
slots.push((path.clone(), before..end));
signature.push(format!("+{}", String::from_utf8_lossy(local)));
if local == b"tbl" {
let scope = story_namespace_scope_at(xml, before)?;
let closed = close_content_fragment_namespaces(&xml[before..end], &scope)?;
let mut wrapped =
format!(r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body>"#)
.into_bytes();
wrapped.extend_from_slice(&closed);
wrapped.extend_from_slice(b"</w:body></w:document>");
let parsed = CT_Document::from_xml(&wrapped)?;
let Some(BodyContent::Table(table)) = parsed.body.content.first() else {
return Err(Error::Other(
"paired table is not a qualified table".into(),
));
};
validate_table_topology(table)?;
let columns = table.grid.as_ref().map_or(0, |grid| grid.columns.len());
signature.push(format!("grid:{columns}"));
for row in &table.rows {
let ranges = crate::table::row_cell_ranges(row, columns)?;
signature.push(format!("row:{:?}", ranges));
signature.push(format!(
"omissions:{:?}:{:?}",
row.properties.as_ref().and_then(|p| p.grid_before),
row.properties.as_ref().and_then(|p| p.grid_after)
));
for cell in &row.cells {
signature.push(format!(
"merge:{:?}:{:?}",
cell.properties.as_ref().and_then(|p| p.v_merge),
cell.properties.as_ref().and_then(|p| p.h_merge.as_ref())
));
}
}
}
if local == b"p" {
let scope = story_namespace_scope_at(xml, before)?;
let closed = close_content_fragment_namespaces(&xml[before..end], &scope)?;
signature.push(CT_P::from_xml_fragment(&closed)?.accepted_text());
}
Some(path)
} else {
None
};
if modeled
&& namespace == StoryNamespace::Word
&& matches!(
local,
b"gridCol"
| b"gridBefore"
| b"gridAfter"
| b"gridSpan"
| b"vMerge"
| b"hMerge"
)
{
let mut fact = String::from_utf8_lossy(local).into_owned();
for attribute in element.attributes() {
let attribute =
attribute.map_err(|error| Error::Other(error.to_string()))?;
let (attribute_namespace, attribute_local) =
reader.resolver().resolve_attribute(attribute.key);
if word_element(&attribute_namespace) && attribute_local.as_ref() == b"val"
{
fact.push(':');
fact.push_str(
&attribute
.decoded_and_normalized_value(
XmlVersion::Implicit1_0,
reader.decoder(),
)
.map_err(|error| Error::Other(error.to_string()))?,
);
}
}
signature.push(fact);
}
if empty {
if structural {
signature.push("-".into());
}
} else {
let sdt_context = story_element_sdt_context(&stack, namespace, local);
stack.push(XmlElementFrame {
namespace,
local_name: local.to_vec(),
is_word: namespace == StoryNamespace::Word,
full_start: before,
owner_kind: None,
item_kind: None,
direct_owner_child: false,
sdt_context,
word_prefixes,
opaque: !modeled,
typed_sdt_content_seen: false,
});
paths.push(path);
children.push(0);
}
}
Event::End(_) => {
if paths.pop().flatten().is_some() {
signature.push("-".into());
}
children.pop();
stack.pop();
}
Event::Eof => break,
_ => {}
}
if after == range.end {
return Ok(PairedTextBoxProjection { signature, slots });
}
buffer.clear();
}
Err(Error::Other("paired text-box source is incomplete".into()))
}
struct PairedReplacement<'a> {
span: &'a Range<usize>,
paragraph: Option<usize>,
placeholder: &'a str,
replacement: &'a str,
count: usize,
}
fn paired_scoped_edits(
xml: &[u8],
owner: &StoryOwnerSpan,
replacement: &PairedReplacement<'_>,
) -> Result<Option<Vec<PairedTextEdit>>> {
let PairedReplacement {
span,
paragraph,
placeholder,
replacement,
count,
} = *replacement;
let copies = scoped_alternate_text_box_copies(xml)?;
let selected = copies
.iter()
.filter(|copy| copy.range.start <= owner.full.start && owner.full.end <= copy.range.end)
.max_by_key(|copy| copy.alternate);
let Some(selected) = selected else {
return Ok(None);
};
let mut branches = BTreeMap::<usize, Vec<&PairedTextBoxCopy>>::new();
for copy in &copies {
if copy.alternate == selected.alternate {
branches.entry(copy.branch).or_default().push(copy);
}
}
if branches.len() < 2 {
return Ok(None);
}
let selected_branch = &branches[&selected.branch];
let selected_slot = selected_branch
.iter()
.position(|copy| copy.range == selected.range)
.ok_or_else(|| Error::Other("paired selected box disappeared".into()))?;
let baseline = selected_branch
.iter()
.map(|copy| paired_text_box_projection(xml, copy.range.clone()))
.collect::<Result<Vec<_>>>()?;
let selected_signature = &baseline[selected_slot].signature;
if selected_branch.len() > 1
&& baseline
.iter()
.filter(|projection| &projection.signature == selected_signature)
.count()
!= 1
{
return Err(Error::Other(
"indistinguishable sibling text boxes cannot be paired".into(),
));
}
let path = baseline[selected_slot]
.slots
.iter()
.find(|(_, range)| range == span)
.map(|(path, _)| path.clone())
.ok_or_else(|| Error::Other("selected paired content has no structural slot".into()))?;
let mut edits = Vec::new();
for branch in branches.values() {
if branch.len() != baseline.len() {
return Err(Error::Other(
"paired text-box branches have unequal cardinality".into(),
));
}
let projections = branch
.iter()
.map(|copy| paired_text_box_projection(xml, copy.range.clone()))
.collect::<Result<Vec<_>>>()?;
if projections
.iter()
.zip(&baseline)
.any(|(actual, expected)| actual.signature != expected.signature)
|| (branch.len() > 1
&& projections
.iter()
.filter(|p| &p.signature == selected_signature)
.count()
!= 1)
{
return Err(Error::Other(
"paired alternate text-box topology or accepted text is asymmetric".into(),
));
}
let range = projections[selected_slot]
.slots
.iter()
.find(|(actual_path, _)| actual_path == &path)
.map(|(_, range)| range.clone())
.ok_or_else(|| Error::Other("paired content structural slot is absent".into()))?;
let scope = story_namespace_scope_at(xml, range.start)?;
let mut raw = close_content_fragment_namespaces(&xml[range.clone()], &scope)?;
let actual_count = rdocx_oxml::placeholder::try_replace_in_block(
&mut raw,
paragraph,
placeholder,
replacement,
)?;
if actual_count != count {
return Err(Error::Other(
"paired replacement physical counts are unequal".into(),
));
}
edits.push(PairedTextEdit { range, raw });
}
edits.sort_by_key(|edit| edit.range.start);
if edits
.windows(2)
.any(|pair| pair[0].range.end > pair[1].range.start)
{
return Err(Error::Other("paired replacement sources overlap".into()));
}
Ok(Some(edits))
}
fn opaque_story_event(
stack: &mut [XmlElementFrame],
namespace: StoryNamespace,
local_name: &[u8],
element: &BytesStart<'_>,
empty: bool,
xml: &[u8],
before: usize,
after: usize,
) -> Result<bool> {
Ok(stack.last().is_some_and(|frame| frame.opaque)
|| !modeled_story_event(
stack, namespace, local_name, element, empty, xml, before, after,
)?)
}
fn direct_story_item_kind(is_word: bool, local_name: &[u8]) -> StoryItemKind {
if !is_word {
return StoryItemKind::PreservedNode;
}
match local_name {
b"p" => StoryItemKind::Paragraph,
b"tbl" => StoryItemKind::Table,
b"sdt" => StoryItemKind::ContentControl,
_ => StoryItemKind::PreservedNode,
}
}
fn owner_kind(local_name: &[u8]) -> Option<StoryKind> {
match local_name {
b"body" => Some(StoryKind::Body),
b"tc" => Some(StoryKind::TableCell),
b"hdr" => Some(StoryKind::Header),
b"ftr" => Some(StoryKind::Footer),
b"footnote" => Some(StoryKind::Footnote),
b"endnote" => Some(StoryKind::Endnote),
b"comment" => Some(StoryKind::Comment),
b"txbxContent" => Some(StoryKind::TextBox),
_ => None,
}
}
fn editable_note_owner(reader: &NsReader<&[u8]>, element: &BytesStart<'_>) -> Result<bool> {
let mut id = None;
let mut separator = false;
for attribute in element.attributes() {
let attribute = attribute
.map_err(|error| Error::Other(format!("story owner attribute scan failed: {error}")))?;
let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
if !word_element(&namespace) {
continue;
}
match local_name.as_ref() {
b"type" => {
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|error| {
Error::Other(format!("story owner type decode failed: {error}"))
})?;
separator = matches!(
value.as_ref(),
"separator" | "continuationSeparator" | "continuationNotice"
);
}
b"id" => {
id = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|error| {
Error::Other(format!("story owner id decode failed: {error}"))
})?
.parse::<i32>()
.ok();
}
_ => {}
}
}
Ok(!separator && id.unwrap_or(0) > 0)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum ComplexFieldMarker {
Begin,
Separate,
End,
}
struct ComplexFieldScan {
marker_start: usize,
run_start: usize,
ancestors: Vec<usize>,
separators: usize,
instruction: String,
leading_nested: bool,
valid: bool,
}
impl ComplexFieldScan {
fn accepted(&self) -> bool {
self.valid
&& self.separators <= 1
&& !self.leading_nested
&& field_instruction_has_name(&self.instruction)
}
}
fn complex_field_marker(
reader: &NsReader<&[u8]>,
element: &BytesStart<'_>,
) -> Result<Option<ComplexFieldMarker>> {
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| {
Error::Other(format!("field marker attribute scan failed: {error}"))
})?;
let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
if !word_element(&namespace) || local_name.as_ref() != b"fldCharType" {
continue;
}
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|error| Error::Other(format!("field marker type decode failed: {error}")))?;
return Ok(match value.as_ref() {
"begin" => Some(ComplexFieldMarker::Begin),
"separate" => Some(ComplexFieldMarker::Separate),
"end" => Some(ComplexFieldMarker::End),
_ => None,
});
}
Ok(None)
}
fn story_enclosing_run_range(
xml: &[u8],
stack: &[XmlElementFrame],
) -> Result<Option<Range<usize>>> {
let Some(start) = stack.iter().rev().find_map(|frame| {
(!frame.opaque && frame.namespace == StoryNamespace::Word && frame.local_name == b"r")
.then_some(frame.full_start)
}) else {
return Ok(None);
};
Ok(Some(start..story_element_end(xml, start)?))
}
fn complex_field_scan_context(stack: &[XmlElementFrame]) -> bool {
stack.iter().any(|frame| {
!frame.opaque && frame.namespace == StoryNamespace::Word && frame.local_name == b"r"
}) && !stack
.iter()
.any(|frame| frame.namespace == StoryNamespace::Word && frame.local_name == b"fldSimple")
}
fn scan_complex_field_marker(
fields: &mut Vec<ComplexFieldScan>,
items: &mut Vec<StoryItemSpan>,
marker: Option<ComplexFieldMarker>,
before: usize,
after: usize,
run_start: usize,
run_end: usize,
) {
match marker {
Some(ComplexFieldMarker::Begin) => {
let ancestors = fields.iter().map(|field| field.marker_start).collect();
fields.push(ComplexFieldScan {
marker_start: before,
run_start,
ancestors,
separators: 0,
instruction: String::new(),
leading_nested: false,
valid: true,
});
}
Some(ComplexFieldMarker::Separate) => {
if let Some(field) = fields.last_mut() {
field.separators += 1;
if field.separators > 1 {
field.valid = false;
}
}
}
Some(ComplexFieldMarker::End) => {
let Some(field) = fields.pop() else {
return;
};
let accepted = field.accepted();
if let Some(parent) = fields.last_mut() {
if !accepted {
parent.valid = false;
} else if parent.separators == 0 && !field_instruction_has_name(&parent.instruction)
{
parent.leading_nested = true;
}
}
if accepted {
items.push(StoryItemSpan {
kind: StoryItemKind::Field,
full: field.marker_start..after,
scan: field.run_start..run_end,
direct_owner_child: false,
complex_field: true,
complex_ancestors: field.ancestors,
sdt_context: None,
});
}
}
None => {}
}
}
fn push_complex_field_instruction(
fields: &mut [ComplexFieldScan],
stack: &[XmlElementFrame],
text: &str,
) {
let modeled_instruction = stack.last().is_some_and(|frame| {
!frame.opaque && frame.namespace == StoryNamespace::Word && frame.local_name == b"instrText"
});
if modeled_instruction
&& let Some(field) = fields.last_mut()
&& field.separators == 0
{
field.instruction.push_str(text);
}
}
fn update_complex_field_results(results: &mut Vec<bool>, marker: Option<ComplexFieldMarker>) {
match marker {
Some(ComplexFieldMarker::Begin) => results.push(false),
Some(ComplexFieldMarker::Separate) => {
if let Some(result) = results.last_mut() {
*result = true;
}
}
Some(ComplexFieldMarker::End) => {
results.pop();
}
None => {}
}
}
/// Return whether text at this point is visible: no open complex field is
/// still in its instruction part, and a field item is inside its own field.
fn complex_field_text_visible(item: &StoryItemSpan, results: &[bool]) -> bool {
(!item.complex_field || !results.is_empty()) && results.iter().all(|result| *result)
}
/// The fields open where a piece of story text was read, by index into
/// [`StoryFieldTracker`], each with whether it had reached its result.
type StoryFieldContext = Vec<(usize, bool)>;
/// The complex fields an item other than a field item opens, in source order.
///
/// The typed model reads a field as its cached result only when the field
/// closes within the paragraph. An open field, such as the first paragraph of
/// a table of contents, reads as plain runs. Whether a field hides text is
/// only known at its end marker, so text records the [`Self::context`] it was
/// read in, and [`Self::hides`] decides once the item is scanned.
#[derive(Default)]
struct StoryFieldTracker {
/// The fields open now, by index, each with whether it has separated.
open: StoryFieldContext,
closed: Vec<bool>,
}
impl StoryFieldTracker {
fn mark(&mut self, marker: Option<ComplexFieldMarker>) {
match marker {
Some(ComplexFieldMarker::Begin) => {
self.open.push((self.closed.len(), false));
self.closed.push(false);
}
Some(ComplexFieldMarker::Separate) => {
if let Some((_, separated)) = self.open.last_mut() {
*separated = true;
}
}
Some(ComplexFieldMarker::End) => {
if let Some((field, _)) = self.open.pop() {
self.closed[field] = true;
}
}
None => {}
}
}
/// The open fields, each with whether it has reached its result.
fn context(&self) -> StoryFieldContext {
self.open.clone()
}
/// Whether a closed field was still in its instruction part.
fn hides(&self, context: &[(usize, bool)]) -> bool {
context
.iter()
.any(|(field, separated)| self.closed[*field] && !separated)
}
/// Whether the text lies in the result of a closed field.
fn in_result(&self, context: &[(usize, bool)]) -> bool {
context.iter().any(|(field, _)| self.closed[*field])
}
}
/// Return whether a field character at `position` opens, separates or closes
/// a field that hides text of this item. A field item tracks the markers of
/// its own field. Any other item tracks every accepted marker of its own
/// story, so a field's instruction part stays out of its text as in
/// `Paragraph::text`.
fn story_field_marker_tracked(
item: &StoryItemSpan,
position: usize,
nested_owner_depth: usize,
stack: &[XmlElementFrame],
) -> bool {
if item.complex_field {
item.full.start <= position && position < item.full.end
} else {
nested_owner_depth == 0 && accepted_story_text_visible(stack)
}
}
/// One piece of story text, kept with the fields open where it was read.
enum StoryTextPiece {
Text(String),
/// A tab or break, as read outside and inside a field result.
Character(Option<char>, Option<char>),
}
/// The text a visible run `w:br` or `w:cr` adds, as the typed model reads it.
/// Direct runs and flattened simple fields read breaks as line feeds.
/// Inside a closed complex field's cached result, page and column breaks
/// read as form feeds and vertical tabs. CR remains a line feed in both views.
fn story_break_text(
reader: &NsReader<&[u8]>,
element: &BytesStart<'_>,
in_field_result: bool,
) -> Result<Option<char>> {
if element.local_name().as_ref() == b"cr" {
return Ok(Some('\n'));
}
if !in_field_result {
return Ok(Some('\n'));
}
for attribute in element.attributes() {
let attribute = attribute
.map_err(|error| Error::Other(format!("break attribute scan failed: {error}")))?;
let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
if !word_element(&namespace) || local_name.as_ref() != b"type" {
continue;
}
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|error| Error::Other(format!("break type decode failed: {error}")))?;
return Ok(Some(match value.as_ref() {
"page" => '\u{000c}',
"column" => '\u{000b}',
_ => '\n',
}));
}
Ok(Some('\n'))
}
fn accepted_story_text_visible(stack: &[XmlElementFrame]) -> bool {
!stack.iter().any(|frame| {
!frame.opaque
&& frame.namespace == StoryNamespace::Word
&& matches!(frame.local_name.as_slice(), b"del" | b"moveFrom")
})
}
fn scan_story_owners(xml: &[u8], root_kind: StoryKind) -> Result<Vec<StoryOwnerSpan>> {
scan_story_owners_with_special(xml, root_kind, false)
}
fn scan_story_owners_with_special(
xml: &[u8],
root_kind: StoryKind,
include_special: bool,
) -> Result<Vec<StoryOwnerSpan>> {
#[cfg(test)]
STORY_OWNER_SCANS.set(STORY_OWNER_SCANS.get() + 1);
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut stack: Vec<XmlElementFrame> = Vec::new();
let mut owners = Vec::new();
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story XML scan failed: {error}")))?;
let namespace_kind = story_namespace(&namespace);
let is_word_namespace = namespace_kind == StoryNamespace::Word;
drop(namespace);
let after = reader.buffer_position() as usize;
match event {
Event::Start(element) => {
let is_word = is_word_namespace;
let local_name = element.local_name().as_ref().to_vec();
let sdt_context = story_element_sdt_context(&stack, namespace_kind, &local_name);
let direct_owner_child =
stack.last().is_some_and(|frame| frame.owner_kind.is_some());
let mut opaque = opaque_story_event(
&mut stack,
namespace_kind,
&local_name,
&element,
false,
xml,
before,
after,
)? || (direct_owner_child
&& direct_story_item_kind(is_word, &local_name)
== StoryItemKind::PreservedNode);
let mut candidate = (!opaque && is_word)
.then(|| owner_kind(&local_name))
.flatten();
if matches!(candidate, Some(StoryKind::Footnote | StoryKind::Endnote))
&& !include_special
&& !editable_note_owner(&reader, &element)?
{
candidate = None;
opaque = true;
}
let inherited = stack
.last()
.map_or(&[][..], |frame| frame.word_prefixes.as_slice());
let word_prefixes = story_word_prefixes_at(&element, inherited, is_word)?;
stack.push(XmlElementFrame {
namespace: namespace_kind,
local_name,
is_word,
full_start: before,
owner_kind: candidate.filter(|kind| {
*kind == root_kind
|| matches!(kind, StoryKind::TableCell | StoryKind::TextBox)
}),
item_kind: None,
direct_owner_child: false,
sdt_context,
word_prefixes,
opaque,
typed_sdt_content_seen: false,
});
}
Event::Empty(element) => {
let direct_owner_child =
stack.last().is_some_and(|frame| frame.owner_kind.is_some());
let local_name = element.local_name();
let opaque = opaque_story_event(
&mut stack,
namespace_kind,
local_name.as_ref(),
&element,
true,
xml,
before,
after,
)? || (direct_owner_child
&& direct_story_item_kind(is_word_namespace, local_name.as_ref())
== StoryItemKind::PreservedNode);
if is_word_namespace
&& !opaque
&& let Some(kind) = owner_kind(local_name.as_ref()).filter(|kind| {
*kind == root_kind
|| matches!(kind, StoryKind::TableCell | StoryKind::TextBox)
})
&& (!matches!(kind, StoryKind::Footnote | StoryKind::Endnote)
|| include_special
|| editable_note_owner(&reader, &element)?)
{
owners.push(StoryOwnerSpan {
kind,
owner_index: 0,
full: before..after,
fingerprint: structural_fingerprint(&xml[before..after])?,
});
}
}
Event::End(_) => {
let Some(frame) = stack.pop() else {
return Err(Error::Other(
"story XML contains an unmatched closing element".to_owned(),
));
};
if let Some(kind) = frame.owner_kind {
owners.push(StoryOwnerSpan {
kind,
owner_index: 0,
full: frame.full_start..after,
fingerprint: structural_fingerprint(&xml[frame.full_start..after])?,
});
}
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
owners.sort_by_key(|owner| owner.full.start);
let mut counts = HashMap::new();
for owner in &mut owners {
let count = counts.entry(owner.kind).or_insert(0usize);
owner.owner_index = *count;
*count += 1;
}
Ok(owners)
}
/// Pair the non-cell story owners of `scanned`, the bytes revision resolution
/// scans, with the identities [`Document::stories`] reports from `reported`,
/// its own bytes for the same part.
///
/// Owners pair in order when both byte streams have the same owner kinds.
/// Otherwise the two disagree, for example because the typed serialization
/// dropped the namespace binding of a text box, so root owners pair by index
/// and text boxes by identical bytes. An owner without a partner is left
/// out, which folds its revisions into the owner around it.
fn paired_story_owners(
part_name: &str,
root_kind: StoryKind,
scanned: &[u8],
reported: Option<&[u8]>,
) -> Result<Vec<(Range<usize>, StoryId)>> {
let non_cell_owners = |xml: &[u8]| -> Result<Vec<StoryOwnerSpan>> {
Ok(scan_story_owners(xml, root_kind)?
.into_iter()
.filter(|owner| owner.kind != StoryKind::TableCell)
.collect())
};
let scanned_owners = non_cell_owners(scanned)?;
let reported_owners = match reported {
Some(xml) if xml == scanned => scanned_owners.clone(),
Some(xml) => non_cell_owners(xml)?,
None => Vec::new(),
};
let story = |owner: &StoryOwnerSpan| StoryId {
kind: owner.kind,
part_name: part_name.to_owned(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
};
let same_kinds = scanned_owners.len() == reported_owners.len()
&& scanned_owners
.iter()
.zip(&reported_owners)
.all(|(scanned, reported)| scanned.kind == reported.kind);
if same_kinds {
return Ok(scanned_owners
.iter()
.zip(&reported_owners)
.map(|(scanned, reported)| (scanned.full.clone(), story(reported)))
.collect());
}
let mut unpaired = reported_owners.iter().collect::<Vec<_>>();
Ok(scanned_owners
.iter()
.filter_map(|scanned| {
let position = unpaired.iter().position(|reported| {
reported.kind == scanned.kind
&& if scanned.kind == root_kind {
reported.owner_index == scanned.owner_index
} else {
reported.fingerprint == scanned.fingerprint
}
})?;
Some((scanned.full.clone(), story(unpaired.remove(position))))
})
.collect())
}
// Anchor capture uses trim_text(true). Apply precisely that parser projection
// to the original span, while retaining element names, attributes and order.
fn drawing_capture_key(xml: &[u8]) -> Result<Vec<u8>> {
let mut reader = quick_xml::Reader::from_reader(xml);
reader.config_mut().trim_text(true);
let mut writer = quick_xml::Writer::new(Vec::new());
let mut buffer = Vec::new();
loop {
let event = reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("drawing capture scan failed: {error}")))?;
if matches!(event, Event::Eof) {
break;
}
writer
.write_event(event)
.map_err(|error| Error::Other(error.to_string()))?;
buffer.clear();
}
Ok(writer.into_inner())
}
/// Bind modeled selected anchors to actual physical story owners before shaping.
fn bind_text_box_source_owners(
body: &mut rdocx_oxml::document::CT_Body,
xml: &[u8],
root_kind: StoryKind,
scope: Range<usize>,
) -> Result<()> {
let owners = scan_story_owners(xml, root_kind)?;
let mut anchors = Vec::new();
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("text-box owner scan failed: {error}")))?;
if let Event::Start(element) = event {
if story_namespace(&namespace) == StoryNamespace::WordDrawing
&& element.local_name().as_ref() == b"anchor"
&& scope.contains(&before)
{
let end = story_element_end(xml, before)?;
let contained = owners
.iter()
.filter(|owner| {
owner.kind == StoryKind::TextBox
&& before <= owner.full.start
&& owner.full.end <= end
})
.collect::<Vec<_>>();
// Nested anchors own their own boxes. This anchor's direct box
// is the first selected owner in its captured source span.
anchors.push((
drawing_capture_key(&xml[before..end])?,
contained.first().map(|owner| owner.owner_index),
false,
));
}
} else if matches!(event, Event::Eof) {
break;
}
buffer.clear();
}
prepare_physical_story_projection(body, &mut anchors)
}
pub(crate) fn prepare_physical_story_projection(
body: &mut rdocx_oxml::document::CT_Body,
anchors: &mut [(Vec<u8>, Option<usize>, bool)],
) -> Result<()> {
fn consume_raw(raw: &[u8], anchors: &mut [(Vec<u8>, Option<usize>, bool)]) -> Result<()> {
let captured = drawing_capture_key(raw)?;
let mut reader = quick_xml::Reader::from_reader(captured.as_slice());
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let event = reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(error.to_string()))?;
if let Event::Start(element) = &event
&& element.local_name().as_ref() == b"anchor"
{
let end = story_element_end(&captured, before)?;
let key = &captured[before..end];
if let Some((_, _, used)) = anchors
.iter_mut()
.find(|(source, _, used)| !*used && source == key)
{
*used = true;
}
}
if matches!(event, Event::Eof) {
break;
}
buffer.clear();
}
Ok(())
}
fn drawing(
value: &mut rdocx_oxml::drawing::CT_Drawing,
anchors: &mut [(Vec<u8>, Option<usize>, bool)],
) -> Result<()> {
let Some(anchor) = value.anchor.as_mut() else {
return Ok(());
};
let Some(shape) = anchor.shape.as_mut() else {
return Ok(());
};
shape.source_text_box_owner = if let Some(raw) = &anchor.raw_xml {
let key = drawing_capture_key(raw)?;
anchors
.iter_mut()
.find(|(source, _, used)| !*used && *source == key)
.and_then(|(_, owner, used)| {
*used = true;
*owner
})
} else {
None
};
shape_content(shape, anchors)
}
fn shape_content(
shape: &mut rdocx_oxml::drawing::CT_Shape,
anchors: &mut [(Vec<u8>, Option<usize>, bool)],
) -> Result<()> {
if let Some(body) = &mut shape.text_body {
body_content(&mut body.content, anchors)?;
} else {
for p in &mut shape.text {
paragraph(p, anchors)?;
}
}
Ok(())
}
fn selected_raw(
raw: &[u8],
selected: &mut rdocx_oxml::drawing::CT_Drawing,
anchors: &mut [(Vec<u8>, Option<usize>, bool)],
) -> Result<()> {
let selected_key = selected
.anchor
.as_ref()
.and_then(|anchor| anchor.raw_xml.as_deref())
.map(drawing_capture_key)
.transpose()?;
let captured = drawing_capture_key(raw)?;
let mut reader = quick_xml::Reader::from_reader(captured.as_slice());
let mut buffer = Vec::new();
let mut bound = false;
loop {
let before = reader.buffer_position() as usize;
let event = reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(error.to_string()))?;
if let Event::Start(element) = &event
&& element.local_name().as_ref() == b"anchor"
{
let end = story_element_end(&captured, before)?;
let key = &captured[before..end];
let owner = anchors
.iter_mut()
.find(|(source, _, used)| !*used && source == key)
.and_then(|(_, owner, used)| {
*used = true;
*owner
});
if !bound && owner.is_some() && selected_key.as_deref() == Some(key) {
if let Some(shape) = selected
.anchor
.as_mut()
.and_then(|anchor| anchor.shape.as_mut())
{
shape.source_text_box_owner = owner;
shape_content(shape, anchors)?;
}
bound = true;
let name = element.name().as_ref().to_vec();
reader
.read_to_end_into(quick_xml::name::QName(&name), &mut Vec::new())
.map_err(|error| Error::Other(error.to_string()))?;
}
}
if matches!(event, Event::Eof) {
break;
}
buffer.clear();
}
if !bound
&& let Some(shape) = selected
.anchor
.as_mut()
.and_then(|anchor| anchor.shape.as_mut())
{
shape.source_text_box_owner = None;
}
Ok(())
}
fn run(value: &mut CT_R, anchors: &mut [(Vec<u8>, Option<usize>, bool)]) -> Result<()> {
let properties = usize::from(value.properties.is_some());
let mut selected_index = 0;
let mut children = value
.extra_xml
.iter()
.enumerate()
.filter_map(|(index, _)| {
let encoded = value.extra_xml_positions.get(index).copied();
if encoded.is_some_and(CT_R::raw_child_is_root_attributes) {
return None;
}
let selected = if encoded.is_some_and(CT_R::raw_child_has_alternate_content_drawing)
{
let index = selected_index;
selected_index += 1;
Some(index)
} else {
None
};
Some((
encoded.map_or(properties + value.content.len(), CT_R::raw_child_position),
0u8,
index,
selected,
))
})
.chain(
value
.content
.iter()
.enumerate()
.filter_map(|(index, content)| {
matches!(content, RunContent::Drawing(_)).then_some((
properties + index,
1u8,
index,
None,
))
}),
)
.collect::<Vec<_>>();
children.sort_unstable();
for (_, kind, index, selected) in children {
if kind == 1 {
if let RunContent::Drawing(value) = &mut value.content[index] {
drawing(value, anchors)?;
}
} else if let Some(selected) =
selected.and_then(|index| value.alt_drawings.get_mut(index))
{
selected_raw(&value.extra_xml[index], selected, anchors)?;
} else {
consume_raw(&value.extra_xml[index], anchors)?;
}
}
Ok(())
}
fn paragraph(value: &mut CT_P, anchors: &mut [(Vec<u8>, Option<usize>, bool)]) -> Result<()> {
// Consume every physical revision owner before selecting accepted runs.
// Projection metadata is installed in place, never serialized/reparsed.
for boundary in 0..=value.runs.len() {
let mut owners = value
.content_controls
.iter()
.enumerate()
.filter(|(_, (at, _, _, _))| *at == boundary)
.map(|(index, (_, slot, _, _))| (1u8, *slot, 0u8, index))
.chain(
value
.revisions
.iter()
.enumerate()
.filter(|(_, (at, _, _))| *at == boundary)
.map(|(index, (_, slot, _))| {
let (order, slot) = if let Some(link_index) =
rdocx_oxml::text::hyperlink_revision_index(*slot)
{
match value.hyperlinks.get(link_index) {
Some(link) if link.preserved_raw_before.is_some() => {
(1, link.preserved_raw_before.unwrap())
}
Some(link) if boundary == link.run_end => (0, 0),
_ => (2, 0),
}
} else {
(1, *slot)
};
(order, slot, 1u8, index)
}),
)
.collect::<Vec<_>>();
for (raw_slot, (index, (at, _))) in value
.extra_xml
.iter()
.enumerate()
.filter(|(_, (at, _))| *at == boundary)
.enumerate()
{
let projected = value
.revisions
.iter()
.any(|(position, slot, _)| position == at && *slot == raw_slot)
|| value.hyperlinks.iter().any(|link| {
link.run_start == boundary && link.preserved_raw_before == Some(raw_slot)
});
if !projected {
owners.push((1, raw_slot, 2, index));
}
}
owners.sort_unstable();
for (_, _, kind, index) in owners {
if kind == 1 {
if let Some(p) = value.revisions[index].2.physical_content_paragraph_mut()? {
paragraph(p, anchors)?;
}
} else if kind == 0 {
control(&mut value.content_controls[index].3, anchors)?;
} else {
consume_raw(&value.extra_xml[index].1, anchors)?;
}
}
if let Some(value) = value.runs.get_mut(boundary) {
run(value, anchors)?;
}
}
Ok(())
}
fn control(value: &mut CT_Sdt, anchors: &mut [(Vec<u8>, Option<usize>, bool)]) -> Result<()> {
for boundary in 0..=value.content.len() {
for (_, revision) in value
.physical_revisions_mut()
.iter_mut()
.filter(|(at, _)| *at == boundary)
{
if let Some(p) = revision.physical_content_paragraph_mut()? {
paragraph(p, anchors)?;
}
}
if let Some(content) = value.content.get_mut(boundary) {
match content {
SdtContent::Paragraph(p) => paragraph(p, anchors)?,
SdtContent::Run(value) => run(value, anchors)?,
SdtContent::Table(value) => table(value, anchors)?,
SdtContent::Row(value) => row(value, anchors)?,
SdtContent::Cell(value) => cell(value, anchors)?,
SdtContent::ContentControl(value) => control(value, anchors)?,
SdtContent::RawXml(raw) => consume_raw(raw, anchors)?,
}
}
}
Ok(())
}
fn table(value: &mut CT_Tbl, anchors: &mut [(Vec<u8>, Option<usize>, bool)]) -> Result<()> {
for boundary in 0..=value.rows.len() {
for (_, _, value) in value
.content_controls
.iter_mut()
.filter(|(at, _, _)| *at == boundary)
{
control(value, anchors)?;
}
if let Some(value) = value.rows.get_mut(boundary) {
row(value, anchors)?;
}
}
Ok(())
}
fn row(value: &mut CT_Row, anchors: &mut [(Vec<u8>, Option<usize>, bool)]) -> Result<()> {
for boundary in 0..=value.cells.len() {
for (_, _, value) in value
.content_controls
.iter_mut()
.filter(|(at, _, _)| *at == boundary)
{
control(value, anchors)?;
}
if let Some(value) = value.cells.get_mut(boundary) {
cell(value, anchors)?;
}
}
Ok(())
}
fn cell(value: &mut CT_Tc, anchors: &mut [(Vec<u8>, Option<usize>, bool)]) -> Result<()> {
for content in &mut value.content {
match content {
CellContent::Paragraph(value) => paragraph(value, anchors)?,
CellContent::Table(value) => table(value, anchors)?,
CellContent::ContentControl(value) => control(value, anchors)?,
}
}
Ok(())
}
fn body_content(
values: &mut [BodyContent],
anchors: &mut [(Vec<u8>, Option<usize>, bool)],
) -> Result<()> {
for value in values {
match value {
BodyContent::Paragraph(value) => paragraph(value, anchors)?,
BodyContent::Table(value) => table(value, anchors)?,
BodyContent::ContentControl(value) => control(value, anchors)?,
BodyContent::RawXml(raw) => consume_raw(raw, anchors)?,
}
}
Ok(())
}
let mut candidate = body.clone();
body_content(&mut candidate.content, anchors)?;
*body = candidate;
Ok(())
}
fn structural_fingerprint(xml: &[u8]) -> Result<u64> {
const OFFSET: u64 = 0xcbf29ce484222325;
const PRIME: u64 = 0x100000001b3;
let mut hash = OFFSET;
for byte in xml {
hash ^= u64::from(*byte);
hash = hash.wrapping_mul(PRIME);
}
Ok(hash)
}
fn typed_complex_field_candidate_is_admitted(
xml: &[u8],
paragraph: &Range<usize>,
candidate: &StoryItemSpan,
inherited_word_prefixes: &[String],
) -> Result<bool> {
let paragraph_xml = &xml[paragraph.clone()];
let mut reader = quick_xml::Reader::from_reader(paragraph_xml);
reader.config_mut().trim_text(false);
let mut buffer = Vec::new();
let paragraph_local_name = loop {
match reader.read_event_into(&mut buffer).map_err(|error| {
Error::Other(format!("typed story field paragraph scan failed: {error}"))
})? {
Event::Start(element) => {
break element.local_name().as_ref().to_vec();
}
Event::Eof => {
return Err(Error::Other(
"typed story field paragraph has no root element".to_owned(),
));
}
_ => {}
}
buffer.clear();
};
let scanned = &xml[candidate.scan.clone()];
let marker_start = candidate
.full
.start
.checked_sub(candidate.scan.start)
.filter(|start| *start <= scanned.len())
.ok_or_else(|| {
Error::Other("complex story field starts outside its run span".to_owned())
})?;
let marker_end = candidate
.full
.end
.checked_sub(candidate.scan.start)
.filter(|end| marker_start <= *end && *end <= scanned.len())
.ok_or_else(|| Error::Other("complex story field ends outside its run span".to_owned()))?;
let mut run_reader = quick_xml::Reader::from_reader(scanned);
run_reader.config_mut().trim_text(false);
let mut run_buffer = Vec::new();
let mut run_opening_end = None;
let mut run_closing_start = None;
loop {
let before = run_reader.buffer_position() as usize;
let event = run_reader
.read_event_into(&mut run_buffer)
.map_err(|error| Error::Other(format!("typed story field run scan failed: {error}")))?;
let after = run_reader.buffer_position() as usize;
if run_opening_end.is_none() && matches!(event, Event::Start(_)) {
run_opening_end = Some(after);
}
if after == scanned.len() && matches!(event, Event::End(_)) {
run_closing_start = Some(before);
break;
}
if matches!(event, Event::Eof) {
break;
}
run_buffer.clear();
}
let Some(run_opening_end) = run_opening_end.filter(|end| *end <= marker_start) else {
return Ok(false);
};
let Some(run_closing_start) = run_closing_start.filter(|start| marker_end <= *start) else {
return Ok(false);
};
let mut isolated = Vec::new();
isolated.extend_from_slice(&scanned[..run_opening_end]);
isolated.extend_from_slice(&scanned[marker_start..marker_end]);
isolated.extend_from_slice(&scanned[run_closing_start..]);
let mut scope = story_namespace_scope_at(xml, candidate.scan.start)?;
let word_prefix = story_word_prefix_for_scope(&scope);
scope
.entry(word_prefix.clone())
.or_insert_with(|| WORD_NAMESPACE.to_owned());
let mut paragraph_name = Vec::new();
if !word_prefix.is_empty() {
paragraph_name.extend_from_slice(word_prefix.as_bytes());
paragraph_name.push(b':');
}
paragraph_name.extend_from_slice(¶graph_local_name);
let mut fragment = Vec::new();
fragment.push(b'<');
fragment.extend_from_slice(¶graph_name);
for (prefix, namespace) in scope {
fragment.extend_from_slice(b" xmlns");
if !prefix.is_empty() {
fragment.push(b':');
fragment.extend_from_slice(prefix.as_bytes());
}
fragment.extend_from_slice(b"=\"");
fragment.extend_from_slice(quick_xml::escape::escape(&namespace).as_bytes());
fragment.push(b'"');
}
fragment.push(b'>');
fragment.extend_from_slice(&isolated);
fragment.extend_from_slice(b"</");
fragment.extend_from_slice(¶graph_name);
fragment.push(b'>');
let Ok(sources) = CT_P::story_complex_field_sources(&fragment, inherited_word_prefixes) else {
return Ok(false);
};
Ok(sources.iter().any(|source| source == &isolated))
}
fn scan_story_control_paragraphs(xml: &[u8], control: &StoryItemSpan) -> Result<Vec<Range<usize>>> {
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut stack = Vec::<XmlElementFrame>::new();
let mut paragraphs = Vec::new();
let mut active = false;
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("control paragraph scan failed: {error}")))?;
let namespace = story_namespace(&namespace);
let after = reader.buffer_position() as usize;
if !active {
if before != control.full.start {
if matches!(event, Event::Eof) {
break;
}
buffer.clear();
continue;
}
active = true;
}
match event {
Event::Start(ref element) | Event::Empty(ref element) => {
let local = element.local_name().as_ref().to_vec();
let empty = matches!(event, Event::Empty(_));
let is_word = namespace == StoryNamespace::Word;
let root = stack.is_empty();
if root && (!is_word || local != b"sdt") {
return Err(Error::Other(
"control paragraph source has no qualified selected control".into(),
));
}
// Only this control's direct paragraphs belong to this inventory.
// Descendants of another control cannot contribute, typed or raw.
let opaque = !root
&& ((is_word && local == b"sdt")
|| opaque_story_event(
&mut stack, namespace, &local, element, empty, xml, before, after,
)?);
let direct_content = stack.last().is_some_and(|parent| {
!parent.opaque
&& parent.namespace == StoryNamespace::Word
&& parent.local_name == b"sdtContent"
});
if !opaque && is_word && local == b"p" && direct_content {
paragraphs.push(
before..if empty {
after
} else {
story_element_end(xml, before)?
},
);
}
if !empty {
let inherited = if root {
story_namespace_scope_at(xml, before)?
.into_iter()
.filter_map(|(prefix, uri)| (uri == WORD_NAMESPACE).then_some(prefix))
.collect::<Vec<_>>()
} else {
stack
.last()
.map_or_else(Vec::new, |parent| parent.word_prefixes.clone())
};
let word_prefixes = story_word_prefixes_at(element, &inherited, is_word)?;
let sdt_context = if root {
control.sdt_context
} else {
story_element_sdt_context(&stack, namespace, &local)
};
stack.push(XmlElementFrame {
namespace,
local_name: local,
is_word,
full_start: before,
owner_kind: None,
item_kind: None,
direct_owner_child: false,
sdt_context,
word_prefixes,
opaque,
typed_sdt_content_seen: false,
});
}
}
Event::End(_) => {
stack.pop();
}
Event::Eof => break,
_ => {}
}
if after >= control.full.end {
break;
}
buffer.clear();
}
Ok(paragraphs)
}
fn scan_story_control_paragraphs_with_scope(
xml: &[u8],
control: &StoryItemSpan,
scope: &BTreeMap<String, String>,
) -> Result<Vec<Range<usize>>> {
let (closed, added) = scoped_story_fragment(xml, control.full.clone(), scope)?;
let mut local_control = control.clone();
local_control.full = 0..closed.len();
let map = |position: usize| -> Result<usize> {
let mapped = if position == 0 {
Some(control.full.start)
} else {
position
.checked_sub(added)
.and_then(|offset| control.full.start.checked_add(offset))
};
mapped
.filter(|offset| *offset <= control.full.end)
.ok_or_else(|| {
Error::Other("control paragraph excerpt has an invalid source offset".to_owned())
})
};
scan_story_control_paragraphs(&closed, &local_control)?
.into_iter()
.map(|paragraph| Ok(map(paragraph.start)?..map(paragraph.end)?))
.collect()
}
fn scan_story_items(xml: &[u8], owner: &StoryOwnerSpan) -> Result<Vec<StoryItemSpan>> {
#[cfg(test)]
FX237_ITEM_SCANS.set(FX237_ITEM_SCANS.get() + 1);
#[cfg(test)]
{
let mut counts = FX211_PROFILE.get();
counts[0] += 1;
counts[1] += xml.len();
FX211_PROFILE.set(counts);
}
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut stack: Vec<XmlElementFrame> = Vec::new();
let mut items = Vec::new();
let mut complex_fields = Vec::<ComplexFieldScan>::new();
let mut paragraphs = Vec::<(Range<usize>, Vec<String>)>::new();
let mut active = false;
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story item scan failed: {error}")))?;
let namespace_kind = story_namespace(&namespace);
let is_word_namespace = namespace_kind == StoryNamespace::Word;
drop(namespace);
let after = reader.buffer_position() as usize;
if !active {
if before != owner.full.start {
if matches!(event, Event::Eof) {
break;
}
buffer.clear();
continue;
}
active = true;
}
match event {
Event::Start(element) => {
let is_word = is_word_namespace;
let local_name = element.local_name().as_ref().to_vec();
let sdt_context = if stack.is_empty() && is_word && local_name == b"sdtContent" {
Some(StorySdtContext::Block)
} else {
story_element_sdt_context(&stack, namespace_kind, &local_name)
};
let depth = stack.len();
let in_nested_owner = stack.iter().skip(1).any(|frame| {
frame.is_word && matches!(frame.local_name.as_slice(), b"tc" | b"txbxContent")
});
#[cfg(test)]
FX211_PROFILE_CONTEXT.set(if in_nested_owner { 1 } else { 2 });
let child_opaque = in_nested_owner
|| opaque_story_event(
&mut stack,
namespace_kind,
&local_name,
&element,
false,
xml,
before,
after,
)?;
#[cfg(test)]
FX211_PROFILE_CONTEXT.set(0);
let item_kind = if depth == 1 && !in_nested_owner {
Some(if child_opaque {
StoryItemKind::PreservedNode
} else {
direct_story_item_kind(is_word, &local_name)
})
} else if !in_nested_owner && !child_opaque && is_word {
match local_name.as_slice() {
b"sdt" => Some(StoryItemKind::ContentControl),
b"fldSimple" => Some(StoryItemKind::Field),
// A drawing in `mc:Choice` is read only for the text
// boxes it holds, and lists no item of its own.
b"drawing"
if stack.last().is_some_and(|parent| {
parent.namespace == StoryNamespace::MarkupCompatibility
}) =>
{
None
}
b"drawing" | b"pict" => Some(StoryItemKind::Drawing),
_ => None,
}
} else {
None
};
let opaque = child_opaque || item_kind == Some(StoryItemKind::PreservedNode);
if !opaque
&& !in_nested_owner
&& is_word
&& local_name.as_slice() == b"fldChar"
&& complex_field_scan_context(&stack)
&& let Some(run) = story_enclosing_run_range(xml, &stack)?
{
scan_complex_field_marker(
&mut complex_fields,
&mut items,
complex_field_marker(&reader, &element)?,
before,
after,
run.start,
run.end,
);
}
let inherited = stack
.last()
.map_or(&[][..], |frame| frame.word_prefixes.as_slice());
let word_prefixes = story_word_prefixes_at(&element, inherited, is_word_namespace)?;
stack.push(XmlElementFrame {
namespace: namespace_kind,
local_name,
is_word,
full_start: before,
owner_kind: None,
item_kind,
direct_owner_child: depth == 1 && !in_nested_owner,
sdt_context,
word_prefixes,
opaque,
typed_sdt_content_seen: false,
});
}
Event::Empty(element) => {
let is_word = is_word_namespace;
let local_name = element.local_name();
let sdt_context =
story_element_sdt_context(&stack, namespace_kind, local_name.as_ref());
let depth = stack.len();
let in_nested_owner = stack.iter().skip(1).any(|frame| {
frame.is_word && matches!(frame.local_name.as_slice(), b"tc" | b"txbxContent")
});
#[cfg(test)]
FX211_PROFILE_CONTEXT.set(if in_nested_owner { 1 } else { 2 });
let opaque = in_nested_owner
|| opaque_story_event(
&mut stack,
namespace_kind,
local_name.as_ref(),
&element,
true,
xml,
before,
after,
)?;
#[cfg(test)]
FX211_PROFILE_CONTEXT.set(0);
let kind = if depth == 1 && !in_nested_owner {
Some(if opaque {
StoryItemKind::PreservedNode
} else {
direct_story_item_kind(is_word, local_name.as_ref())
})
} else if !in_nested_owner && !opaque && is_word {
match local_name.as_ref() {
b"sdt" => Some(StoryItemKind::ContentControl),
b"fldSimple" => Some(StoryItemKind::Field),
b"drawing" | b"pict" => Some(StoryItemKind::Drawing),
_ => None,
}
} else {
None
};
if !opaque
&& !in_nested_owner
&& is_word
&& local_name.as_ref() == b"fldChar"
&& complex_field_scan_context(&stack)
&& let Some(run) = story_enclosing_run_range(xml, &stack)?
{
scan_complex_field_marker(
&mut complex_fields,
&mut items,
complex_field_marker(&reader, &element)?,
before,
after,
run.start,
run.end,
);
}
if let Some(kind) = kind {
items.push(StoryItemSpan {
kind,
full: before..after,
scan: before..after,
direct_owner_child: depth == 1 && !in_nested_owner,
complex_field: false,
complex_ancestors: Vec::new(),
sdt_context,
});
}
}
Event::End(_) => {
let Some(frame) = stack.pop() else {
return Err(Error::Other(
"story item XML contains an unmatched closing element".to_owned(),
));
};
if !frame.opaque
&& frame.namespace == StoryNamespace::Word
&& frame.local_name == b"p"
{
paragraphs.push((frame.full_start..after, frame.word_prefixes.clone()));
complex_fields.clear();
}
if let Some(kind) = frame.item_kind {
items.push(StoryItemSpan {
kind,
full: frame.full_start..after,
scan: frame.full_start..after,
direct_owner_child: frame.direct_owner_child,
complex_field: false,
complex_ancestors: Vec::new(),
sdt_context: frame.sdt_context,
});
}
if after == owner.full.end {
break;
}
}
Event::Text(value) => {
let decoded = value.decode().map_err(|error| {
Error::Other(format!("field instruction decode failed: {error}"))
})?;
let value = quick_xml::escape::unescape(&decoded).map_err(|error| {
Error::Other(format!("field instruction unescape failed: {error}"))
})?;
push_complex_field_instruction(&mut complex_fields, &stack, &value);
}
Event::CData(value) => {
let value = value.decode().map_err(|error| {
Error::Other(format!("field instruction CDATA decode failed: {error}"))
})?;
push_complex_field_instruction(&mut complex_fields, &stack, &value);
}
Event::GeneralRef(value) => push_complex_field_instruction(
&mut complex_fields,
&stack,
&oxml_core::xml_text::resolve_entity(&value),
),
Event::Eof => break,
_ => {}
}
buffer.clear();
}
let mut typed_complex_admission = HashMap::<usize, bool>::new();
for (paragraph, word_prefixes) in ¶graphs {
for candidate in items.iter().filter(|item| {
item.complex_field
&& paragraph.start <= item.scan.start
&& item.scan.end <= paragraph.end
}) {
let admitted = typed_complex_field_candidate_is_admitted(
xml,
paragraph,
candidate,
word_prefixes,
)?;
typed_complex_admission.insert(candidate.full.start, admitted);
}
}
items.retain(|item| {
!item.complex_field
|| (typed_complex_admission
.get(&item.full.start)
.copied()
.unwrap_or(false)
&& item.complex_ancestors.iter().all(|ancestor| {
typed_complex_admission
.get(ancestor)
.copied()
.unwrap_or(false)
}))
});
items.sort_by_key(|item| (item.full.start, item.full.end));
items.dedup_by(|left, right| left.kind == right.kind && left.full == right.full);
Ok(items)
}
fn scan_story_item_links(xml: &[u8], item: &StoryItemSpan) -> Result<Vec<StoryLinkSpan>> {
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut stack: Vec<XmlElementFrame> = Vec::new();
let mut open_links = Vec::new();
let mut links = Vec::new();
let mut nested_owner_depth = 0usize;
let mut active = false;
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story hyperlink scan failed: {error}")))?;
let namespace_kind = story_namespace(&namespace);
let is_word_namespace = namespace_kind == StoryNamespace::Word;
drop(namespace);
let after = reader.buffer_position() as usize;
if !active {
if before != item.scan.start {
if matches!(event, Event::Eof) {
break;
}
buffer.clear();
continue;
}
active = true;
}
match event {
Event::Start(element) => {
let depth = stack.len();
let local_name = element.local_name().as_ref().to_vec();
let opaque = if stack.is_empty() && item.kind == StoryItemKind::ContentControl {
false
} else {
opaque_story_event(
&mut stack,
namespace_kind,
&local_name,
&element,
false,
xml,
before,
after,
)?
};
if !opaque
&& depth > 0
&& is_word_namespace
&& matches!(local_name.as_slice(), b"tc" | b"txbxContent" | b"sdt")
{
nested_owner_depth += 1;
}
if !opaque
&& nested_owner_depth == 0
&& is_word_namespace
&& local_name == b"hyperlink"
{
let (rel_id, anchor) = story_hyperlink_attributes(&reader, &element)?;
open_links.push((before, rel_id, anchor));
}
let inherited = stack
.last()
.map_or(&[][..], |frame| frame.word_prefixes.as_slice());
let word_prefixes = story_word_prefixes_at(&element, inherited, is_word_namespace)?;
stack.push(XmlElementFrame {
namespace: namespace_kind,
local_name,
is_word: is_word_namespace,
full_start: before,
owner_kind: None,
item_kind: None,
direct_owner_child: false,
sdt_context: if depth == 0 && item.kind == StoryItemKind::ContentControl {
item.sdt_context
} else {
story_element_sdt_context(
&stack,
namespace_kind,
element.local_name().as_ref(),
)
},
word_prefixes,
opaque,
typed_sdt_content_seen: false,
});
}
Event::Empty(element) => {
let local_name = element.local_name();
let opaque = opaque_story_event(
&mut stack,
namespace_kind,
local_name.as_ref(),
&element,
true,
xml,
before,
after,
)?;
if !opaque
&& nested_owner_depth == 0
&& is_word_namespace
&& local_name.as_ref() == b"hyperlink"
{
let (rel_id, anchor) = story_hyperlink_attributes(&reader, &element)?;
links.push(StoryLinkSpan {
full: before..after,
rel_id,
anchor,
});
}
}
Event::End(_) => {
let Some(frame) = stack.pop() else {
return Err(Error::Other(
"story hyperlink XML contains an unmatched closing element".to_owned(),
));
};
if !frame.opaque
&& nested_owner_depth == 0
&& frame.namespace == StoryNamespace::Word
&& frame.local_name == b"hyperlink"
{
let (start, rel_id, anchor) = open_links.pop().ok_or_else(|| {
Error::Other("story hyperlink has no matching start".to_owned())
})?;
links.push(StoryLinkSpan {
full: start..after,
rel_id,
anchor,
});
}
if !frame.opaque
&& !stack.is_empty()
&& frame.namespace == StoryNamespace::Word
&& matches!(frame.local_name.as_slice(), b"tc" | b"txbxContent" | b"sdt")
{
nested_owner_depth = nested_owner_depth.saturating_sub(1);
}
}
Event::Eof => break,
_ => {}
}
if after == item.scan.end {
break;
}
buffer.clear();
}
if !open_links.is_empty() {
return Err(Error::Other(
"story hyperlink has no matching end".to_owned(),
));
}
Ok(links)
}
fn story_hyperlink_attributes(
reader: &NsReader<&[u8]>,
element: &BytesStart<'_>,
) -> Result<(Option<String>, Option<String>)> {
let mut rel_id = None;
let mut anchor = None;
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| {
Error::Other(format!("story hyperlink attribute scan failed: {error}"))
})?;
let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
let target = if matches!(
namespace,
ResolveResult::Bound(Namespace(uri)) if uri == drawing_ns::R.as_bytes()
) && local_name.as_ref() == b"id"
{
&mut rel_id
} else if word_element(&namespace) && local_name.as_ref() == b"anchor" {
&mut anchor
} else {
continue;
};
*target = Some(
attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|error| {
Error::Other(format!("story hyperlink attribute decode failed: {error}"))
})?
.into_owned(),
);
}
Ok((rel_id, anchor))
}
/// Return the start tag range of one hyperlink element and, unless it is
/// empty, its end tag range.
fn story_hyperlink_tags(
xml: &[u8],
link: &Range<usize>,
) -> Result<(Range<usize>, Option<Range<usize>>)> {
let mut reader = quick_xml::Reader::from_reader(&xml[link.clone()]);
reader.config_mut().trim_text(false);
let mut buffer = Vec::new();
let mut depth = 0usize;
let mut start = None;
loop {
let before = link.start + reader.buffer_position() as usize;
let event = reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story hyperlink scan failed: {error}")))?;
let after = link.start + reader.buffer_position() as usize;
match event {
Event::Empty(_) if depth == 0 => return Ok((before..after, None)),
Event::Start(_) => {
if depth == 0 {
start = Some(before..after);
}
depth += 1;
}
Event::End(_) => {
depth = depth.saturating_sub(1);
if depth == 0 {
let start = start.ok_or_else(|| {
Error::Other("story hyperlink has no start tag".to_owned())
})?;
return Ok((start, Some(before..after)));
}
}
Event::Eof => {
return Err(Error::Other(
"story hyperlink has no closing tag".to_owned(),
));
}
_ => {}
}
buffer.clear();
}
}
/// Rebuild a hyperlink start tag so that it names `relationship_id` and has no
/// anchor, keeping every other attribute.
fn external_story_hyperlink_start(
tag: &[u8],
scope: &BTreeMap<String, String>,
relationship_id: &str,
) -> Result<Vec<u8>> {
let mut reader = quick_xml::Reader::from_reader(tag);
let mut buffer = Vec::new();
let (element, empty) = match reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story hyperlink scan failed: {error}")))?
{
Event::Start(element) => (element, false),
Event::Empty(element) => (element, true),
_ => {
return Err(Error::Other(
"story hyperlink start tag is invalid".to_owned(),
));
}
};
let mut rewritten = Vec::with_capacity(tag.len() + relationship_id.len() + 16);
rewritten.push(b'<');
rewritten.extend_from_slice(element.name().as_ref());
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| {
Error::Other(format!("story hyperlink attribute scan failed: {error}"))
})?;
let key = attribute.key.as_ref();
let resolved = key.iter().position(|byte| *byte == b':').and_then(|colon| {
let prefix = std::str::from_utf8(&key[..colon]).ok()?;
Some((scope.get(prefix)?.as_str(), &key[colon + 1..]))
});
if matches!(resolved, Some((namespace, b"id")) if namespace == drawing_ns::R)
|| matches!(resolved, Some((namespace, b"anchor")) if namespace == WORD_NAMESPACE)
{
continue;
}
let quote = if attribute.value.contains(&b'"') {
b'\''
} else {
b'"'
};
rewritten.push(b' ');
rewritten.extend_from_slice(key);
rewritten.push(b'=');
rewritten.push(quote);
rewritten.extend_from_slice(&attribute.value);
rewritten.push(quote);
}
let bound = scope
.iter()
.find(|(prefix, namespace)| !prefix.is_empty() && namespace.as_str() == drawing_ns::R)
.map(|(prefix, _)| prefix.clone());
let prefix = match bound {
Some(prefix) => prefix,
None => {
let mut suffix = 0usize;
let prefix = loop {
let candidate = if suffix == 0 {
"r".to_owned()
} else {
format!("r{suffix}")
};
if !scope.contains_key(&candidate) {
break candidate;
}
suffix += 1;
};
rewritten
.extend_from_slice(format!(" xmlns:{prefix}=\"{}\"", drawing_ns::R).as_bytes());
prefix
}
};
rewritten.extend_from_slice(
format!(
" {prefix}:id=\"{}\"",
quick_xml::escape::escape(relationship_id)
)
.as_bytes(),
);
rewritten.extend_from_slice(if empty { b"/>" } else { b">" });
Ok(rewritten)
}
/// Remove one hyperlink element and keep its content in place.
///
/// Namespace declarations on the hyperlink move to each child element, and
/// `cleared_styles` are removed from the `w:rStyle` of the runs it held.
fn unwrap_story_hyperlink(
xml: &[u8],
link: &Range<usize>,
scope: &BTreeMap<String, String>,
cleared_styles: &HashSet<String>,
) -> Result<Vec<u8>> {
let (start, end) = story_hyperlink_tags(xml, link)?;
let mut edits = vec![(start.clone(), Vec::new())];
if let Some(end) = end {
let mut declarations = Vec::new();
let mut reader = quick_xml::Reader::from_reader(&xml[start.clone()]);
let mut buffer = Vec::new();
if let Event::Start(element) = reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story hyperlink scan failed: {error}")))?
{
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| {
Error::Other(format!("story hyperlink attribute scan failed: {error}"))
})?;
let key = attribute.key.as_ref();
if key == b"xmlns" || key.starts_with(b"xmlns:") {
declarations.push((key.to_vec(), attribute.value.into_owned()));
}
}
}
let (closed, added) = scoped_story_fragment(xml, link.clone(), scope)?;
let map = |position: usize| link.start + position - added;
let mut reader = NsReader::from_reader(closed.as_slice());
reader.config_mut().trim_text(false);
let mut buffer = Vec::new();
let mut stack: Vec<(bool, Vec<u8>, Option<usize>)> = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story hyperlink scan failed: {error}")))?;
let is_word = word_element(&namespace);
drop(namespace);
let after = reader.buffer_position() as usize;
match event {
Event::Start(ref element) | Event::Empty(ref element) => {
if stack.len() == 1 {
let insert_at = map(before + 1 + element.name().as_ref().len());
let mut inserted = Vec::new();
for (key, value) in &declarations {
let redeclared = element
.attributes()
.flatten()
.any(|attribute| attribute.key.as_ref() == key.as_slice());
if !redeclared {
inserted.push(b' ');
inserted.extend_from_slice(key);
inserted.extend_from_slice(b"=\"");
inserted.extend_from_slice(value);
inserted.push(b'"');
}
}
if !inserted.is_empty() {
edits.push((insert_at..insert_at, inserted));
}
}
let local = element.local_name().as_ref().to_vec();
let in_run_properties = is_word
&& local == b"rStyle"
&& stack.len() >= 2
&& matches!(&stack[stack.len() - 2..], [(true, run, _), (true, properties, _)]
if run == b"r" && properties == b"rPr")
&& !stack
.iter()
.any(|(word, name, _)| *word && name == b"txbxContent");
let cleared = in_run_properties
&& element.attributes().flatten().any(|attribute| {
let (namespace, name) =
reader.resolver().resolve_attribute(attribute.key);
word_element(&namespace)
&& name.as_ref() == b"val"
&& attribute
.decoded_and_normalized_value(
XmlVersion::Implicit1_0,
reader.decoder(),
)
.is_ok_and(|value| cleared_styles.contains(value.as_ref()))
});
if matches!(event, Event::Start(_)) {
stack.push((is_word, local, cleared.then(|| map(before))));
} else if cleared {
edits.push((map(before)..map(after), Vec::new()));
}
}
Event::End(_) => {
if let Some((_, _, Some(removed_from))) = stack.pop() {
edits.push((removed_from..map(after), Vec::new()));
}
if stack.is_empty() {
break;
}
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
edits.push((end, Vec::new()));
}
edits.sort_by_key(|(range, _)| range.start);
let mut updated = xml.to_vec();
for (mut range, replacement) in edits.into_iter().rev() {
// A removed tag takes its indentation with it: whitespace between
// WordprocessingML elements outside `w:t` carries no content.
if replacement.is_empty() && !range.is_empty() {
let indented = xml[..range.start]
.iter()
.rposition(|byte| !byte.is_ascii_whitespace());
if let Some(previous) = indented.filter(|previous| xml[*previous] == b'>') {
range.start = previous + 1;
}
}
updated.splice(range, replacement);
}
Ok(updated)
}
/// One admitted bookmark marker and its physical source range.
struct BookmarkMarkerSpan {
start: bool,
id: String,
span: Range<usize>,
}
fn bookmark_marker_spans(xml: &[u8]) -> Result<Vec<BookmarkMarkerSpan>> {
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
let mut stack = Vec::<XmlElementFrame>::new();
let mut markers = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("bookmark scan failed: {error}")))?;
let namespace = story_namespace(&namespace);
let after = reader.buffer_position() as usize;
match event {
Event::Start(ref element) | Event::Empty(ref element) => {
let local = element.local_name().as_ref().to_vec();
let empty = matches!(event, Event::Empty(_));
let modeled = stack.last().is_none_or(|parent| !parent.opaque)
&& modeled_story_event(
&mut stack, namespace, &local, element, empty, xml, before, after,
)?;
if modeled
&& accepted_story_text_visible(&stack)
&& namespace == StoryNamespace::Word
&& matches!(local.as_slice(), b"bookmarkStart" | b"bookmarkEnd")
{
let mut id = None;
for attribute in element.attributes() {
let attribute =
attribute.map_err(|error| Error::Other(error.to_string()))?;
let (namespace, local) = reader.resolver().resolve_attribute(attribute.key);
if word_element(&namespace) && local.as_ref() == b"id" {
let value = attribute
.decoded_and_normalized_value(
XmlVersion::Implicit1_0,
reader.decoder(),
)
.map_err(|error| Error::Other(error.to_string()))?;
if id.replace(value.into_owned()).is_some() {
return Err(Error::Other(
"bookmark marker has duplicate owned id".to_owned(),
));
}
}
}
if let Some(id) = id {
let end = if empty {
after
} else {
story_element_end(xml, before)?
};
if !empty {
let mut child = quick_xml::Reader::from_reader(&xml[after..end]);
if !matches!(
child
.read_event()
.map_err(|error| Error::Other(error.to_string()))?,
Event::End(_)
) {
return Err(Error::Other(
"a bookmark marker with child content cannot be removed"
.to_owned(),
));
}
}
markers.push(BookmarkMarkerSpan {
start: local == b"bookmarkStart",
id,
span: before..end,
});
}
}
if !empty {
let sdt_context = story_element_sdt_context(&stack, namespace, &local);
let inherited = stack
.last()
.map_or(&[][..], |frame| frame.word_prefixes.as_slice());
let word_prefixes = story_word_prefixes_at(
element,
inherited,
namespace == StoryNamespace::Word,
)?;
stack.push(XmlElementFrame {
namespace,
local_name: local,
is_word: namespace == StoryNamespace::Word,
full_start: before,
owner_kind: None,
item_kind: None,
direct_owner_child: false,
sdt_context,
word_prefixes,
opaque: !modeled,
typed_sdt_content_seen: false,
});
}
}
Event::End(_) => {
stack.pop();
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
Ok(markers)
}
/// Resize every `pic:pic` drawing in `xml` whose blip names `relationship_id`,
/// returning the edited XML and the number of drawings resized.
///
/// `wp:extent` and the `a:ext` of `pic:spPr/a:xfrm` take the new size, and
/// `wp:effectExtent` scales with the extent on each axis.
fn resize_story_pictures(
xml: &[u8],
relationship_id: &str,
cx: i64,
cy: i64,
) -> Result<(Vec<u8>, usize)> {
struct Drawing {
depth: usize,
extent: Option<Range<usize>>,
effect_extent: Option<Range<usize>>,
shape_extent: Option<Range<usize>>,
shows_image: bool,
}
const A: &str = drawing_ns::A;
const PIC: &str = drawing_ns::PIC;
let on_path = |path: &[(String, Vec<u8>)], expected: &[(&str, &[u8])]| {
path.len() == expected.len()
&& path.iter().zip(expected).all(
|((namespace, local), (want_namespace, want_local))| {
namespace == want_namespace && local.as_slice() == *want_local
},
)
};
let attribute_value = |tag: Range<usize>, name: &[u8]| -> Result<(Range<usize>, i64)> {
let (start, end) =
story_attribute_value_span(&xml[tag.clone()], name).ok_or_else(|| {
Error::Other(format!(
"picture drawing has no {} attribute",
String::from_utf8_lossy(name)
))
})?;
let range = tag.start + start..tag.start + end;
let value = std::str::from_utf8(&xml[range.clone()])
.ok()
.and_then(|value| value.trim().parse::<i64>().ok())
.ok_or_else(|| {
Error::Other(format!(
"picture drawing {} is not an integer",
String::from_utf8_lossy(name)
))
})?;
Ok((range, value))
};
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut buffer = Vec::new();
let mut stack: Vec<(String, Vec<u8>)> = Vec::new();
let mut drawings: Vec<Drawing> = Vec::new();
let mut edits = Vec::new();
let mut resized = 0usize;
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("picture drawing scan failed: {error}")))?;
let namespace = match namespace {
ResolveResult::Bound(Namespace(uri)) => String::from_utf8_lossy(uri).into_owned(),
_ => String::new(),
};
let after = reader.buffer_position() as usize;
match event {
Event::Start(ref element) | Event::Empty(ref element) => {
let local = element.local_name().as_ref().to_vec();
if let Some(drawing) = drawings.last_mut() {
let path = &stack[drawing.depth + 1..];
if path.is_empty() && namespace == drawing_ns::WP {
match local.as_slice() {
b"extent" => drawing.extent = Some(before..after),
b"effectExtent" => drawing.effect_extent = Some(before..after),
_ => {}
}
} else if namespace == A
&& local == b"blip"
&& on_path(
path,
&[
(A, b"graphic"),
(A, b"graphicData"),
(PIC, b"pic"),
(PIC, b"blipFill"),
],
)
{
drawing.shows_image |= element.attributes().flatten().any(|attribute| {
let (attribute_namespace, name) =
reader.resolver().resolve_attribute(attribute.key);
matches!(attribute_namespace, ResolveResult::Bound(Namespace(uri)) if uri == drawing_ns::R.as_bytes())
&& matches!(name.as_ref(), b"embed" | b"link")
&& attribute.value.as_ref() == relationship_id.as_bytes()
});
} else if namespace == A
&& local == b"ext"
&& on_path(
path,
&[
(A, b"graphic"),
(A, b"graphicData"),
(PIC, b"pic"),
(PIC, b"spPr"),
(A, b"xfrm"),
],
)
{
drawing.shape_extent = Some(before..after);
}
}
if matches!(event, Event::Start(_)) {
if namespace == drawing_ns::WP
&& matches!(local.as_slice(), b"inline" | b"anchor")
{
drawings.push(Drawing {
depth: stack.len(),
extent: None,
effect_extent: None,
shape_extent: None,
shows_image: false,
});
}
stack.push((namespace, local));
}
}
Event::End(_) => {
stack.pop();
if drawings
.last()
.is_some_and(|drawing| drawing.depth == stack.len())
&& let Some(drawing) = drawings.pop()
&& drawing.shows_image
&& let Some(extent) = drawing.extent
{
let (cx_range, old_cx) = attribute_value(extent.clone(), b"cx")?;
let (cy_range, old_cy) = attribute_value(extent, b"cy")?;
edits.push((cx_range, cx));
edits.push((cy_range, cy));
if let Some(shape_extent) = drawing.shape_extent {
edits.push((attribute_value(shape_extent.clone(), b"cx")?.0, cx));
edits.push((attribute_value(shape_extent, b"cy")?.0, cy));
}
if let Some(effect_extent) = drawing.effect_extent {
for (name, new, old) in [
(&b"l"[..], cx, old_cx),
(b"t", cy, old_cy),
(b"r", cx, old_cx),
(b"b", cy, old_cy),
] {
let (range, value) = attribute_value(effect_extent.clone(), name)?;
let scaled = if old == 0 {
0
} else {
i64::try_from(i128::from(value) * i128::from(new) / i128::from(old))
.map_err(|_| {
Error::Other(
"scaled picture effect extent overflows".to_owned(),
)
})?
};
edits.push((range, scaled));
}
}
resized += 1;
}
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
edits.sort_by_key(|(range, _)| range.start);
let mut updated = xml.to_vec();
for (range, value) in edits.into_iter().rev() {
updated.splice(range, value.to_string().into_bytes());
}
Ok((updated, resized))
}
fn story_item_text(
xml: &[u8],
item: &StoryItemSpan,
qualified_items: &[StoryItemSpan],
source_scan: Range<usize>,
added: usize,
) -> Result<Option<String>> {
#[cfg(test)]
{
let mut counts = FX211_PROFILE.get();
counts[6] += 1;
counts[7] += xml.len();
FX211_PROFILE.set(counts);
}
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut in_text = 0usize;
let mut pieces: Vec<(StoryFieldContext, StoryTextPiece)> = Vec::new();
let mut fields = StoryFieldTracker::default();
let mut active = false;
let mut element_stack: Vec<XmlElementFrame> = Vec::new();
let mut nested_owner_depth = 0usize;
let mut complex_results = Vec::new();
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story text scan failed: {error}")))?;
let namespace_kind = story_namespace(&namespace);
let is_word_namespace = namespace_kind == StoryNamespace::Word;
drop(namespace);
let after = reader.buffer_position() as usize;
if !active {
if before != item.scan.start {
#[cfg(test)]
STORY_TEXT_PREFIX_BYTES.set(STORY_TEXT_PREFIX_BYTES.get() + after - before);
if matches!(event, Event::Eof) {
break;
}
buffer.clear();
continue;
}
active = true;
}
match event {
Event::Start(element) => {
let depth = element_stack.len();
let local_name = element.local_name().as_ref().to_vec();
#[cfg(test)]
FX211_PROFILE_CONTEXT.set(if nested_owner_depth > 0 { 4 } else { 3 });
// Reuse only controls independently admitted in this operation's
// owner inventory. A typed parent can still retain untyped raw
// descendants, which must follow ordinary qualification.
let qualified_control = namespace_kind == StoryNamespace::Word
&& local_name == b"sdt"
&& !element_stack.last().is_some_and(|frame| frame.opaque)
&& modeled_story_child(&element_stack, namespace_kind, &local_name)
&& (if !item.complex_field && before == 0 {
Some(source_scan.start)
} else {
before
.checked_sub(added)
.and_then(|offset| source_scan.start.checked_add(offset))
})
.and_then(|start| {
qualified_items
.binary_search_by_key(&start, |item| item.full.start)
.ok()
})
.is_some_and(|index| {
let control = &qualified_items[index];
control.kind == StoryItemKind::ContentControl
&& !control.complex_field
&& control.full == control.scan
&& source_scan.start <= control.full.start
&& control.full.end <= source_scan.end
&& control.sdt_context == story_sdt_context(&element_stack)
});
let opaque =
if element_stack.is_empty() && item.kind == StoryItemKind::ContentControl {
false
} else if nested_owner_depth > 0 {
// The qualified nested owner excludes this content from
// the selected item. Keep inherited opacity and balanced
// owner depth while traversing its source and scopes.
element_stack.last().is_some_and(|frame| frame.opaque)
} else if qualified_control {
false
} else {
opaque_story_event(
&mut element_stack,
namespace_kind,
&local_name,
&element,
false,
xml,
before,
after,
)?
};
#[cfg(test)]
FX211_PROFILE_CONTEXT.set(0);
if !opaque
&& story_field_marker_tracked(item, before, nested_owner_depth, &element_stack)
&& is_word_namespace
&& local_name == b"fldChar"
{
let marker = complex_field_marker(&reader, &element)?;
if item.complex_field {
update_complex_field_results(&mut complex_results, marker);
} else {
fields.mark(marker);
}
}
#[cfg(test)]
FX211_PROFILE_CONTEXT.set(0);
if !opaque
&& depth > 0
&& is_word_namespace
&& matches!(local_name.as_slice(), b"tc" | b"txbxContent")
{
nested_owner_depth += 1;
}
if !opaque
&& complex_field_text_visible(item, &complex_results)
&& accepted_story_text_visible(&element_stack)
&& nested_owner_depth == 0
&& is_word_namespace
{
if local_name == b"t" {
in_text += 1;
pieces.push((fields.context(), StoryTextPiece::Text(String::new())));
} else if let Some(piece) =
story_run_character(&reader, &element, &element_stack, item)?
{
pieces.push((fields.context(), piece));
}
}
let inherited = element_stack
.last()
.map_or(&[][..], |frame| frame.word_prefixes.as_slice());
let word_prefixes = story_word_prefixes_at(&element, inherited, is_word_namespace)?;
element_stack.push(XmlElementFrame {
namespace: namespace_kind,
local_name,
is_word: is_word_namespace,
full_start: before,
owner_kind: None,
item_kind: None,
direct_owner_child: false,
sdt_context: if depth == 0 && item.kind == StoryItemKind::ContentControl {
item.sdt_context
} else {
story_element_sdt_context(
&element_stack,
namespace_kind,
element.local_name().as_ref(),
)
},
word_prefixes,
opaque,
typed_sdt_content_seen: false,
});
}
Event::Empty(element) => {
let local_name = element.local_name();
#[cfg(test)]
FX211_PROFILE_CONTEXT.set(if nested_owner_depth > 0 { 4 } else { 3 });
let opaque = if nested_owner_depth > 0 {
element_stack.last().is_some_and(|frame| frame.opaque)
} else {
opaque_story_event(
&mut element_stack,
namespace_kind,
local_name.as_ref(),
&element,
true,
xml,
before,
after,
)?
};
#[cfg(test)]
FX211_PROFILE_CONTEXT.set(0);
if !opaque
&& story_field_marker_tracked(item, before, nested_owner_depth, &element_stack)
&& is_word_namespace
&& local_name.as_ref() == b"fldChar"
{
let marker = complex_field_marker(&reader, &element)?;
if item.complex_field {
update_complex_field_results(&mut complex_results, marker);
} else {
fields.mark(marker);
}
} else if !opaque
&& complex_field_text_visible(item, &complex_results)
&& accepted_story_text_visible(&element_stack)
&& nested_owner_depth == 0
&& is_word_namespace
{
if local_name.as_ref() == b"t" {
pieces.push((fields.context(), StoryTextPiece::Text(String::new())));
} else if let Some(piece) =
story_run_character(&reader, &element, &element_stack, item)?
{
pieces.push((fields.context(), piece));
}
}
}
Event::End(element) => {
let opaque = element_stack.pop().is_some_and(|frame| frame.opaque);
let depth = element_stack.len();
if !opaque
&& complex_field_text_visible(item, &complex_results)
&& accepted_story_text_visible(&element_stack)
&& nested_owner_depth == 0
&& is_word_namespace
&& element.local_name().as_ref() == b"t"
{
in_text = in_text.saturating_sub(1);
}
if !opaque
&& depth > 0
&& is_word_namespace
&& matches!(element.local_name().as_ref(), b"tc" | b"txbxContent")
{
nested_owner_depth = nested_owner_depth.saturating_sub(1);
}
}
Event::Text(value)
if complex_field_text_visible(item, &complex_results) && in_text > 0 =>
{
push_story_text(
&mut pieces,
&quick_xml::escape::unescape(&value.decode().map_err(|error| {
Error::Other(format!("story text decode failed: {error}"))
})?)
.map_err(|error| {
Error::Other(format!("story text unescape failed: {error}"))
})?,
)
}
Event::CData(value)
if complex_field_text_visible(item, &complex_results) && in_text > 0 =>
{
push_story_text(
&mut pieces,
&value.decode().map_err(|error| {
Error::Other(format!("story CDATA decode failed: {error}"))
})?,
)
}
Event::GeneralRef(value)
if complex_field_text_visible(item, &complex_results) && in_text > 0 =>
{
push_story_text(&mut pieces, &oxml_core::xml_text::resolve_entity(&value));
}
Event::Eof => break,
_ => {}
}
if (item.complex_field && after >= item.full.end)
|| (!item.complex_field && after == item.scan.end)
{
break;
}
buffer.clear();
}
let mut text = String::new();
let mut found = false;
for (context, piece) in pieces {
if fields.hides(&context) {
continue;
}
match piece {
StoryTextPiece::Text(value) => {
text.push_str(&value);
found = true;
}
StoryTextPiece::Character(plain, in_result) => {
let character = if fields.in_result(&context) {
in_result
} else {
plain
};
if let Some(character) = character {
text.push(character);
found = true;
}
}
}
}
Ok((found || item.complex_field).then_some(text))
}
/// Append decoded text to the `w:t` piece it belongs to.
fn push_story_text(pieces: &mut [(StoryFieldContext, StoryTextPiece)], value: &str) {
if let Some((_, StoryTextPiece::Text(text))) = pieces.last_mut() {
text.push_str(value);
}
}
/// Return whether an element opened at this stack is run content, so a run's
/// `w:tab` counts as text and a tab stop under `w:tabs` does not.
fn story_run_child(stack: &[XmlElementFrame]) -> bool {
stack
.last()
.is_some_and(|frame| !frame.opaque && frame.is_word && frame.local_name == b"r")
}
/// The text a visible Word element adds when it is run content other than
/// `w:t`: a tab for `w:tab`, and a break for `w:br` or `w:cr`, as read
/// outside and inside a field result. A field item reads inside its field.
fn story_run_character(
reader: &NsReader<&[u8]>,
element: &BytesStart<'_>,
stack: &[XmlElementFrame],
item: &StoryItemSpan,
) -> Result<Option<StoryTextPiece>> {
if !story_run_child(stack) {
return Ok(None);
}
let (plain, in_result) = match element.local_name().as_ref() {
b"tab" => (Some('\t'), Some('\t')),
b"br" | b"cr" => (
story_break_text(reader, element, false)?,
story_break_text(reader, element, true)?,
),
_ => return Ok(None),
};
let plain = if item.complex_field { in_result } else { plain };
Ok(Some(StoryTextPiece::Character(plain, in_result)))
}
fn story_text_start(element: &BytesStart<'_>, preserve_space: bool) -> Result<Vec<u8>> {
let content: &[u8] = element;
let mut xml_space_key = None;
for attribute in element.attributes() {
let attribute = attribute
.map_err(|error| Error::Other(format!("story text attribute scan failed: {error}")))?;
if attribute.key.as_ref() == b"xml:space" {
let key_address = attribute.key.as_ref().as_ptr() as usize;
let content_address = content.as_ptr() as usize;
let key_start = key_address.checked_sub(content_address).ok_or_else(|| {
Error::Other("story text attribute lies outside its opening tag".to_owned())
})?;
if key_start + attribute.key.as_ref().len() > content.len() {
return Err(Error::Other(
"story text attribute lies outside its opening tag".to_owned(),
));
}
xml_space_key = Some((key_start, attribute.key.as_ref().len()));
}
}
let mut updated = Vec::with_capacity(content.len() + 23);
updated.push(b'<');
if let Some((key_start, key_len)) = xml_space_key {
let mut attribute_start = key_start;
while attribute_start > element.name().as_ref().len()
&& content[attribute_start - 1].is_ascii_whitespace()
{
attribute_start -= 1;
}
let mut attribute_end = key_start + key_len;
while content
.get(attribute_end)
.is_some_and(u8::is_ascii_whitespace)
{
attribute_end += 1;
}
if content.get(attribute_end) != Some(&b'=') {
return Err(Error::Other(
"story text xml:space attribute has no equals sign".to_owned(),
));
}
attribute_end += 1;
while content
.get(attribute_end)
.is_some_and(u8::is_ascii_whitespace)
{
attribute_end += 1;
}
let quote = *content.get(attribute_end).ok_or_else(|| {
Error::Other("story text xml:space attribute has no quoted value".to_owned())
})?;
if !matches!(quote, b'\'' | b'"') {
return Err(Error::Other(
"story text xml:space attribute has no quoted value".to_owned(),
));
}
attribute_end += 1;
let value_end = content[attribute_end..]
.iter()
.position(|byte| *byte == quote)
.map(|offset| attribute_end + offset + 1)
.ok_or_else(|| {
Error::Other("story text xml:space attribute has no closing quote".to_owned())
})?;
updated.extend_from_slice(&content[..attribute_start]);
updated.extend_from_slice(&content[value_end..]);
} else {
updated.extend_from_slice(content);
}
if preserve_space {
updated.extend_from_slice(b" xml:space=\"preserve\"");
}
updated.push(b'>');
Ok(updated)
}
fn replace_story_item_text(source: &[u8], item: &StoryItemSpan, value: &str) -> Result<Vec<u8>> {
let mut reader = NsReader::from_reader(source);
reader.config_mut().trim_text(false);
let mut text_depth = 0usize;
let mut root_prefix = None;
let mut root_end = None;
let mut root_is_empty = false;
let mut texts: Vec<StoryTextElement> = Vec::new();
// Visible run tabs and breaks, which the new text replaces as well.
let mut removed: Vec<(StoryFieldContext, Range<usize>)> = Vec::new();
let mut removed_start = None;
let mut fields = StoryFieldTracker::default();
let mut element_stack: Vec<XmlElementFrame> = Vec::new();
let mut nested_owner_depth = 0usize;
let mut complex_results = Vec::new();
let mut complex_field_edit = ComplexFieldEdit::default();
let mut active = false;
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("story text mutation scan failed: {error}")))?;
let namespace_kind = story_namespace(&namespace);
let is_word_namespace = namespace_kind == StoryNamespace::Word;
drop(namespace);
let after = reader.buffer_position() as usize;
if !active {
if before != item.scan.start {
if matches!(event, Event::Eof) {
break;
}
buffer.clear();
continue;
}
active = true;
}
match event {
Event::Start(element) => {
let depth = element_stack.len();
if depth == 0 {
let qualified_name = element.name();
let name = std::str::from_utf8(qualified_name.as_ref()).map_err(|error| {
Error::Other(format!("story item name is not UTF-8: {error}"))
})?;
root_prefix = Some(
name.split_once(':')
.map_or("", |(prefix, _)| prefix)
.to_owned(),
);
}
let local_name = element.local_name().as_ref().to_vec();
let opaque =
if element_stack.is_empty() && item.kind == StoryItemKind::ContentControl {
false
} else {
opaque_story_event(
&mut element_stack,
namespace_kind,
&local_name,
&element,
false,
source,
before,
after,
)?
};
if !opaque
&& story_field_marker_tracked(item, before, nested_owner_depth, &element_stack)
&& is_word_namespace
&& local_name == b"fldChar"
{
let marker = complex_field_marker(&reader, &element)?;
track_story_field_marker(
item,
marker,
&mut fields,
&mut complex_results,
&mut complex_field_edit,
before,
&element_stack,
);
}
if !opaque
&& depth > 0
&& is_word_namespace
&& matches!(local_name.as_slice(), b"tc" | b"txbxContent")
{
nested_owner_depth += 1;
}
if !opaque
&& complex_field_text_visible(item, &complex_results)
&& nested_owner_depth == 0
&& is_word_namespace
&& local_name == b"t"
{
texts.push(StoryTextElement {
context: fields.context(),
tag: before..after,
markup: story_text_markup_tags(
&element,
story_text_preserves_space(value),
)?,
empty: false,
content: Vec::new(),
});
text_depth += 1;
} else if !opaque
&& complex_field_text_visible(item, &complex_results)
&& accepted_story_text_visible(&element_stack)
&& nested_owner_depth == 0
&& is_word_namespace
&& story_run_child(&element_stack)
&& matches!(local_name.as_slice(), b"tab" | b"br" | b"cr")
{
removed_start = Some((element_stack.len(), before, fields.context()));
}
let inherited = element_stack
.last()
.map_or(&[][..], |frame| frame.word_prefixes.as_slice());
let word_prefixes = story_word_prefixes_at(&element, inherited, is_word_namespace)?;
element_stack.push(XmlElementFrame {
namespace: namespace_kind,
local_name,
is_word: is_word_namespace,
full_start: before,
owner_kind: None,
item_kind: None,
direct_owner_child: false,
sdt_context: if depth == 0 && item.kind == StoryItemKind::ContentControl {
item.sdt_context
} else {
story_element_sdt_context(
&element_stack,
namespace_kind,
element.local_name().as_ref(),
)
},
word_prefixes,
opaque,
typed_sdt_content_seen: false,
});
}
Event::Empty(element) => {
if element_stack.is_empty() {
root_is_empty = true;
let qualified_name = element.name();
let name = std::str::from_utf8(qualified_name.as_ref()).map_err(|error| {
Error::Other(format!("story item name is not UTF-8: {error}"))
})?;
root_prefix = Some(
name.split_once(':')
.map_or("", |(prefix, _)| prefix)
.to_owned(),
);
}
let local_name = element.local_name();
let opaque = opaque_story_event(
&mut element_stack,
namespace_kind,
local_name.as_ref(),
&element,
true,
source,
before,
after,
)?;
if !opaque
&& story_field_marker_tracked(item, before, nested_owner_depth, &element_stack)
&& is_word_namespace
&& local_name.as_ref() == b"fldChar"
{
let marker = complex_field_marker(&reader, &element)?;
track_story_field_marker(
item,
marker,
&mut fields,
&mut complex_results,
&mut complex_field_edit,
before,
&element_stack,
);
} else if !opaque
&& complex_field_text_visible(item, &complex_results)
&& nested_owner_depth == 0
&& is_word_namespace
&& local_name.as_ref() == b"t"
{
texts.push(StoryTextElement {
context: fields.context(),
tag: before..after,
markup: story_text_markup_tags(
&element,
story_text_preserves_space(value),
)?,
empty: true,
content: Vec::new(),
});
} else if !opaque
&& complex_field_text_visible(item, &complex_results)
&& accepted_story_text_visible(&element_stack)
&& nested_owner_depth == 0
&& is_word_namespace
&& story_run_child(&element_stack)
&& matches!(local_name.as_ref(), b"tab" | b"br" | b"cr")
{
removed.push((fields.context(), before..after));
}
}
Event::End(element) => {
let opaque = element_stack.pop().is_some_and(|frame| frame.opaque);
let depth = element_stack.len();
if removed_start
.as_ref()
.is_some_and(|(start_depth, _, _)| *start_depth == depth)
&& let Some((_, start, context)) = removed_start.take()
{
removed.push((context, start..after));
}
if !opaque
&& complex_field_text_visible(item, &complex_results)
&& nested_owner_depth == 0
&& is_word_namespace
&& element.local_name().as_ref() == b"t"
{
text_depth = text_depth.saturating_sub(1);
}
if !opaque
&& depth > 0
&& is_word_namespace
&& matches!(element.local_name().as_ref(), b"tc" | b"txbxContent")
{
nested_owner_depth = nested_owner_depth.saturating_sub(1);
}
if depth == 0 {
root_end = Some(before);
}
}
Event::Text(_) | Event::CData(_) | Event::GeneralRef(_)
if complex_field_text_visible(item, &complex_results) && text_depth > 0 =>
{
if let Some(text) = texts.last_mut() {
text.content.push(before..after);
}
}
Event::Eof => break,
_ => {}
}
if (item.complex_field && after >= item.full.end)
|| (!item.complex_field && after == item.scan.end)
{
break;
}
buffer.clear();
}
// Only a field closed within the item hides its instruction part.
let texts = texts
.into_iter()
.filter(|text| !fields.hides(&text.context))
.collect::<Vec<_>>();
let removed_ranges = removed
.into_iter()
.filter(|(context, _)| !fields.hides(context))
.map(|(_, range)| range)
.collect::<Vec<_>>();
let ComplexFieldEdit {
separator_found: outer_complex_separator_found,
end_marker_start: complex_end_marker_start,
word_prefixes: complex_insertion_word_prefixes,
} = complex_field_edit;
let mut updated = source.to_vec();
let space = if story_text_preserves_space(value) {
" xml:space=\"preserve\""
} else {
""
};
if texts.is_empty() {
// Each insertion lies after every removed tab or break, so removing
// them afterwards from the end keeps the insertion in place.
if item.complex_field {
let insert_at = complex_end_marker_start.ok_or_else(|| {
Error::Other("complex story field has no closing marker".to_owned())
})?;
let nonempty_word_prefix = complex_insertion_word_prefixes
.iter()
.find(|prefix| !prefix.is_empty());
let default_word_namespace =
complex_insertion_word_prefixes.iter().any(String::is_empty);
let (prefix, declaration, attribute_prefix, attribute_declaration) =
if let Some(prefix) = nonempty_word_prefix {
(format!("{prefix}:"), String::new(), prefix.as_str(), "")
} else if default_word_namespace {
(
String::new(),
String::new(),
"w",
concat!(
" xmlns:w=\"",
"http://schemas.openxmlformats.org/wordprocessingml/2006/main",
"\""
),
)
} else {
(
"w:".to_owned(),
format!(" xmlns:w=\"{WORD_NAMESPACE}\""),
"w",
"",
)
};
let separator = if outer_complex_separator_found {
String::new()
} else {
format!(
"<{prefix}fldChar{declaration}{attribute_declaration} \
{attribute_prefix}:fldCharType=\"separate\"/>"
)
};
let markup = StoryTextMarkup::new(&prefix, &declaration, space);
let mut replacement = separator.into_bytes();
replacement.extend_from_slice(&markup.open);
replacement.extend_from_slice(&story_text_content(value, &markup));
replacement.extend_from_slice(&markup.close);
updated.splice(insert_at..insert_at, replacement);
} else {
if item.kind != StoryItemKind::Paragraph {
return Err(StoryError::NotTextBearing { kind: item.kind }.into());
}
let prefix = root_prefix.unwrap_or_else(|| "w".to_owned());
let prefix = if prefix.is_empty() {
String::new()
} else {
format!("{prefix}:")
};
let markup = StoryTextMarkup::new(&prefix, "", space);
let mut run = format!("<{prefix}r>").into_bytes();
run.extend_from_slice(&markup.open);
run.extend_from_slice(&story_text_content(value, &markup));
run.extend_from_slice(&markup.close);
run.extend_from_slice(format!("</{prefix}r>").as_bytes());
if let Some(end) = root_end {
updated.splice(end..end, run);
} else if root_is_empty {
let slash = item.full.end.checked_sub(2).ok_or_else(|| {
Error::Other("self-closing paragraph story item is truncated".to_owned())
})?;
if source.get(slash..item.full.end) != Some(b"/>".as_slice()) {
return Err(Error::Other(
"self-closing paragraph story item has no closing slash".to_owned(),
));
}
let mut replacement = b">".to_vec();
replacement.extend_from_slice(&run);
replacement.extend_from_slice(format!("</{prefix}p>").as_bytes());
updated.splice(slash..item.full.end, replacement);
} else {
return Err(Error::Other(
"paragraph story item has no closing element".to_owned(),
));
}
}
for range in removed_ranges.into_iter().rev() {
updated.splice(range, []);
}
} else {
let mut edits: Vec<(Range<usize>, Vec<u8>)> = removed_ranges
.into_iter()
.map(|range| (range, Vec::new()))
.collect();
for (ordinal, text) in texts.into_iter().enumerate() {
if ordinal == 0 {
// The first text element takes the whole new text.
let content = story_text_content(value, &text.markup);
if text.empty {
let mut replacement = text.markup.open;
replacement.extend_from_slice(&content);
replacement.extend_from_slice(&text.markup.close);
edits.push((text.tag, replacement));
} else {
edits.push((text.tag.end..text.tag.end, content));
edits.push((text.tag, text.markup.open));
}
}
edits.extend(text.content.into_iter().map(|range| (range, Vec::new())));
}
// A start tag ends where the new text is inserted, so equal starts
// sort the start tag first and apply it last.
edits.sort_by_key(|(range, _)| (range.start, range.end));
for (range, replacement) in edits.into_iter().rev() {
updated.splice(range, replacement);
}
}
Ok(updated)
}
/// One accepted text element an item's new text may replace, with the
/// fields open where it was read.
struct StoryTextElement {
context: StoryFieldContext,
tag: Range<usize>,
markup: StoryTextMarkup,
empty: bool,
content: Vec<Range<usize>>,
}
/// Where new text goes in a field item that has no text yet.
#[derive(Default)]
struct ComplexFieldEdit {
separator_found: bool,
end_marker_start: Option<usize>,
word_prefixes: Vec<String>,
}
/// Track one field character for text replacement. A field item records
/// where its own field separates and ends. Any other item tracks the fields
/// of its story to learn which close within it.
fn track_story_field_marker(
item: &StoryItemSpan,
marker: Option<ComplexFieldMarker>,
fields: &mut StoryFieldTracker,
complex_results: &mut Vec<bool>,
edit: &mut ComplexFieldEdit,
position: usize,
stack: &[XmlElementFrame],
) {
if !item.complex_field {
fields.mark(marker);
return;
}
if complex_results.len() == 1 && marker == Some(ComplexFieldMarker::Separate) {
edit.separator_found = true;
} else if complex_results.len() == 1 && marker == Some(ComplexFieldMarker::End) {
edit.end_marker_start = Some(position);
edit.word_prefixes = stack
.last()
.map_or_else(Vec::new, |frame| frame.word_prefixes.clone());
}
update_complex_field_results(complex_results, marker);
}
/// The tags that write new story text into a run: the text element's start
/// and end tags, and the tab and break elements a tab or line feed becomes.
struct StoryTextMarkup {
open: Vec<u8>,
close: Vec<u8>,
tab: Vec<u8>,
br: Vec<u8>,
}
impl StoryTextMarkup {
fn new(prefix: &str, declaration: &str, space: &str) -> Self {
Self {
open: format!("<{prefix}t{declaration}{space}>").into_bytes(),
close: format!("</{prefix}t>").into_bytes(),
tab: format!("<{prefix}tab{declaration}/>").into_bytes(),
br: format!("<{prefix}br{declaration}/>").into_bytes(),
}
}
}
/// Build the markup for new text written into an existing `w:t`, keeping its
/// attributes and namespace declarations for any further text element.
fn story_text_markup_tags(
element: &BytesStart<'_>,
preserve_space: bool,
) -> Result<StoryTextMarkup> {
let name = element.name();
let name = std::str::from_utf8(name.as_ref())
.map_err(|error| Error::Other(format!("story text name is not UTF-8: {error}")))?;
let prefix = name.strip_suffix('t').unwrap_or_default();
let mut declarations = String::new();
for attribute in element.attributes() {
let attribute = attribute
.map_err(|error| Error::Other(format!("story text attribute scan failed: {error}")))?;
let key = attribute.key.as_ref();
if key == b"xmlns" || key.starts_with(b"xmlns:") {
declarations.push(' ');
declarations.push_str(&String::from_utf8_lossy(key));
declarations.push_str("=\"");
declarations.push_str(&String::from_utf8_lossy(&attribute.value));
declarations.push('"');
}
}
let mut markup = StoryTextMarkup::new(prefix, &declarations, "");
markup.open = story_text_start(element, preserve_space)?;
markup.close = format!("</{name}>").into_bytes();
Ok(markup)
}
/// Escape new story text, writing a tab as `w:tab` and a line feed or a
/// carriage return as `w:br` between text elements, as `Paragraph::set_text`
/// does.
fn story_text_content(value: &str, markup: &StoryTextMarkup) -> Vec<u8> {
let mut content = Vec::with_capacity(value.len());
let mut separators = value
.chars()
.filter(|character| matches!(character, '\t' | '\n' | '\r'));
for (index, segment) in value.split(['\t', '\n', '\r']).enumerate() {
if index > 0 {
content.extend_from_slice(&markup.close);
if separators.next() == Some('\t') {
content.extend_from_slice(&markup.tab);
} else {
content.extend_from_slice(&markup.br);
}
content.extend_from_slice(&markup.open);
}
content.extend_from_slice(quick_xml::escape::escape(segment).as_bytes());
}
content
}
/// Return whether any text element of new story text must keep its spaces.
fn story_text_preserves_space(value: &str) -> bool {
value
.split(['\t', '\n', '\r'])
.any(|segment| segment.starts_with(' ') || segment.ends_with(' '))
}
fn hyperlink_has_visible_content(source: &[u8], range: Range<usize>) -> Result<bool> {
let mut reader = NsReader::from_reader(source);
reader.config_mut().trim_text(false);
let mut word_text_depth = 0usize;
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("hyperlink content scan failed: {error}")))?;
let is_word = word_element(&namespace);
drop(namespace);
let after = reader.buffer_position() as usize;
if after <= range.start {
buffer.clear();
continue;
}
if before >= range.end {
return Ok(false);
}
match event {
Event::Start(element) if is_word && element.local_name().as_ref() == b"t" => {
word_text_depth += 1;
}
Event::End(element) if is_word && element.local_name().as_ref() == b"t" => {
word_text_depth = word_text_depth.saturating_sub(1);
}
Event::Text(text) if word_text_depth > 0 => {
let bytes: &[u8] = text.as_ref();
if !bytes.is_empty() {
return Ok(true);
}
}
Event::Start(element) | Event::Empty(element)
if is_word
&& matches!(
element.local_name().as_ref(),
b"tab"
| b"br"
| b"cr"
| b"sym"
| b"drawing"
| b"pict"
| b"object"
| b"footnoteReference"
| b"endnoteReference"
) =>
{
return Ok(true);
}
Event::Eof => return Ok(false),
_ => {}
}
buffer.clear();
}
}
fn story_hyperlinks(source: &[u8], item_range: Range<usize>) -> Result<Vec<(Range<usize>, bool)>> {
source
.get(item_range.clone())
.ok_or_else(|| Error::Other("story item range is outside updated XML".to_owned()))?;
let mut reader = NsReader::from_reader(source);
reader.config_mut().trim_text(false);
let mut ranges = Vec::new();
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("empty hyperlink scan failed: {error}")))?;
let is_word = word_element(&namespace);
drop(namespace);
let after = reader.buffer_position() as usize;
if after <= item_range.start {
buffer.clear();
continue;
}
if before >= item_range.end {
break;
}
if let Event::Start(element) = event {
if is_word && element.local_name().as_ref() == b"hyperlink" {
let end = story_element_end(source, before)?;
let visible = hyperlink_has_visible_content(source, before..end)?;
ranges.push((before..end, visible));
}
} else if matches!(event, Event::Eof) {
break;
}
buffer.clear();
}
Ok(ranges)
}
fn remove_newly_empty_hyperlinks(
before: &[u8],
before_item: Range<usize>,
after: &[u8],
after_item: Range<usize>,
) -> Result<Vec<u8>> {
let old_links = story_hyperlinks(before, before_item)?;
let new_links = story_hyperlinks(after, after_item)?;
let ranges = new_links
.into_iter()
.enumerate()
.filter_map(|(index, (range, visible))| {
(!visible
&& old_links
.get(index)
.is_some_and(|(_, old_visible)| *old_visible))
.then_some(range)
})
.collect::<Vec<_>>();
if ranges.is_empty() {
return Ok(after.to_vec());
}
let mut updated = Vec::with_capacity(after.len());
let mut cursor = 0usize;
for range in ranges {
updated.extend_from_slice(&after[cursor..range.start]);
cursor = range.end;
}
updated.extend_from_slice(&after[cursor..]);
Ok(updated)
}
fn validate_fresh_word_compatible_package(
package: &OpcPackage,
expected_class: WordPackageClass,
) -> Result<()> {
let (_, actual_class) = validated_word_package_class(package)?;
if actual_class != expected_class {
return Err(Error::Other(
"fresh Word package class does not match its profile".to_owned(),
));
}
let expected_parts = [
("/word/document.xml", expected_class.content_type()),
(DEFAULT_STYLES_PART, STYLES_CONTENT_TYPE),
(DEFAULT_SETTINGS_PART, SETTINGS_CONTENT_TYPE),
(DEFAULT_THEME_PART, content_types::THEME),
(DEFAULT_FONT_TABLE_PART, FONT_TABLE_CONTENT_TYPE),
(DEFAULT_CORE_PROPERTIES_PART, content_types::CORE_PROPERTIES),
(
DEFAULT_APP_PROPERTIES_PART,
content_types::EXTENDED_PROPERTIES,
),
];
for (part_name, expected_content_type) in expected_parts {
if !package.contains_part(part_name)
|| package
.content_types
.override_for(part_name)
.is_none_or(|actual| actual != expected_content_type)
{
return Err(Error::Other(format!(
"fresh Word package is missing required part {part_name}"
)));
}
}
if package.parts.len() != expected_parts.len()
|| package.content_types.overrides.len() != expected_parts.len()
{
return Err(Error::Other(
"fresh Word package contains an unexpected owned part".to_owned(),
));
}
let expected_package_relationships = [
(rel_types::DOCUMENT, "/word/document.xml"),
(rel_types::CORE_PROPERTIES, DEFAULT_CORE_PROPERTIES_PART),
(rel_types::EXTENDED_PROPERTIES, DEFAULT_APP_PROPERTIES_PART),
];
let expected_document_relationships = [
(rel_types::STYLES, DEFAULT_STYLES_PART),
(rel_types::SETTINGS, DEFAULT_SETTINGS_PART),
(rel_types::THEME, DEFAULT_THEME_PART),
(rel_types::FONT_TABLE, DEFAULT_FONT_TABLE_PART),
];
let document_relationships = package
.get_part_rels("/word/document.xml")
.ok_or_else(|| Error::Other("fresh Word package has no main relationships".to_owned()))?;
if package.package_rels.items.len() != expected_package_relationships.len()
|| document_relationships.items.len() != expected_document_relationships.len()
|| package.part_rels.len() != 1
{
return Err(Error::Other(
"fresh Word package relationship inventory is incomplete".to_owned(),
));
}
for (source, relationships, expected) in [
(
"/",
&package.package_rels,
expected_package_relationships.as_slice(),
),
(
"/word/document.xml",
document_relationships,
expected_document_relationships.as_slice(),
),
] {
for (relationship_type, expected_target) in expected {
let matching: Vec<_> = relationships
.items
.iter()
.filter(|relationship| relationship.rel_type == *relationship_type)
.collect();
if matching.len() != 1
|| matching[0].target_mode.is_some()
|| OpcPackage::resolve_rel_target(source, &matching[0].target) != *expected_target
{
return Err(Error::Other(format!(
"fresh Word package relationship {relationship_type} is invalid"
)));
}
}
}
for (source, relationships) in std::iter::once(("/", &package.package_rels)).chain(
package
.part_rels
.iter()
.map(|(source, rels)| (source.as_str(), rels)),
) {
for relationship in &relationships.items {
if !relationship_is_internal(relationship) {
continue;
}
let target = OpcPackage::resolve_rel_target(source, &relationship.target);
if !package.contains_part(&target) {
return Err(Error::Other(format!(
"fresh Word relationship targets missing part {target}"
)));
}
}
}
Ok(())
}
fn validated_word_package_class(package: &OpcPackage) -> Result<(String, WordPackageClass)> {
let relationships: Vec<_> = package
.package_rels
.items
.iter()
.filter(|relationship| relationship.rel_type == rel_types::DOCUMENT)
.collect();
let relationship = match relationships.as_slice() {
[relationship] => *relationship,
[] => return Err(Error::NoDocumentPart),
_ => {
return Err(Error::Other(
"Word package has multiple officeDocument relationships".to_owned(),
));
}
};
if !matches!(relationship.target_mode.as_deref(), None | Some("Internal")) {
return Err(Error::Other(
"Word package officeDocument relationship must be internal".to_owned(),
));
}
let target = relationship.target.as_str();
if !crate::embedded::relationship_target_is_normalized_pack_uri(target)
|| target
.split('/')
.any(|segment| segment == "." || segment == "..")
{
return Err(Error::Other(
"Word package officeDocument relationship has an unsafe target".to_owned(),
));
}
let doc_part_name = if target.starts_with('/') {
target.to_owned()
} else {
format!("/{target}")
};
if !package.contains_part(&doc_part_name) {
return Err(Error::NoDocumentPart);
}
let content_type = package
.content_types
.override_for(&doc_part_name)
.ok_or_else(|| {
Error::Other("Word main part requires an exact content-type override".to_owned())
})?;
let class = WordPackageClass::from_content_type(content_type).ok_or_else(|| {
Error::Other(format!(
"unsupported Word main-part content type: {content_type}"
))
})?;
Ok((doc_part_name, class))
}
fn take_paragraph<'a>(paragraph: &'a mut CT_P, index: &mut usize) -> Option<&'a mut CT_P> {
if *index == 0 {
Some(paragraph)
} else {
*index -= 1;
None
}
}
fn nth_paragraph_in_body<'a>(
content: &'a mut [BodyContent],
index: &mut usize,
) -> Option<&'a mut CT_P> {
for child in content {
let paragraph = match child {
BodyContent::Paragraph(paragraph) => take_paragraph(paragraph, index),
BodyContent::ContentControl(control) => nth_paragraph_in_control(control, index),
BodyContent::Table(_) | BodyContent::RawXml(_) => None,
};
if paragraph.is_some() {
return paragraph;
}
}
None
}
fn nth_paragraph_in_control<'a>(
control: &'a mut CT_Sdt,
index: &mut usize,
) -> Option<&'a mut CT_P> {
for child in &mut control.content {
let paragraph = match child {
SdtContent::Paragraph(paragraph) => take_paragraph(paragraph, index),
SdtContent::Table(table) => nth_paragraph_in_table(table, index),
SdtContent::Row(row) => nth_paragraph_in_row(row, index),
SdtContent::Cell(cell) => nth_paragraph_in_cell(cell, index),
SdtContent::ContentControl(control) => nth_paragraph_in_control(control, index),
SdtContent::Run(_) | SdtContent::RawXml(_) => None,
};
if paragraph.is_some() {
return paragraph;
}
}
None
}
fn paragraph_count_in_control(control: &CT_Sdt) -> usize {
control
.content
.iter()
.map(|child| match child {
SdtContent::Paragraph(_) => 1,
SdtContent::Table(table) => paragraph_count_in_table(table),
SdtContent::Row(row) => paragraph_count_in_row(row),
SdtContent::Cell(cell) => paragraph_count_in_cell(cell),
SdtContent::ContentControl(control) => paragraph_count_in_control(control),
SdtContent::Run(_) | SdtContent::RawXml(_) => 0,
})
.sum()
}
fn paragraph_count_in_table(table: &CT_Tbl) -> usize {
table
.content_controls
.iter()
.map(|(_, _, control)| paragraph_count_in_control(control))
.sum::<usize>()
+ table.rows.iter().map(paragraph_count_in_row).sum::<usize>()
}
fn paragraph_count_in_row(row: &CT_Row) -> usize {
row.content_controls
.iter()
.map(|(_, _, control)| paragraph_count_in_control(control))
.sum::<usize>()
+ row.cells.iter().map(paragraph_count_in_cell).sum::<usize>()
}
fn paragraph_count_in_cell(cell: &CT_Tc) -> usize {
cell.content
.iter()
.map(|child| match child {
CellContent::Paragraph(_) => 1,
CellContent::ContentControl(control) => paragraph_count_in_control(control),
CellContent::Table(_) => 0,
})
.sum()
}
fn nth_paragraph_in_table<'a>(table: &'a mut CT_Tbl, index: &mut usize) -> Option<&'a mut CT_P> {
let CT_Tbl {
rows,
content_controls,
..
} = table;
let mut selected_control = None;
let mut selected_row = None;
for boundary in 0..=rows.len() {
for (control_index, (_, _, control)) in content_controls
.iter()
.enumerate()
.filter(|(_, (at, _, _))| *at == boundary)
{
let count = paragraph_count_in_control(control);
if *index < count {
selected_control = Some(control_index);
break;
}
*index -= count;
}
if selected_control.is_some() {
break;
}
if let Some(row) = rows.get(boundary) {
let count = paragraph_count_in_row(row);
if *index < count {
selected_row = Some(boundary);
break;
}
*index -= count;
}
}
if let Some(control_index) = selected_control {
nth_paragraph_in_control(&mut content_controls[control_index].2, index)
} else if let Some(row_index) = selected_row {
nth_paragraph_in_row(&mut rows[row_index], index)
} else {
None
}
}
fn nth_paragraph_in_row<'a>(row: &'a mut CT_Row, index: &mut usize) -> Option<&'a mut CT_P> {
let CT_Row {
cells,
content_controls,
..
} = row;
let mut selected_control = None;
let mut selected_cell = None;
for boundary in 0..=cells.len() {
for (control_index, (_, _, control)) in content_controls
.iter()
.enumerate()
.filter(|(_, (at, _, _))| *at == boundary)
{
let count = paragraph_count_in_control(control);
if *index < count {
selected_control = Some(control_index);
break;
}
*index -= count;
}
if selected_control.is_some() {
break;
}
if let Some(cell) = cells.get(boundary) {
let count = paragraph_count_in_cell(cell);
if *index < count {
selected_cell = Some(boundary);
break;
}
*index -= count;
}
}
if let Some(control_index) = selected_control {
nth_paragraph_in_control(&mut content_controls[control_index].2, index)
} else if let Some(cell_index) = selected_cell {
nth_paragraph_in_cell(&mut cells[cell_index], index)
} else {
None
}
}
fn nth_paragraph_in_cell<'a>(cell: &'a mut CT_Tc, index: &mut usize) -> Option<&'a mut CT_P> {
for child in &mut cell.content {
let paragraph = match child {
CellContent::Paragraph(paragraph) => take_paragraph(paragraph, index),
CellContent::ContentControl(control) => nth_paragraph_in_control(control, index),
CellContent::Table(_) => None,
};
if paragraph.is_some() {
return paragraph;
}
}
None
}
fn take_table<'a>(table: &'a mut CT_Tbl, index: &mut usize) -> Option<&'a mut CT_Tbl> {
if *index == 0 {
Some(table)
} else {
*index -= 1;
None
}
}
fn nth_table_in_body<'a>(
content: &'a mut [BodyContent],
index: &mut usize,
) -> Option<&'a mut CT_Tbl> {
for child in content {
let table = match child {
BodyContent::Table(table) => take_table(table, index),
BodyContent::ContentControl(control) => nth_table_in_control(control, index),
BodyContent::Paragraph(_) | BodyContent::RawXml(_) => None,
};
if table.is_some() {
return table;
}
}
None
}
fn nth_table_in_control<'a>(control: &'a mut CT_Sdt, index: &mut usize) -> Option<&'a mut CT_Tbl> {
for child in &mut control.content {
let table = match child {
SdtContent::Table(table) => take_table(table, index),
SdtContent::Cell(cell) => nth_table_in_cell(cell, index),
SdtContent::ContentControl(control) => nth_table_in_control(control, index),
SdtContent::Paragraph(_)
| SdtContent::Row(_)
| SdtContent::Run(_)
| SdtContent::RawXml(_) => None,
};
if table.is_some() {
return table;
}
}
None
}
fn nth_table_in_cell<'a>(cell: &'a mut CT_Tc, index: &mut usize) -> Option<&'a mut CT_Tbl> {
for child in &mut cell.content {
let table = match child {
CellContent::Table(table) => take_table(table, index),
CellContent::ContentControl(control) => nth_table_in_control(control, index),
CellContent::Paragraph(_) => None,
};
if table.is_some() {
return table;
}
}
None
}
fn modeled_main_cell_route(xml: &[u8], target: &StoryOwnerSpan) -> Result<(usize, usize)> {
let body = scan_story_owners(xml, StoryKind::Body)?
.into_iter()
.find(|owner| owner.kind == StoryKind::Body)
.ok_or_else(|| Error::Other("main document body owner was not found".to_owned()))?;
let direct_items = direct_story_content_items(xml, &body)?;
let (content_index, direct_item) = direct_items
.iter()
.enumerate()
.find(|(_, item)| item.full.start <= target.full.start && target.full.end <= item.full.end)
.ok_or_else(|| Error::Other("table-cell owner has no direct body container".to_owned()))?;
if !matches!(
direct_item.kind,
StoryItemKind::Table | StoryItemKind::ContentControl
) {
return Err(Error::Other(
"HTML fragments require a modeled main-document table-cell owner".to_owned(),
));
}
let cell_index = scan_story_owners(xml, StoryKind::Body)?
.into_iter()
.filter(|owner| {
owner.kind == StoryKind::TableCell
&& direct_item.full.start <= owner.full.start
&& owner.full.end <= direct_item.full.end
})
.position(|owner| owner.full == target.full)
.ok_or_else(|| Error::Other("modeled table-cell owner was not found".to_owned()))?;
Ok((content_index, cell_index))
}
fn nth_cell_in_control<'a>(control: &'a mut CT_Sdt, index: &mut usize) -> Option<&'a mut CT_Tc> {
for child in &mut control.content {
let cell = match child {
SdtContent::Table(table) => nth_cell_in_table(table, index),
SdtContent::Row(row) => nth_cell_in_row(row, index),
SdtContent::Cell(cell) => take_cell(cell, index),
SdtContent::ContentControl(control) => nth_cell_in_control(control, index),
SdtContent::Paragraph(_) | SdtContent::Run(_) | SdtContent::RawXml(_) => None,
};
if cell.is_some() {
return cell;
}
}
None
}
fn nth_cell_in_table<'a>(table: &'a mut CT_Tbl, index: &mut usize) -> Option<&'a mut CT_Tc> {
let CT_Tbl {
rows,
content_controls,
..
} = table;
let mut selected_control = None;
let mut selected_row = None;
for boundary in 0..=rows.len() {
for (control_index, (_, _, control)) in content_controls
.iter()
.enumerate()
.filter(|(_, (at, _, _))| *at == boundary)
{
let count = cell_count_in_control(control);
if *index < count {
selected_control = Some(control_index);
break;
}
*index -= count;
}
if selected_control.is_some() {
break;
}
if let Some(row) = rows.get(boundary) {
let count = cell_count_in_row(row);
if *index < count {
selected_row = Some(boundary);
break;
}
*index -= count;
}
}
if let Some(control_index) = selected_control {
nth_cell_in_control(&mut content_controls[control_index].2, index)
} else if let Some(row_index) = selected_row {
nth_cell_in_row(&mut rows[row_index], index)
} else {
None
}
}
fn nth_cell_in_row<'a>(row: &'a mut CT_Row, index: &mut usize) -> Option<&'a mut CT_Tc> {
let CT_Row {
cells,
content_controls,
..
} = row;
let mut selected_control = None;
let mut selected_cell = None;
for boundary in 0..=cells.len() {
for (control_index, (_, _, control)) in content_controls
.iter()
.enumerate()
.filter(|(_, (at, _, _))| *at == boundary)
{
let count = cell_count_in_control(control);
if *index < count {
selected_control = Some(control_index);
break;
}
*index -= count;
}
if selected_control.is_some() {
break;
}
if let Some(cell) = cells.get(boundary) {
let count = 1 + cell_count_in_cell(cell);
if *index < count {
selected_cell = Some(boundary);
break;
}
*index -= count;
}
}
if let Some(control_index) = selected_control {
nth_cell_in_control(&mut content_controls[control_index].2, index)
} else if let Some(cell_index) = selected_cell {
take_cell(&mut cells[cell_index], index)
} else {
None
}
}
fn take_cell<'a>(cell: &'a mut CT_Tc, index: &mut usize) -> Option<&'a mut CT_Tc> {
#[cfg(test)]
FX237_LEGACY_CELL_STEPS.set(FX237_LEGACY_CELL_STEPS.get() + 1);
if *index == 0 {
Some(cell)
} else {
*index -= 1;
for child in &mut cell.content {
let nested = match child {
CellContent::Table(table) => nth_cell_in_table(table, index),
CellContent::ContentControl(control) => nth_cell_in_control(control, index),
CellContent::Paragraph(_) => None,
};
if nested.is_some() {
return nested;
}
}
None
}
}
// Traverse the parsed physical tree once. Coordinates name direct physical
// rows/cells only, while controls still participate in source correspondence.
enum PhysicalCellNode<'a> {
Table(&'a CT_Tbl),
Row(&'a CT_Row, usize, Option<usize>),
Cell(&'a CT_Tc, usize, Option<(usize, usize)>),
Control(&'a CT_Sdt, usize),
}
#[allow(clippy::type_complexity)]
fn physical_table_cells(table: &CT_Tbl) -> (usize, Vec<(usize, Option<(usize, usize)>)>) {
let mut stack = vec![PhysicalCellNode::Table(table)];
let mut tables = 0;
let mut cells = Vec::new();
while let Some(node) = stack.pop() {
let mut children = Vec::new();
match node {
PhysicalCellNode::Table(table) => {
let index = tables;
tables += 1;
let mut controls = table.content_controls.iter().peekable();
for boundary in 0..=table.rows.len() {
while controls.peek().is_some_and(|(at, _, _)| *at == boundary) {
children.push(PhysicalCellNode::Control(
&controls.next().unwrap().2,
index,
));
}
if let Some(row) = table.rows.get(boundary) {
children.push(PhysicalCellNode::Row(row, index, Some(boundary)));
}
}
}
PhysicalCellNode::Row(row, table, ordinal) => {
let mut controls = row.content_controls.iter().peekable();
for boundary in 0..=row.cells.len() {
while controls.peek().is_some_and(|(at, _, _)| *at == boundary) {
children.push(PhysicalCellNode::Control(
&controls.next().unwrap().2,
table,
));
}
if let Some(cell) = row.cells.get(boundary) {
children.push(PhysicalCellNode::Cell(
cell,
table,
ordinal.map(|row| (row, boundary)),
));
}
}
}
PhysicalCellNode::Cell(cell, table, coordinate) => {
cells.push((table, coordinate));
for child in &cell.content {
match child {
CellContent::Table(table) => children.push(PhysicalCellNode::Table(table)),
CellContent::ContentControl(control) => {
children.push(PhysicalCellNode::Control(control, table))
}
CellContent::Paragraph(_) => {}
}
}
}
PhysicalCellNode::Control(control, table) => {
for child in &control.content {
match child {
SdtContent::Table(table) => children.push(PhysicalCellNode::Table(table)),
SdtContent::Row(row) => {
children.push(PhysicalCellNode::Row(row, table, None))
}
SdtContent::Cell(cell) => {
children.push(PhysicalCellNode::Cell(cell, table, None))
}
SdtContent::ContentControl(control) => {
children.push(PhysicalCellNode::Control(control, table))
}
_ => {}
}
}
}
}
stack.extend(children.into_iter().rev());
}
#[cfg(test)]
{
FX237_TABLE_VISITS.set(FX237_TABLE_VISITS.get() + tables);
FX237_CELL_VISITS.set(FX237_CELL_VISITS.get() + cells.len());
}
(tables, cells)
}
fn cell_count_in_control(control: &CT_Sdt) -> usize {
control
.content
.iter()
.map(|child| match child {
SdtContent::Table(table) => cell_count_in_table(table),
SdtContent::Row(row) => cell_count_in_row(row),
SdtContent::Cell(cell) => 1 + cell_count_in_cell(cell),
SdtContent::ContentControl(control) => cell_count_in_control(control),
SdtContent::Paragraph(_) | SdtContent::Run(_) | SdtContent::RawXml(_) => 0,
})
.sum()
}
fn cell_count_in_table(table: &CT_Tbl) -> usize {
table
.content_controls
.iter()
.map(|(_, _, control)| cell_count_in_control(control))
.sum::<usize>()
+ table.rows.iter().map(cell_count_in_row).sum::<usize>()
}
fn cell_count_in_row(row: &CT_Row) -> usize {
row.content_controls
.iter()
.map(|(_, _, control)| cell_count_in_control(control))
.sum::<usize>()
+ row
.cells
.iter()
.map(|cell| 1 + cell_count_in_cell(cell))
.sum::<usize>()
}
fn cell_count_in_cell(cell: &CT_Tc) -> usize {
#[cfg(test)]
FX237_LEGACY_CELL_STEPS.set(FX237_LEGACY_CELL_STEPS.get() + 1);
cell.content
.iter()
.map(|child| match child {
CellContent::Table(table) => cell_count_in_table(table),
CellContent::ContentControl(control) => cell_count_in_control(control),
CellContent::Paragraph(_) => 0,
})
.sum()
}
fn insert_html_content_into_cell(
cell: &mut CT_Tc,
direct_index: usize,
content: Vec<BodyContent>,
) -> Result<()> {
let inserted = content
.into_iter()
.map(|item| match item {
BodyContent::Paragraph(paragraph) => Ok(CellContent::Paragraph(paragraph)),
BodyContent::Table(table) => Ok(CellContent::Table(table)),
BodyContent::ContentControl(control) => Ok(CellContent::ContentControl(control)),
BodyContent::RawXml(_) => Err(Error::Other(
"HTML projection produced unsupported raw cell content".to_owned(),
)),
})
.collect::<Result<Vec<_>>>()?;
let inserted_len = inserted.len();
let mut virtual_index = 0_usize;
let mut insertion = None;
for content_index in 0..=cell.content.len() {
for (raw_index, (position, _)) in cell.extra_xml.iter().enumerate() {
if *position != content_index {
continue;
}
if virtual_index == direct_index {
insertion = Some((content_index, raw_index));
break;
}
virtual_index += 1;
}
if insertion.is_some() {
break;
}
if content_index < cell.content.len() {
if virtual_index == direct_index {
let raw_index = cell
.extra_xml
.iter()
.position(|(position, _)| *position > content_index)
.unwrap_or(cell.extra_xml.len());
insertion = Some((content_index, raw_index));
break;
}
virtual_index += 1;
}
}
if virtual_index == direct_index && insertion.is_none() {
insertion = Some((cell.content.len(), cell.extra_xml.len()));
}
let Some((content_index, raw_shift_start)) = insertion else {
return Err(StoryError::OutOfBounds {
index: direct_index,
len: virtual_index,
}
.into());
};
for (position, _) in cell.extra_xml.iter_mut().skip(raw_shift_start) {
*position = position
.checked_add(inserted_len)
.ok_or_else(|| Error::Other("cell raw-content position overflowed".to_owned()))?;
}
cell.content.splice(content_index..content_index, inserted);
Ok(())
}
pub(crate) fn visit_body_paragraphs(
content: &[BodyContent],
accepted: bool,
visitor: &mut impl FnMut(&CT_P),
) {
for item in content {
match item {
BodyContent::Paragraph(paragraph) => visit_paragraph(paragraph, accepted, visitor),
BodyContent::Table(table) => visit_table(table, accepted, visitor),
BodyContent::ContentControl(control) => visit_sdt(control, accepted, visitor),
BodyContent::RawXml(_) => {}
}
}
}
// With `accepted`, the visitors leave out the rows that accepting every
// tracked change removes, as `CT_Row::accepted_view_removes` reports them.
// `visit_row` itself visits the row it is given.
fn visit_paragraph(paragraph: &CT_P, accepted: bool, visitor: &mut impl FnMut(&CT_P)) {
visitor(paragraph);
for (_, _, _, control) in ¶graph.content_controls {
visit_sdt(control, accepted, visitor);
}
}
fn visit_table(table: &CT_Tbl, accepted: bool, visitor: &mut impl FnMut(&CT_P)) {
for index in 0..=table.rows.len() {
for (_, _, control) in table
.content_controls
.iter()
.filter(|(at, _, _)| *at == index)
{
visit_sdt(control, accepted, visitor);
}
if let Some(row) = table.rows.get(index)
&& !(accepted && row.accepted_view_removes())
{
visit_row(row, accepted, visitor);
}
}
}
fn visit_row(row: &CT_Row, accepted: bool, visitor: &mut impl FnMut(&CT_P)) {
for index in 0..=row.cells.len() {
for (_, _, control) in row
.content_controls
.iter()
.filter(|(at, _, _)| *at == index)
{
visit_sdt(control, accepted, visitor);
}
if let Some(cell) = row.cells.get(index) {
visit_cell(cell, accepted, visitor);
}
}
}
fn visit_cell(cell: &CT_Tc, accepted: bool, visitor: &mut impl FnMut(&CT_P)) {
for item in &cell.content {
match item {
CellContent::Paragraph(paragraph) => visit_paragraph(paragraph, accepted, visitor),
CellContent::Table(table) => visit_table(table, accepted, visitor),
CellContent::ContentControl(control) => visit_sdt(control, accepted, visitor),
}
}
}
fn visit_sdt(control: &CT_Sdt, accepted: bool, visitor: &mut impl FnMut(&CT_P)) {
for item in &control.content {
match item {
SdtContent::Paragraph(paragraph) => visit_paragraph(paragraph, accepted, visitor),
SdtContent::Table(table) => visit_table(table, accepted, visitor),
SdtContent::Row(row) if accepted && row.accepted_view_removes() => {}
SdtContent::Row(row) => visit_row(row, accepted, visitor),
SdtContent::Cell(cell) => visit_cell(cell, accepted, visitor),
SdtContent::Run(_) | SdtContent::RawXml(_) => {}
SdtContent::ContentControl(nested) => visit_sdt(nested, accepted, visitor),
}
}
}
/// Collect the body paragraphs in document order, those that body-level
/// content controls wrap included. Tables are not entered, with or without a
/// control around them. This is what [`Document::headings`] and
/// [`Document::links`] read.
fn block_paragraphs(content: &[BodyContent]) -> Vec<&CT_P> {
let mut paragraphs = Vec::new();
for item in content {
match item {
BodyContent::Paragraph(paragraph) => paragraphs.push(paragraph),
BodyContent::ContentControl(control) => {
collect_block_control_paragraphs(control, &mut paragraphs);
}
BodyContent::Table(_) | BodyContent::RawXml(_) => {}
}
}
paragraphs
}
fn collect_block_control_paragraphs<'a>(control: &'a CT_Sdt, paragraphs: &mut Vec<&'a CT_P>) {
for item in &control.content {
match item {
SdtContent::Paragraph(paragraph) => paragraphs.push(paragraph),
SdtContent::ContentControl(nested) => {
collect_block_control_paragraphs(nested, paragraphs);
}
SdtContent::Table(_)
| SdtContent::Row(_)
| SdtContent::Cell(_)
| SdtContent::Run(_)
| SdtContent::RawXml(_) => {}
}
}
}
/// Append a block-level paragraph to [`Document::text`] as one line.
fn push_paragraph_line(paragraph: &CT_P, text: &mut String) {
text.push_str(&ParagraphRef { inner: paragraph }.text());
text.push('\n');
}
/// Append a table to [`Document::text`] as one line per row, rows wrapped by
/// table-level content controls included.
fn push_table_text(table: &CT_Tbl, text: &mut String) {
for index in 0..=table.rows.len() {
for (_, _, control) in table
.content_controls
.iter()
.filter(|(at, _, _)| *at == index)
{
push_control_text(control, text);
}
if let Some(row) = table.rows.get(index) {
push_row_text(row, text);
}
}
}
/// Append a row to [`Document::text`] as one line in which every paragraph of
/// its cells ends with a tab, cell controls and nested tables included.
fn push_row_text(row: &CT_Row, text: &mut String) {
if row.accepted_view_removes() {
return;
}
visit_row(row, true, &mut |paragraph| {
push_cell_paragraph(paragraph, text)
});
text.push('\n');
}
fn push_cell_paragraph(paragraph: &CT_P, text: &mut String) {
text.push_str(&ParagraphRef { inner: paragraph }.text());
text.push('\t');
}
/// Append what a block, table or row content control wraps to [`Document::text`].
fn push_control_text(control: &CT_Sdt, text: &mut String) {
for item in &control.content {
match item {
SdtContent::Paragraph(paragraph) => push_paragraph_line(paragraph, text),
SdtContent::Table(table) => push_table_text(table, text),
SdtContent::Row(row) => push_row_text(row, text),
SdtContent::Cell(cell) => {
visit_cell(cell, true, &mut |paragraph| {
push_cell_paragraph(paragraph, text)
});
}
SdtContent::Run(_) | SdtContent::RawXml(_) => {}
SdtContent::ContentControl(nested) => push_control_text(nested, text),
}
}
}
pub(crate) fn visit_body_paragraphs_mut(
content: &mut [BodyContent],
visitor: &mut impl FnMut(&mut CT_P),
) {
for item in content {
match item {
BodyContent::Paragraph(paragraph) => visit_paragraph_mut(paragraph, visitor),
BodyContent::Table(table) => visit_table_mut(table, visitor),
BodyContent::ContentControl(control) => visit_sdt_mut(control, visitor),
BodyContent::RawXml(_) => {}
}
}
}
fn visit_paragraph_mut(paragraph: &mut CT_P, visitor: &mut impl FnMut(&mut CT_P)) {
visitor(paragraph);
for (_, _, _, control) in &mut paragraph.content_controls {
visit_sdt_mut(control, visitor);
}
}
fn visit_table_mut(table: &mut CT_Tbl, visitor: &mut impl FnMut(&mut CT_P)) {
for index in 0..=table.rows.len() {
for (_, _, control) in table
.content_controls
.iter_mut()
.filter(|(at, _, _)| *at == index)
{
visit_sdt_mut(control, visitor);
}
if let Some(row) = table.rows.get_mut(index) {
visit_row_mut(row, visitor);
}
}
}
fn visit_row_mut(row: &mut CT_Row, visitor: &mut impl FnMut(&mut CT_P)) {
for index in 0..=row.cells.len() {
for (_, _, control) in row
.content_controls
.iter_mut()
.filter(|(at, _, _)| *at == index)
{
visit_sdt_mut(control, visitor);
}
if let Some(cell) = row.cells.get_mut(index) {
visit_cell_mut(cell, visitor);
}
}
}
fn visit_cell_mut(cell: &mut CT_Tc, visitor: &mut impl FnMut(&mut CT_P)) {
for item in &mut cell.content {
match item {
CellContent::Paragraph(paragraph) => visit_paragraph_mut(paragraph, visitor),
CellContent::Table(table) => visit_table_mut(table, visitor),
CellContent::ContentControl(control) => visit_sdt_mut(control, visitor),
}
}
}
fn visit_sdt_mut(control: &mut CT_Sdt, visitor: &mut impl FnMut(&mut CT_P)) {
for item in &mut control.content {
match item {
SdtContent::Paragraph(paragraph) => visit_paragraph_mut(paragraph, visitor),
SdtContent::Table(table) => visit_table_mut(table, visitor),
SdtContent::Row(row) => visit_row_mut(row, visitor),
SdtContent::Cell(cell) => visit_cell_mut(cell, visitor),
SdtContent::Run(_) | SdtContent::RawXml(_) => {}
SdtContent::ContentControl(nested) => visit_sdt_mut(nested, visitor),
}
}
}
fn update_reciprocal_style_link(
styles: &mut CT_Styles,
style_id: &str,
old_link: Option<&str>,
new_link: Option<&str>,
) {
if old_link != new_link
&& let Some(old_target) = old_link
.and_then(|target_id| styles.styles.iter_mut().find(|s| s.style_id == target_id))
&& old_target.linked_style.as_deref() == Some(style_id)
{
old_target.linked_style = None;
}
if let Some(new_target) =
new_link.and_then(|target_id| styles.styles.iter_mut().find(|s| s.style_id == target_id))
&& new_target
.linked_style
.as_deref()
.is_none_or(|linked| linked == style_id)
{
new_target.linked_style = Some(style_id.to_owned());
}
}
fn merge_style_update(
existing: &rdocx_oxml::styles::CT_Style,
authored: &mut rdocx_oxml::styles::CT_Style,
cleared: u32,
removed_regions: &[rdocx_oxml::styles::TableStyleRegion],
) {
authored.is_default = existing.is_default;
if authored.based_on.is_none() && cleared & style::CLEAR_BASED_ON == 0 {
authored.based_on = existing.based_on.clone();
}
if authored.next_style.is_none() && cleared & style::CLEAR_NEXT_STYLE == 0 {
authored.next_style = existing.next_style.clone();
}
if authored.linked_style.is_none() && cleared & style::CLEAR_LINKED_STYLE == 0 {
authored.linked_style = existing.linked_style.clone();
}
if authored.auto_redefine.is_none() && cleared & style::CLEAR_AUTO_REDEFINE == 0 {
authored.auto_redefine = existing.auto_redefine;
}
if authored.hidden.is_none() && cleared & style::CLEAR_HIDDEN == 0 {
authored.hidden = existing.hidden;
}
if authored.ui_priority.is_none() && cleared & style::CLEAR_PRIORITY == 0 {
authored.ui_priority = existing.ui_priority;
}
if authored.semi_hidden.is_none() && cleared & style::CLEAR_SEMI_HIDDEN == 0 {
authored.semi_hidden = existing.semi_hidden;
}
if authored.unhide_when_used.is_none() && cleared & style::CLEAR_UNHIDE_WHEN_USED == 0 {
authored.unhide_when_used = existing.unhide_when_used;
}
if authored.quick_format.is_none() && cleared & style::CLEAR_QUICK_FORMAT == 0 {
authored.quick_format = existing.quick_format;
}
if authored.locked.is_none() && cleared & style::CLEAR_LOCKED == 0 {
authored.locked = existing.locked;
}
if let Some(properties) = &authored.ppr {
if cleared & style::REPLACE_PARAGRAPH_PROPERTIES == 0 {
let mut updated = existing.ppr.clone().unwrap_or_default();
updated.merge_from(properties);
authored.ppr = Some(updated);
}
} else if cleared & style::CLEAR_PARAGRAPH_PROPERTIES == 0 {
authored.ppr = existing.ppr.clone();
}
if let Some(properties) = &authored.rpr {
let mut updated = existing.rpr.clone().unwrap_or_default();
updated.merge_from(properties);
if cleared & style::CLEAR_FONT_THEME != 0 {
updated.font_ascii_theme = None;
updated.font_hansi_theme = None;
updated.font_east_asia_theme = None;
updated.font_cs_theme = None;
}
if cleared & style::CLEAR_COLOR_THEME != 0 {
updated.color_theme = None;
updated.color_theme_tint = None;
updated.color_theme_shade = None;
}
authored.rpr = Some(updated);
} else if cleared & style::CLEAR_RUN_PROPERTIES == 0 {
authored.rpr = existing.rpr.clone();
}
authored.extra_xml = existing.extra_xml.clone();
authored.extra_attributes = existing.extra_attributes.clone();
authored.modeled_xml = existing.modeled_xml.clone();
if let Some(properties) = &authored.table_properties {
authored.table_properties = Some(merge_table_style_properties(
existing.table_properties.as_ref(),
properties,
));
} else if cleared & style::CLEAR_TABLE_PROPERTIES == 0 {
authored.table_properties = existing.table_properties.clone();
}
authored.table_properties_original = existing.table_properties_original.clone();
authored.table_properties_xml = existing.table_properties_xml.clone();
if authored.table_row_properties.is_none() && cleared & style::CLEAR_TABLE_ROW_PROPERTIES == 0 {
authored.table_row_properties = existing.table_row_properties.clone();
}
if authored.table_cell_properties.is_none() && cleared & style::CLEAR_TABLE_CELL_PROPERTIES == 0
{
authored.table_cell_properties = existing.table_cell_properties.clone();
}
let authored_regions = std::mem::take(&mut authored.conditional_table_styles);
let mut merged_regions = if cleared & style::CLEAR_CONDITIONAL_TABLE_STYLES == 0 {
existing.conditional_table_styles.clone()
} else {
Vec::new()
};
merged_regions.retain(|conditional| {
conditional
.region
.is_none_or(|region| !removed_regions.contains(®ion))
});
let mut authored_region_names = std::collections::HashSet::new();
for authored_region in authored_regions {
if !authored_region_names.insert(authored_region.region) {
merged_regions.push(authored_region);
continue;
}
if let Some(index) = merged_regions
.iter()
.position(|current| current.region == authored_region.region)
{
merged_regions[index] =
merge_conditional_table_style(&merged_regions[index], &authored_region);
} else {
merged_regions.push(authored_region);
}
}
authored.conditional_table_styles = merged_regions;
}
fn merge_conditional_table_style(
existing: &rdocx_oxml::styles::CT_TblStylePr,
authored: &rdocx_oxml::styles::CT_TblStylePr,
) -> rdocx_oxml::styles::CT_TblStylePr {
let mut updated = existing.clone();
if let Some(properties) = &authored.paragraph_properties {
let target = updated.paragraph_properties.get_or_insert_default();
target.merge_from(properties);
}
if let Some(properties) = &authored.run_properties {
let target = updated.run_properties.get_or_insert_default();
target.merge_from(properties);
}
if let Some(properties) = &authored.table_properties {
updated.table_properties = Some(merge_table_style_properties(
updated.table_properties.as_ref(),
properties,
));
}
if let Some(properties) = &authored.row_properties {
// A row layer has no typed merge of its own, so an authored row layer
// replaces the existing one outright.
updated.row_properties = Some(properties.clone());
}
if let Some(properties) = &authored.cell_properties {
updated.cell_properties = Some(merge_table_cell_style_properties(
updated.cell_properties.as_ref(),
properties,
));
}
updated
}
fn merge_table_borders(
existing: Option<&rdocx_oxml::table::CT_TblBorders>,
authored: &rdocx_oxml::table::CT_TblBorders,
) -> rdocx_oxml::table::CT_TblBorders {
let mut updated = existing.cloned().unwrap_or_default();
if authored.top.is_some() {
updated.top.clone_from(&authored.top);
}
if authored.bottom.is_some() {
updated.bottom.clone_from(&authored.bottom);
}
if authored.left.is_some() {
updated.left.clone_from(&authored.left);
}
if authored.right.is_some() {
updated.right.clone_from(&authored.right);
}
if authored.inside_h.is_some() {
updated.inside_h.clone_from(&authored.inside_h);
}
if authored.inside_v.is_some() {
updated.inside_v.clone_from(&authored.inside_v);
}
for raw in &authored.extra_xml {
if !updated.extra_xml.contains(raw) {
updated.extra_xml.push(raw.clone());
}
}
updated
}
fn merge_table_cell_style_properties(
existing: Option<&rdocx_oxml::table::CT_TcPr>,
authored: &rdocx_oxml::table::CT_TcPr,
) -> rdocx_oxml::table::CT_TcPr {
let mut updated = existing.cloned().unwrap_or_default();
if authored.width.is_some() {
updated.width.clone_from(&authored.width);
}
if authored.grid_span.is_some() {
updated.grid_span = authored.grid_span;
}
if authored.h_merge.is_some() {
updated.h_merge.clone_from(&authored.h_merge);
}
if authored.v_merge.is_some() {
updated.v_merge = authored.v_merge;
}
if let Some(borders) = &authored.borders {
updated.borders = Some(merge_table_borders(updated.borders.as_ref(), borders));
}
if authored.shading.is_some() {
updated.shading.clone_from(&authored.shading);
}
if authored.v_align.is_some() {
updated.v_align = authored.v_align;
}
if authored.no_wrap.is_some() {
updated.no_wrap = authored.no_wrap;
}
if authored.text_direction.is_some() {
updated.text_direction.clone_from(&authored.text_direction);
}
if authored.cnf_style.is_some() {
updated.cnf_style.clone_from(&authored.cnf_style);
}
for raw in &authored.extra_xml {
if !updated.extra_xml.contains(raw) {
updated.extra_xml.push(raw.clone());
}
}
updated
}
fn merge_table_style_properties(
existing: Option<&rdocx_oxml::table::CT_TblPr>,
authored: &rdocx_oxml::table::CT_TblPr,
) -> rdocx_oxml::table::CT_TblPr {
let mut updated = existing.cloned().unwrap_or_default();
if authored.style_id.is_some() {
updated.style_id.clone_from(&authored.style_id);
}
if authored.row_band_size.is_some() {
updated.row_band_size = authored.row_band_size;
}
if authored.column_band_size.is_some() {
updated.column_band_size = authored.column_band_size;
}
if authored.width.is_some() {
updated.width.clone_from(&authored.width);
}
if authored.jc.is_some() {
updated.jc = authored.jc;
}
if let Some(borders) = &authored.borders {
updated.borders = Some(merge_table_borders(updated.borders.as_ref(), borders));
}
if let Some(margins) = &authored.cell_margin {
let target = updated.cell_margin.get_or_insert_default();
if margins.top.is_some() {
target.top = margins.top;
}
if margins.bottom.is_some() {
target.bottom = margins.bottom;
}
if margins.left.is_some() {
target.left = margins.left;
}
if margins.right.is_some() {
target.right = margins.right;
}
}
if authored.layout.is_some() {
updated.layout.clone_from(&authored.layout);
}
if authored.indent.is_some() {
updated.indent.clone_from(&authored.indent);
}
if authored.shading.is_some() {
updated.shading.clone_from(&authored.shading);
}
if let Some(look) = &authored.look {
let target = updated.look.get_or_insert_default();
if look.val.is_some() {
target.val.clone_from(&look.val);
}
if look.first_row.is_some() {
target.first_row = look.first_row;
}
if look.last_row.is_some() {
target.last_row = look.last_row;
}
if look.first_column.is_some() {
target.first_column = look.first_column;
}
if look.last_column.is_some() {
target.last_column = look.last_column;
}
if look.no_h_band.is_some() {
target.no_h_band = look.no_h_band;
}
if look.no_v_band.is_some() {
target.no_v_band = look.no_v_band;
}
}
if authored.change.is_some() {
updated.change.clone_from(&authored.change);
}
for raw in &authored.revision_xml {
if !updated.revision_xml.contains(raw) {
updated.revision_xml.push(raw.clone());
}
}
for raw in &authored.extra_xml {
if !updated.extra_xml.contains(raw) {
updated.extra_xml.push(raw.clone());
}
}
updated
}
fn body_references_style(content: &[BodyContent], style_id: &str) -> bool {
content.iter().any(|item| match item {
BodyContent::Paragraph(paragraph) => paragraph_references_style(paragraph, style_id),
BodyContent::Table(table) => table_references_style(table, style_id),
BodyContent::ContentControl(control) => control_references_style(control, style_id),
BodyContent::RawXml(_) => false,
})
}
fn paragraph_references_style(paragraph: &CT_P, style_id: &str) -> bool {
paragraph
.properties
.as_ref()
.and_then(|properties| properties.style_id.as_deref())
== Some(style_id)
|| paragraph.runs.iter().any(|run| {
run.properties
.as_ref()
.and_then(|properties| properties.style_id.as_deref())
== Some(style_id)
})
|| paragraph
.content_controls
.iter()
.any(|(_, _, _, control)| control_references_style(control, style_id))
}
fn table_references_style(table: &CT_Tbl, style_id: &str) -> bool {
table
.properties
.as_ref()
.and_then(|properties| properties.style_id.as_deref())
== Some(style_id)
|| table.rows.iter().any(|row| {
row.cells.iter().any(|cell| {
cell.content.iter().any(|item| match item {
CellContent::Paragraph(paragraph) => {
paragraph_references_style(paragraph, style_id)
}
CellContent::Table(table) => table_references_style(table, style_id),
CellContent::ContentControl(control) => {
control_references_style(control, style_id)
}
})
})
})
|| table
.content_controls
.iter()
.any(|(_, _, control)| control_references_style(control, style_id))
}
fn control_references_style(control: &CT_Sdt, style_id: &str) -> bool {
control.content.iter().any(|item| match item {
SdtContent::Paragraph(paragraph) => paragraph_references_style(paragraph, style_id),
SdtContent::Table(table) => table_references_style(table, style_id),
SdtContent::Row(row) => row.cells.iter().any(|cell| {
cell.content.iter().any(|item| match item {
CellContent::Paragraph(paragraph) => {
paragraph_references_style(paragraph, style_id)
}
CellContent::Table(table) => table_references_style(table, style_id),
CellContent::ContentControl(control) => control_references_style(control, style_id),
})
}),
SdtContent::Cell(cell) => cell.content.iter().any(|item| match item {
CellContent::Paragraph(paragraph) => paragraph_references_style(paragraph, style_id),
CellContent::Table(table) => table_references_style(table, style_id),
CellContent::ContentControl(control) => control_references_style(control, style_id),
}),
SdtContent::Run(run) => {
run.properties
.as_ref()
.and_then(|properties| properties.style_id.as_deref())
== Some(style_id)
}
SdtContent::ContentControl(nested) => control_references_style(nested, style_id),
SdtContent::RawXml(_) => false,
})
}
fn xml_references_style(xml: &[u8], style_id: &str) -> Result<bool> {
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
loop {
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(error.to_string()))?;
match event {
Event::Start(ref element) | Event::Empty(ref element)
if matches!(namespace, ResolveResult::Bound(Namespace(uri)) if uri == WORD_NAMESPACE.as_bytes())
&& matches!(
element.local_name().as_ref(),
b"pStyle" | b"rStyle" | b"tblStyle"
) =>
{
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| Error::Other(error.to_string()))?;
let (namespace, local) = reader.resolver().resolve_attribute(attribute.key);
if matches!(namespace, ResolveResult::Bound(value) if value.as_ref() == WORD_NAMESPACE.as_bytes())
&& local.as_ref() == b"val"
&& attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|error| Error::Other(error.to_string()))?
.as_ref()
== style_id
{
return Ok(true);
}
}
}
Event::Eof => return Ok(false),
_ => {}
}
buffer.clear();
}
}
fn collect_relationship_ids(content: &[BodyContent], output: &mut Vec<String>) {
for item in content {
match item {
BodyContent::Paragraph(paragraph) => {
collect_paragraph_relationship_ids(paragraph, output)
}
BodyContent::Table(table) => collect_table_relationship_ids(table, output),
BodyContent::ContentControl(control) => collect_sdt_relationship_ids(control, output),
BodyContent::RawXml(_) => {}
}
}
}
fn collect_paragraph_relationship_ids(paragraph: &CT_P, output: &mut Vec<String>) {
if let Some(section) = paragraph
.properties
.as_ref()
.and_then(|properties| properties.sect_pr.as_ref())
{
for reference in section.header_refs.iter().chain(§ion.footer_refs) {
if !output.contains(&reference.rel_id) {
output.push(reference.rel_id.clone());
}
}
}
for index in 0..=paragraph.runs.len() {
for (_, _, _, control) in paragraph
.content_controls
.iter()
.filter(|(at, _, _, _)| *at == index)
{
collect_sdt_relationship_ids(control, output);
}
for hyperlink in paragraph
.hyperlinks
.iter()
.filter(|hyperlink| hyperlink.run_start == index)
{
if let Some(id) = &hyperlink.rel_id
&& !output.contains(id)
{
output.push(id.clone());
}
}
let Some(run) = paragraph.runs.get(index) else {
continue;
};
for content in &run.content {
let RunContent::Drawing(drawing) = content else {
continue;
};
let id = drawing
.inline
.as_ref()
.filter(|drawing| drawing.raw_xml.is_none())
.map(|drawing| drawing.chart_rel_id.as_ref().unwrap_or(&drawing.embed_id))
.or_else(|| {
drawing
.anchor
.as_ref()
.filter(|drawing| drawing.raw_xml.is_none())
.map(|drawing| drawing.chart_rel_id.as_ref().unwrap_or(&drawing.embed_id))
});
if let Some(id) = id
&& !output.contains(id)
{
output.push(id.clone());
}
}
}
}
fn collect_table_relationship_ids(table: &CT_Tbl, output: &mut Vec<String>) {
for index in 0..=table.rows.len() {
for (_, _, control) in table
.content_controls
.iter()
.filter(|(at, _, _)| *at == index)
{
collect_sdt_relationship_ids(control, output);
}
if let Some(row) = table.rows.get(index) {
for cell_index in 0..=row.cells.len() {
for (_, _, control) in row
.content_controls
.iter()
.filter(|(at, _, _)| *at == cell_index)
{
collect_sdt_relationship_ids(control, output);
}
if let Some(cell) = row.cells.get(cell_index) {
for item in &cell.content {
match item {
CellContent::Paragraph(paragraph) => {
collect_paragraph_relationship_ids(paragraph, output)
}
CellContent::Table(table) => {
collect_table_relationship_ids(table, output)
}
CellContent::ContentControl(control) => {
collect_sdt_relationship_ids(control, output)
}
}
}
}
}
}
}
}
fn collect_sdt_relationship_ids(control: &CT_Sdt, output: &mut Vec<String>) {
for item in &control.content {
match item {
SdtContent::Paragraph(paragraph) => {
collect_paragraph_relationship_ids(paragraph, output)
}
SdtContent::Table(table) => collect_table_relationship_ids(table, output),
SdtContent::Row(row) => {
let table = CT_Tbl {
properties: None,
grid: None,
rows: vec![row.clone()],
extra_xml: Vec::new(),
content_controls: Vec::new(),
};
collect_table_relationship_ids(&table, output);
}
SdtContent::Cell(cell) => {
for item in &cell.content {
match item {
CellContent::Paragraph(paragraph) => {
collect_paragraph_relationship_ids(paragraph, output)
}
CellContent::Table(table) => collect_table_relationship_ids(table, output),
CellContent::ContentControl(control) => {
collect_sdt_relationship_ids(control, output)
}
}
}
}
SdtContent::ContentControl(nested) => collect_sdt_relationship_ids(nested, output),
SdtContent::Run(run) => {
for content in &run.content {
if let RunContent::Drawing(drawing) = content
&& let Some(id) = drawing
.inline
.as_ref()
.filter(|drawing| drawing.raw_xml.is_none())
.map(|drawing| {
drawing.chart_rel_id.as_ref().unwrap_or(&drawing.embed_id)
})
.or_else(|| {
drawing
.anchor
.as_ref()
.filter(|drawing| drawing.raw_xml.is_none())
.map(|drawing| {
drawing.chart_rel_id.as_ref().unwrap_or(&drawing.embed_id)
})
})
&& !output.contains(id)
{
output.push(id.clone());
}
}
}
SdtContent::RawXml(_) => {}
}
}
}
/// A body child as the ODT and RTF writers export it.
pub(crate) enum ExportItem<'a> {
Paragraph(&'a CT_P),
/// A table, with the rows, cells and cell content that content controls
/// wrap in place, see [`unwrap_table_controls`].
Table(Box<Cow<'a, CT_Tbl>>),
/// A block content control. What it wraps follows it.
ContentControl,
RawXml,
}
/// The body children in document order with their source paths, `body[i]`.
/// A block content control is transparent: what it wraps follows it at
/// `{its path}/content[k]`, nested controls included.
pub(crate) fn body_export_items(content: &[BodyContent]) -> Vec<(ExportItem<'_>, String)> {
let mut items = Vec::new();
for (index, item) in content.iter().enumerate() {
let path = format!("body[{index}]");
match item {
BodyContent::Paragraph(paragraph) => {
items.push((ExportItem::Paragraph(paragraph), path))
}
BodyContent::Table(table) => items.push((
ExportItem::Table(Box::new(unwrap_table_controls(table))),
path,
)),
BodyContent::ContentControl(control) => {
push_control_export_items(control, path, &mut items)
}
BodyContent::RawXml(_) => items.push((ExportItem::RawXml, path)),
}
}
items
}
fn push_control_export_items<'a>(
control: &'a CT_Sdt,
path: String,
items: &mut Vec<(ExportItem<'a>, String)>,
) {
items.push((ExportItem::ContentControl, path.clone()));
for (index, item) in control.content.iter().enumerate() {
let path = format!("{path}/content[{index}]");
match item {
SdtContent::Paragraph(paragraph) => {
items.push((ExportItem::Paragraph(paragraph), path))
}
SdtContent::Table(table) => items.push((
ExportItem::Table(Box::new(unwrap_table_controls(table))),
path,
)),
SdtContent::ContentControl(nested) => push_control_export_items(nested, path, items),
// A control around a paragraph or a table holds no rows, cells or
// runs of its own.
SdtContent::Row(_) | SdtContent::Cell(_) | SdtContent::Run(_) => {}
SdtContent::RawXml(_) => items.push((ExportItem::RawXml, path)),
}
}
}
/// Whether a table, not counting the tables nested in its cells, holds a
/// content control around a row, a cell or cell content.
fn table_has_content_controls(table: &CT_Tbl) -> bool {
!table.content_controls.is_empty()
|| table.rows.iter().any(|row| {
!row.content_controls.is_empty()
|| row.cells.iter().any(|cell| {
cell.content
.iter()
.any(|content| matches!(content, CellContent::ContentControl(_)))
})
})
}
/// The table with the rows, cells and cell content that its content controls
/// wrap in place of the controls, nested ones included. Borrowed when the
/// table has no such control.
pub(crate) fn unwrap_table_controls(table: &CT_Tbl) -> Cow<'_, CT_Tbl> {
if !table_has_content_controls(table) {
return Cow::Borrowed(table);
}
let rows = table
.rows()
.into_iter()
.map(|row| CT_Row {
table_property_exception: row.table_property_exception.clone(),
properties: row.properties.clone(),
cells: row
.cells()
.into_iter()
.map(|cell| {
let mut content = Vec::with_capacity(cell.content.len());
for item in &cell.content {
match item {
CellContent::ContentControl(control) => {
push_control_cell_content(control, &mut content)
}
item => content.push(item.clone()),
}
}
CT_Tc {
properties: cell.properties.clone(),
content,
extra_xml: cell.extra_xml.clone(),
}
})
.collect(),
extra_xml: row.extra_xml.clone(),
content_controls: Vec::new(),
})
.collect();
Cow::Owned(CT_Tbl {
properties: table.properties.clone(),
grid: table.grid.clone(),
rows,
extra_xml: table.extra_xml.clone(),
content_controls: Vec::new(),
})
}
fn push_control_cell_content(control: &CT_Sdt, content: &mut Vec<CellContent>) {
for item in &control.content {
match item {
SdtContent::Paragraph(paragraph) => {
content.push(CellContent::Paragraph(paragraph.clone()))
}
SdtContent::Table(table) => content.push(CellContent::Table(table.clone())),
SdtContent::ContentControl(nested) => push_control_cell_content(nested, content),
SdtContent::Row(_)
| SdtContent::Cell(_)
| SdtContent::Run(_)
| SdtContent::RawXml(_) => {}
}
}
}
/// Visit the drawings the exporters write, in the order they write them: those
/// of the accepted view of every paragraph (see `CT_P::accepted_view`), in
/// the paragraphs [`visit_body_paragraphs`] reaches.
pub(crate) fn visit_accepted_drawings(
content: &[BodyContent],
visitor: &mut impl FnMut(&CT_Drawing),
) {
visit_body_paragraphs(content, true, &mut |paragraph| {
for run in ¶graph.accepted_view().runs {
visit_run_drawings(run, visitor);
}
});
}
pub(crate) fn visit_all_drawings(content: &[BodyContent], visitor: &mut impl FnMut(&CT_Drawing)) {
for item in content {
match item {
BodyContent::Paragraph(paragraph) => visit_paragraph_drawings(paragraph, visitor),
BodyContent::Table(table) => visit_table_drawings(table, visitor),
BodyContent::ContentControl(control) => visit_sdt_drawings(control, visitor),
BodyContent::RawXml(_) => {}
}
}
}
pub(crate) fn visit_all_drawings_mut(
content: &mut [BodyContent],
visitor: &mut impl FnMut(&mut CT_Drawing),
) {
for item in content {
match item {
BodyContent::Paragraph(paragraph) => visit_paragraph_drawings_mut(paragraph, visitor),
BodyContent::Table(table) => visit_table_drawings_mut(table, visitor),
BodyContent::ContentControl(control) => visit_sdt_drawings_mut(control, visitor),
BodyContent::RawXml(_) => {}
}
}
}
fn visit_authored_drawings(content: &[BodyContent], visitor: &mut impl FnMut(&CT_Drawing)) {
visit_all_drawings(content, &mut |drawing| {
if authored_drawing(drawing) {
visitor(drawing);
}
});
}
fn visit_authored_drawings_mut(
content: &mut [BodyContent],
visitor: &mut impl FnMut(&mut CT_Drawing),
) {
visit_all_drawings_mut(content, &mut |drawing| {
if authored_drawing(drawing) {
visitor(drawing);
}
});
}
fn close_typed_story_drawing_namespaces(paragraph: &mut CT_P) -> Result<()> {
let scope = BTreeMap::from([
("r".to_owned(), drawing_ns::R.to_owned()),
("wp".to_owned(), drawing_ns::WP.to_owned()),
]);
let mut error = None;
visit_paragraph_drawings_mut(paragraph, &mut |drawing| {
if error.is_some() || !authored_drawing(drawing) {
return;
}
if let Some(inline) = &mut drawing.inline {
let mut writer = Writer::new(Vec::new());
if let Err(current) = inline.to_xml(&mut writer).map_err(Error::from) {
error = Some(current);
return;
}
match close_content_fragment_namespaces(&writer.into_inner(), &scope) {
Ok(xml) => inline.raw_xml = Some(xml),
Err(current) => error = Some(current),
}
} else if let Some(anchor) = &mut drawing.anchor {
let mut writer = Writer::new(Vec::new());
if let Err(current) = anchor.to_xml(&mut writer).map_err(Error::from) {
error = Some(current);
return;
}
match close_content_fragment_namespaces(&writer.into_inner(), &scope) {
Ok(xml) => anchor.raw_xml = Some(xml),
Err(current) => error = Some(current),
}
}
});
error.map_or(Ok(()), Err)
}
fn authored_drawing(drawing: &CT_Drawing) -> bool {
drawing
.inline
.as_ref()
.is_some_and(|value| value.raw_xml.is_none())
|| drawing
.anchor
.as_ref()
.is_some_and(|value| value.raw_xml.is_none())
}
fn visit_run_drawings(run: &CT_R, visitor: &mut impl FnMut(&CT_Drawing)) {
for content in &run.content {
if let RunContent::Drawing(drawing) = content {
visitor(drawing);
}
}
}
fn visit_run_drawings_mut(run: &mut CT_R, visitor: &mut impl FnMut(&mut CT_Drawing)) {
for content in &mut run.content {
if let RunContent::Drawing(drawing) = content {
visitor(drawing);
}
}
}
fn visit_paragraph_drawings(paragraph: &CT_P, visitor: &mut impl FnMut(&CT_Drawing)) {
for index in 0..=paragraph.runs.len() {
for (_, _, _, control) in paragraph
.content_controls
.iter()
.filter(|(at, _, _, _)| *at == index)
{
visit_sdt_drawings(control, visitor);
}
if let Some(run) = paragraph.runs.get(index) {
visit_run_drawings(run, visitor);
}
}
}
fn visit_paragraph_drawings_mut(paragraph: &mut CT_P, visitor: &mut impl FnMut(&mut CT_Drawing)) {
for index in 0..=paragraph.runs.len() {
for (_, _, _, control) in paragraph
.content_controls
.iter_mut()
.filter(|(at, _, _, _)| *at == index)
{
visit_sdt_drawings_mut(control, visitor);
}
if let Some(run) = paragraph.runs.get_mut(index) {
visit_run_drawings_mut(run, visitor);
}
}
}
fn visit_table_drawings(table: &CT_Tbl, visitor: &mut impl FnMut(&CT_Drawing)) {
for index in 0..=table.rows.len() {
for (_, _, control) in table
.content_controls
.iter()
.filter(|(at, _, _)| *at == index)
{
visit_sdt_drawings(control, visitor);
}
if let Some(row) = table.rows.get(index) {
visit_row_drawings(row, visitor);
}
}
}
fn visit_table_drawings_mut(table: &mut CT_Tbl, visitor: &mut impl FnMut(&mut CT_Drawing)) {
for index in 0..=table.rows.len() {
for (_, _, control) in table
.content_controls
.iter_mut()
.filter(|(at, _, _)| *at == index)
{
visit_sdt_drawings_mut(control, visitor);
}
if let Some(row) = table.rows.get_mut(index) {
visit_row_drawings_mut(row, visitor);
}
}
}
fn visit_row_drawings(row: &CT_Row, visitor: &mut impl FnMut(&CT_Drawing)) {
for index in 0..=row.cells.len() {
for (_, _, control) in row
.content_controls
.iter()
.filter(|(at, _, _)| *at == index)
{
visit_sdt_drawings(control, visitor);
}
if let Some(cell) = row.cells.get(index) {
visit_cell_drawings(cell, visitor);
}
}
}
fn visit_row_drawings_mut(row: &mut CT_Row, visitor: &mut impl FnMut(&mut CT_Drawing)) {
for index in 0..=row.cells.len() {
for (_, _, control) in row
.content_controls
.iter_mut()
.filter(|(at, _, _)| *at == index)
{
visit_sdt_drawings_mut(control, visitor);
}
if let Some(cell) = row.cells.get_mut(index) {
visit_cell_drawings_mut(cell, visitor);
}
}
}
fn visit_cell_drawings(cell: &CT_Tc, visitor: &mut impl FnMut(&CT_Drawing)) {
for item in &cell.content {
match item {
CellContent::Paragraph(paragraph) => visit_paragraph_drawings(paragraph, visitor),
CellContent::Table(table) => visit_table_drawings(table, visitor),
CellContent::ContentControl(control) => visit_sdt_drawings(control, visitor),
}
}
}
fn visit_cell_drawings_mut(cell: &mut CT_Tc, visitor: &mut impl FnMut(&mut CT_Drawing)) {
for item in &mut cell.content {
match item {
CellContent::Paragraph(paragraph) => visit_paragraph_drawings_mut(paragraph, visitor),
CellContent::Table(table) => visit_table_drawings_mut(table, visitor),
CellContent::ContentControl(control) => visit_sdt_drawings_mut(control, visitor),
}
}
}
fn visit_sdt_drawings(control: &CT_Sdt, visitor: &mut impl FnMut(&CT_Drawing)) {
for item in &control.content {
match item {
SdtContent::Paragraph(paragraph) => visit_paragraph_drawings(paragraph, visitor),
SdtContent::Table(table) => visit_table_drawings(table, visitor),
SdtContent::Row(row) => visit_row_drawings(row, visitor),
SdtContent::Cell(cell) => visit_cell_drawings(cell, visitor),
SdtContent::Run(run) => visit_run_drawings(run, visitor),
SdtContent::ContentControl(nested) => visit_sdt_drawings(nested, visitor),
SdtContent::RawXml(_) => {}
}
}
}
fn visit_sdt_drawings_mut(control: &mut CT_Sdt, visitor: &mut impl FnMut(&mut CT_Drawing)) {
for item in &mut control.content {
match item {
SdtContent::Paragraph(paragraph) => visit_paragraph_drawings_mut(paragraph, visitor),
SdtContent::Table(table) => visit_table_drawings_mut(table, visitor),
SdtContent::Row(row) => visit_row_drawings_mut(row, visitor),
SdtContent::Cell(cell) => visit_cell_drawings_mut(cell, visitor),
SdtContent::Run(run) => visit_run_drawings_mut(run, visitor),
SdtContent::ContentControl(nested) => visit_sdt_drawings_mut(nested, visitor),
SdtContent::RawXml(_) => {}
}
}
}
impl Document {
/// Create a new Word-compatible DOCX document with default page setup and styles.
pub fn new() -> Self {
Self::new_with_profile(WordCreationProfile::WordCompatible(
WordPackageClass::Document,
))
}
/// Create a new document with explicit package completeness and class.
pub fn new_with_profile(profile: WordCreationProfile) -> Self {
let document = CT_Document::new();
let (class, compatible) = match profile {
WordCreationProfile::Minimal(class) => (class, false),
WordCreationProfile::WordCompatible(class) => (class, true),
};
let mut styles = CT_Styles::new_default();
if compatible {
styles.styles.extend(style::common_styles());
}
// Word writes compatibility mode 15 into every document it creates.
// A document that names no mode opens in Compatibility Mode and lays
// out under the legacy rules (see `build_layout_input`).
let settings = compatible.then(|| {
let mut settings = CT_Settings::new();
settings
.set_compatibility_setting(CompatibilitySetting {
name: "compatibilityMode".to_owned(),
uri: "http://schemas.microsoft.com/office/word".to_owned(),
value: "15".to_owned(),
})
.expect("fresh Word settings accept a compatibility setting");
settings
});
let core_properties = compatible.then(Box::<CoreProperties>::default);
let application_properties = compatible.then(default_application_properties);
let mut package = if compatible {
new_word_compatible_package(
class,
&document,
&styles,
settings.as_ref().expect("compatible settings exist"),
core_properties
.as_deref()
.expect("compatible core properties exist"),
application_properties
.as_ref()
.expect("compatible application properties exist"),
)
} else {
new_word_package(class)
};
if compatible {
validate_fresh_word_compatible_package(&package, class)
.expect("fresh Word-compatible package graph must validate");
}
if !compatible {
package
.get_or_create_part_rels("/word/document.xml")
.add(rel_types::STYLES, "styles.xml");
}
let identifiers = DocumentIdentifiers::scan(&package)
.expect("a freshly constructed package has valid identifiers");
Document {
package,
document,
root_namespace_declarations: Vec::new(),
body_namespace_declarations: Vec::new(),
body_namespace_bindings: Vec::new(),
styles,
numbering: None,
core_properties,
application_properties,
custom_properties: None,
core_properties_part_name: compatible.then(|| DEFAULT_CORE_PROPERTIES_PART.to_owned()),
application_properties_part_name: compatible
.then(|| DEFAULT_APP_PROPERTIES_PART.to_owned()),
custom_properties_part_name: None,
custom_properties_owned: false,
doc_part_name: "/word/document.xml".to_string(),
styles_part_name: Some(DEFAULT_STYLES_PART.to_owned()),
numbering_part_name: None,
settings,
settings_part_name: compatible.then(|| DEFAULT_SETTINGS_PART.to_owned()),
settings_owned: false,
// A fresh package gains no web settings part. An ordinary save
// never creates a part the document did not already have.
web_settings: None,
web_settings_part_name: None,
web_settings_owned: false,
theme: compatible.then(oxml_drawing::theme::CT_OfficeStyleSheet::office_default),
theme_part_name: compatible.then(|| DEFAULT_THEME_PART.to_owned()),
theme_dirty: false,
font_table: compatible.then(|| {
FontTable::from_xml(DEFAULT_FONT_TABLE_XML.as_bytes())
.expect("fresh Word font table must parse")
}),
font_table_part_name: compatible.then(|| DEFAULT_FONT_TABLE_PART.to_owned()),
font_table_dirty: false,
identifiers,
footnotes: rdocx_oxml::footnotes::CT_Footnotes::new(),
footnotes_part_name: None,
footnotes_root_namespace_declarations: Vec::new(),
footnotes_dirty: false,
comments: None,
comments_part_name: None,
comments_dirty: false,
comments_extended: None,
comments_extended_part_name: None,
comments_owned: false,
comments_extended_owned: false,
embedded_invalidated_signatures: HashSet::new(),
package_signatures_invalidated: false,
glossary: None,
glossary_part_name: None,
glossary_dirty: false,
layout_cache: Mutex::new(None),
normal_layout_engine: Mutex::new(None),
deterministic_layout_cache: Mutex::new(None),
bundled_fallback_layout_engine: Mutex::new(None),
}
}
/// Admit each loaded recursive owner before staging clones it.
fn validate_model_resource_limits(&self) -> Result<()> {
self.document.validate_resource_limits()?;
for note in &self.footnotes.footnotes {
for paragraph in ¬e.paragraphs {
paragraph.validate_resource_limits()?;
}
}
if let Some(comments) = &self.comments {
for comment in &comments.comments {
for paragraph in &comment.paragraphs {
paragraph.validate_resource_limits()?;
}
}
}
if let Some(glossary) = &self.glossary {
glossary.validate_resource_limits()?;
}
// Header/footer and endnote parts are source-owned OPC bytes here,
// not recursive cached models. Their independent parsers and writers
// admit owners when those parts are projected or edited.
Ok(())
}
/// Clone all package and typed state while discarding derived layout caches.
pub fn clone_for_staging(&self) -> Self {
Self {
package: self.package.clone(),
document: self.document.clone(),
root_namespace_declarations: self.root_namespace_declarations.clone(),
body_namespace_declarations: self.body_namespace_declarations.clone(),
body_namespace_bindings: self.body_namespace_bindings.clone(),
styles: self.styles.clone(),
numbering: self.numbering.clone(),
core_properties: self.core_properties.clone(),
application_properties: self.application_properties.clone(),
custom_properties: self.custom_properties.clone(),
core_properties_part_name: self.core_properties_part_name.clone(),
application_properties_part_name: self.application_properties_part_name.clone(),
custom_properties_part_name: self.custom_properties_part_name.clone(),
custom_properties_owned: self.custom_properties_owned,
doc_part_name: self.doc_part_name.clone(),
styles_part_name: self.styles_part_name.clone(),
numbering_part_name: self.numbering_part_name.clone(),
settings: self.settings.clone(),
settings_part_name: self.settings_part_name.clone(),
settings_owned: self.settings_owned,
web_settings: self.web_settings.clone(),
web_settings_part_name: self.web_settings_part_name.clone(),
web_settings_owned: self.web_settings_owned,
theme: self.theme.clone(),
theme_part_name: self.theme_part_name.clone(),
theme_dirty: self.theme_dirty,
font_table: self.font_table.clone(),
font_table_part_name: self.font_table_part_name.clone(),
font_table_dirty: self.font_table_dirty,
identifiers: self.identifiers.clone(),
footnotes: self.footnotes.clone(),
footnotes_part_name: self.footnotes_part_name.clone(),
footnotes_root_namespace_declarations: self
.footnotes_root_namespace_declarations
.clone(),
footnotes_dirty: self.footnotes_dirty,
comments: self.comments.clone(),
comments_part_name: self.comments_part_name.clone(),
comments_dirty: self.comments_dirty,
comments_extended: self.comments_extended.clone(),
comments_extended_part_name: self.comments_extended_part_name.clone(),
comments_owned: self.comments_owned,
comments_extended_owned: self.comments_extended_owned,
embedded_invalidated_signatures: self.embedded_invalidated_signatures.clone(),
package_signatures_invalidated: self.package_signatures_invalidated,
glossary: self.glossary.clone(),
glossary_part_name: self.glossary_part_name.clone(),
glossary_dirty: self.glossary_dirty,
layout_cache: Mutex::new(None),
normal_layout_engine: Mutex::new(None),
deterministic_layout_cache: Mutex::new(None),
bundled_fallback_layout_engine: Mutex::new(None),
}
}
fn canonicalize_typed_identifiers(&mut self) -> Result<()> {
self.canonicalize_bookmark_ids()?;
self.canonicalize_numbering_ids()?;
Ok(())
}
fn canonicalize_bookmark_ids(&mut self) -> Result<()> {
let mut semantic = Vec::new();
visit_body_paragraphs(&self.document.body.content, false, &mut |paragraph| {
for marker in ¶graph.bookmark_markers {
if marker.is_start()
&& let Some(id) = marker.id()
&& self.identifiers.authored_bookmark_ids.contains(&id)
&& !semantic.contains(&id)
{
semantic.push(id);
}
}
});
let mut occupied = self.identifiers.preserved_bookmark_ids.clone();
let mut remap = HashMap::new();
for old in semantic {
remap.insert(old, reserve_lowest_i32(&mut occupied, 0, "bookmark")?);
}
if !remap.is_empty() {
visit_body_paragraphs_mut(&mut self.document.body.content, &mut |paragraph| {
let _ = paragraph.remap_authored_bookmark_ids(&remap);
});
for old in remap.keys() {
self.identifiers.authored_bookmark_ids.remove(old);
}
self.identifiers
.authored_bookmark_ids
.extend(remap.values());
}
self.canonicalize_toc_bookmark_ids()?;
Ok(())
}
fn canonicalize_toc_bookmark_ids(&mut self) -> Result<()> {
let mut semantic = Vec::new();
visit_body_paragraphs(&self.document.body.content, false, &mut |paragraph| {
for marker in ¶graph.bookmark_markers {
if marker.is_start()
&& let Some(id) = marker.id()
&& self.identifiers.authored_toc_bookmark_ids.contains(&id)
&& !semantic.contains(&id)
{
semantic.push(id);
}
}
});
if semantic.is_empty() {
return Ok(());
}
let mut occupied = self.identifiers.bookmark_ids.clone();
for id in &self.identifiers.authored_toc_bookmark_ids {
occupied.remove(id);
}
let mut next = occupied.iter().copied().max().unwrap_or(0).checked_add(1);
let mut remap = BTreeMap::new();
for old in semantic {
let new = next.ok_or_else(|| {
Error::Other("table of contents exhausted the bookmark ID range".to_owned())
})?;
occupied.insert(new);
next = new.checked_add(1);
if old != new {
remap.insert(old.to_string(), new.to_string());
}
}
if remap.is_empty() {
return Ok(());
}
let xml = self.document.to_xml()?;
let updated = crate::field::patch_bookmark_ids(&xml, &remap)?;
self.document = CT_Document::from_xml(&updated)?;
for (old, new) in remap {
let old = old.parse::<i32>().expect("bookmark remap key is numeric");
let new = new.parse::<i32>().expect("bookmark remap value is numeric");
self.identifiers.bookmark_ids.remove(&old);
self.identifiers.bookmark_ids.insert(new);
self.identifiers.preserved_bookmark_ids.remove(&old);
self.identifiers.preserved_bookmark_ids.insert(new);
self.identifiers.authored_toc_bookmark_ids.remove(&old);
self.identifiers.authored_toc_bookmark_ids.insert(new);
}
Ok(())
}
fn canonicalize_numbering_ids(&mut self) -> Result<()> {
let Some(numbering) = self.numbering.as_ref() else {
return Ok(());
};
let mut semantic_nums = Vec::new();
visit_body_paragraphs(&self.document.body.content, false, &mut |paragraph| {
if let Some(id) = paragraph
.properties
.as_ref()
.and_then(|properties| properties.num_id)
&& !self
.identifiers
.preserved_numbering_instance_ids
.contains(&id)
&& self.identifiers.numbering_instance_ids.contains(&id)
&& !semantic_nums.contains(&id)
{
semantic_nums.push(id);
}
});
for instance in &numbering.nums {
if !self
.identifiers
.preserved_numbering_instance_ids
.contains(&instance.num_id)
&& self
.identifiers
.numbering_instance_ids
.contains(&instance.num_id)
&& !semantic_nums.contains(&instance.num_id)
{
semantic_nums.push(instance.num_id);
}
}
let abstract_by_num = numbering
.nums
.iter()
.map(|instance| (instance.num_id, instance.abstract_num_id))
.collect::<HashMap<_, _>>();
let mut occupied_nums = self.identifiers.preserved_numbering_instance_ids.clone();
let mut occupied_abstract = self.identifiers.preserved_abstract_numbering_ids.clone();
let referenced_abstract = abstract_by_num.values().copied().collect::<HashSet<_>>();
occupied_abstract.extend(
numbering
.abstract_nums
.iter()
.map(|definition| definition.abstract_num_id)
.filter(|id| !referenced_abstract.contains(id)),
);
let mut num_remap = HashMap::new();
let mut abstract_remap = HashMap::new();
for old_num in semantic_nums {
num_remap.insert(
old_num,
reserve_u32(&mut occupied_nums, 1, "numbering instance")?,
);
if let Some(old_abstract) = abstract_by_num.get(&old_num)
&& !self
.identifiers
.preserved_abstract_numbering_ids
.contains(old_abstract)
&& !abstract_remap.contains_key(old_abstract)
{
abstract_remap.insert(
*old_abstract,
reserve_u32(&mut occupied_abstract, 0, "abstract numbering")?,
);
}
}
visit_body_paragraphs_mut(&mut self.document.body.content, &mut |paragraph| {
if let Some(id) = paragraph
.properties
.as_mut()
.and_then(|properties| properties.num_id.as_mut())
&& let Some(updated) = num_remap.get(id)
{
*id = *updated;
}
});
let numbering = self.numbering.as_mut().expect("checked above");
for definition in &mut numbering.abstract_nums {
if let Some(updated) = abstract_remap.get(&definition.abstract_num_id) {
definition.abstract_num_id = *updated;
}
}
for instance in &mut numbering.nums {
if let Some(updated) = num_remap.get(&instance.num_id) {
instance.num_id = *updated;
}
if let Some(updated) = abstract_remap.get(&instance.abstract_num_id) {
instance.abstract_num_id = *updated;
}
}
if self.identifiers.preserved_abstract_numbering_ids.is_empty()
&& self.identifiers.preserved_numbering_instance_ids.is_empty()
{
numbering
.abstract_nums
.sort_by_key(|definition| definition.abstract_num_id);
numbering.nums.sort_by_key(|instance| instance.num_id);
}
Ok(())
}
fn opaque_relationship_ids_from_serialized_story(
&self,
modeled_relationships: &[String],
) -> Result<Vec<String>> {
// Without modeled candidates there is nothing to mask. An unchanged
// typed story can inventory its exact retained source, including opaque
// property-root attributes that canonical serialization cannot replay.
// Dirty stories and modeled relationships still need the checked mask.
if modeled_relationships.is_empty()
&& let Some(source) = self.package.get_part(&self.doc_part_name)
&& {
#[cfg(test)]
eprintln!("FX211_DRAWING_STAGE opaque-reparse-before");
let matches = CT_Document::from_xml(source).ok().as_ref() == Some(&self.document);
#[cfg(test)]
eprintln!("FX211_DRAWING_STAGE opaque-reparse-after");
matches
}
{
return xml_relationship_ids_in_order(source);
}
let nested_namespace_owners = self
.package
.get_part(&self.doc_part_name)
.map(nested_modeled_namespace_owners)
.transpose()?
.unwrap_or_default();
let serialize = |document: &CT_Document| -> Result<Vec<u8>> {
let xml = document.to_xml()?;
replay_nested_namespace_declarations(&xml, &nested_namespace_owners)
};
let current_xml = serialize(&self.document)?;
let current_relationships = xml_relationship_ids_in_order(¤t_xml)?;
let mut masked = self.document.clone();
let mut masked_values = HashSet::new();
let mut remap = HashMap::new();
for (index, relationship_id) in modeled_relationships.iter().enumerate() {
let mut ordinal = index;
let sentinel = loop {
let candidate = format!("rdocx-modeled-relationship-{ordinal}");
if !current_relationships.contains(&candidate)
&& masked_values.insert(candidate.clone())
{
break candidate;
}
ordinal = ordinal
.checked_add(modeled_relationships.len().max(1))
.ok_or_else(|| {
Error::Other("modeled relationship mask range is exhausted".to_owned())
})?;
};
remap.insert(relationship_id.clone(), sentinel);
}
visit_authored_drawings_mut(&mut masked.body.content, &mut |drawing| {
if let Some(mut drawing) = drawing
.inline
.as_mut()
.map(DrawingMut::Inline)
.or_else(|| drawing.anchor.as_mut().map(DrawingMut::Anchor))
{
drawing.remap_relationship(&remap);
}
});
visit_body_paragraphs_mut(&mut masked.body.content, &mut |paragraph| {
for hyperlink in &mut paragraph.hyperlinks {
if let Some(id) = &mut hyperlink.rel_id
&& let Some(updated) = remap.get(id)
{
id.clone_from(updated);
}
}
if let Some(section) = paragraph
.properties
.as_mut()
.and_then(|properties| properties.sect_pr.as_mut())
{
for reference in section
.header_refs
.iter_mut()
.chain(&mut section.footer_refs)
{
if let Some(updated) = remap.get(&reference.rel_id) {
reference.rel_id.clone_from(updated);
}
}
}
});
if let Some(section) = &mut masked.body.sect_pr {
for reference in section
.header_refs
.iter_mut()
.chain(&mut section.footer_refs)
{
if let Some(updated) = remap.get(&reference.rel_id) {
reference.rel_id.clone_from(updated);
}
}
}
let masked_xml = serialize(&masked)?;
Ok(xml_relationship_ids_in_order(&masked_xml)?
.into_iter()
.filter(|id| !masked_values.contains(id) && current_relationships.contains(id))
.collect())
}
fn canonicalize_drawing_ids(&mut self) -> Result<()> {
let owner = self.doc_part_name.clone();
let owner_identity = relationship_owner_identity(&owner);
// Story picture insertion keeps current producer XML. Patch its
// canonical relationship and drawing IDs into that retained source.
let retained_main_xml = self
.package
.get_part(&owner)
.filter(|xml| {
self.identifiers
.authored_nested_story_relationship_ids
.get(&owner_identity)
.is_some_and(|ids| !ids.is_empty())
&& CT_Document::from_xml(xml).ok().as_ref() == Some(&self.document)
&& self.document.to_xml().ok().as_deref() != Some(*xml)
})
.map(|xml| xml.to_vec());
if let Some(section) = &mut self.document.body.sect_pr {
section
.header_refs
.sort_by_key(|reference| hdr_ftr_type_order(reference.hdr_ftr_type));
section
.footer_refs
.sort_by_key(|reference| hdr_ftr_type_order(reference.hdr_ftr_type));
}
let preserved_relationships = self
.identifiers
.preserved_relationship_ids
.get(&owner_identity)
.cloned()
.unwrap_or_default();
let authored_bundle_relationships = self
.identifiers
.authored_bundle_relationship_ids
.get(&owner_identity)
.cloned()
.unwrap_or_default();
let mut modeled_story_relationships = Vec::new();
#[cfg(test)]
eprintln!("FX211_DRAWING_STAGE collect-before");
collect_relationship_ids(
&self.document.body.content,
&mut modeled_story_relationships,
);
#[cfg(test)]
eprintln!("FX211_DRAWING_STAGE collect-after");
if let Some(section) = &self.document.body.sect_pr {
for reference in section.header_refs.iter().chain(§ion.footer_refs) {
if !modeled_story_relationships.contains(&reference.rel_id) {
modeled_story_relationships.push(reference.rel_id.clone());
}
}
}
let nested_relationships = self
.identifiers
.authored_nested_story_relationship_ids
.get(&owner_identity)
.cloned()
.unwrap_or_default();
#[cfg(test)]
eprintln!("FX211_DRAWING_STAGE opaque-before");
let mut opaque_relationships =
self.opaque_relationship_ids_from_serialized_story(&modeled_story_relationships)?;
#[cfg(test)]
eprintln!("FX211_DRAWING_STAGE opaque-after");
opaque_relationships.retain(|id| !nested_relationships.contains(id));
let mut story_relationships = self.main_story_relationship_ids();
let relationships = self
.package
.get_part_rels(&owner)
.map(|relationships| relationships.items.clone())
.unwrap_or_default();
story_relationships.retain(|id| {
!preserved_relationships.contains(id)
&& relationships
.iter()
.any(|relationship| relationship.id == *id)
});
let mut bundle_relationships = relationships
.iter()
.filter_map(|relationship| {
(!preserved_relationships.contains(&relationship.id)
&& authored_bundle_relationships.contains(&relationship.id)
&& relationship_is_internal(relationship))
.then(|| {
bundle_relationship_order(&relationship.rel_type).map(|order| {
(
order,
relationship.target.as_str(),
relationship.id.as_str(),
bundle_relationship_precedes_story(&relationship.rel_type),
)
})
})
.flatten()
})
.collect::<Vec<_>>();
bundle_relationships.sort_unstable();
let mut semantic_relationships = bundle_relationships
.iter()
.filter(|(_, _, _, precedes_story)| *precedes_story)
.map(|(_, _, id, _)| (*id).to_owned())
.collect::<Vec<_>>();
semantic_relationships.extend(story_relationships);
if let Some(section) = &self.document.body.sect_pr {
for reference in section.header_refs.iter().chain(§ion.footer_refs) {
if !preserved_relationships.contains(&reference.rel_id)
&& !semantic_relationships.contains(&reference.rel_id)
{
semantic_relationships.push(reference.rel_id.clone());
}
}
}
let mut unreferenced_relationships = relationships
.iter()
.filter(|relationship| {
!preserved_relationships.contains(&relationship.id)
&& !semantic_relationships.contains(&relationship.id)
&& !authored_bundle_relationships.contains(&relationship.id)
&& !opaque_relationships.contains(&relationship.id)
})
.map(|relationship| {
let internal = relationship_is_internal(relationship);
let target = if internal {
part_name_identity(&OpcPackage::resolve_rel_target(
&owner,
&relationship.target,
))
} else {
relationship.target.clone()
};
(
!internal,
relationship.rel_type.as_str(),
target,
relationship.target_mode.as_deref().unwrap_or(""),
relationship.id.as_str(),
)
})
.collect::<Vec<_>>();
unreferenced_relationships.sort_unstable();
semantic_relationships.extend(
unreferenced_relationships
.into_iter()
.map(|(_, _, _, _, id)| id.to_owned()),
);
semantic_relationships.extend(
bundle_relationships
.into_iter()
.filter(|(_, _, _, precedes_story)| !precedes_story)
.map(|(_, _, id, _)| id.to_owned()),
);
let mut occupied_relationships = preserved_relationships.clone();
occupied_relationships.extend(opaque_relationships);
occupied_relationships.extend(
relationships
.iter()
.filter(|relationship| !semantic_relationships.contains(&relationship.id))
.map(|relationship| relationship.id.clone()),
);
let mut relationship_cursor = if semantic_relationships.is_empty() {
0
} else {
preserved_relationships
.iter()
.filter_map(|id| id.strip_prefix("rId")?.parse::<u32>().ok())
.max()
.map_or(Ok(0), |maximum| {
maximum.checked_add(1).ok_or_else(|| {
Error::Other("relationship id range is exhausted".to_owned())
})
})?
};
let mut relationship_remap = HashMap::new();
for old in &semantic_relationships {
let updated = reserve_relationship_from_cursor(
&mut occupied_relationships,
&mut relationship_cursor,
)?;
relationship_remap.insert(old.clone(), updated);
}
if let Some(relationships) = self.package.get_part_rels_mut(&owner) {
for relationship in &mut relationships.items {
if let Some(updated) = relationship_remap.get(&relationship.id) {
relationship.id.clone_from(updated);
}
}
let mut authored = Vec::new();
relationships.items.retain(|relationship| {
if preserved_relationships.contains(&relationship.id) {
true
} else {
authored.push(relationship.clone());
false
}
});
authored.sort_by_key(|relationship| {
relationship
.id
.strip_prefix("rId")
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(u32::MAX)
});
relationships.items.extend(authored);
}
if !relationship_remap.is_empty() {
if let Some(occupied) = self.identifiers.relationship_ids.get_mut(&owner_identity) {
for old in relationship_remap.keys() {
occupied.remove(old);
}
occupied.extend(relationship_remap.values().cloned());
}
if let Some(authored) = self
.identifiers
.authored_story_relationship_ids
.get_mut(&owner_identity)
{
let remapped = authored
.iter()
.map(|id| relationship_remap.get(id).unwrap_or(id).clone())
.collect();
*authored = remapped;
}
if let Some(authored) = self
.identifiers
.authored_bundle_relationship_ids
.get_mut(&owner_identity)
{
let remapped = authored
.iter()
.map(|id| relationship_remap.get(id).unwrap_or(id).clone())
.collect();
*authored = remapped;
}
if let Some(provisional) = self
.identifiers
.provisional_relationship_ids
.get_mut(&owner_identity)
{
provisional.retain(|id| !relationship_remap.contains_key(id));
if provisional.is_empty() {
self.identifiers
.provisional_relationship_ids
.remove(&owner_identity);
}
}
}
let registered_nested_drawing_ids = self
.identifiers
.authored_nested_story_drawing_ids
.get(&owner_identity)
.cloned()
.unwrap_or_default();
let mut modeled_drawing_ids = Vec::new();
visit_authored_drawings(&self.document.body.content, &mut |drawing| {
if let Some(inline) = &drawing.inline
&& !registered_nested_drawing_ids.contains(&inline.doc_pr_id)
{
modeled_drawing_ids.push(inline.doc_pr_id);
}
if let Some(anchor) = &drawing.anchor
&& !registered_nested_drawing_ids.contains(&anchor.doc_pr_id)
{
modeled_drawing_ids.push(anchor.doc_pr_id);
}
});
let has_live_nested_relationships = self.remap_authored_nested_story_relationships(
&owner_identity,
&nested_relationships,
&relationship_remap,
)?;
if !has_live_nested_relationships {
visit_authored_drawings_mut(&mut self.document.body.content, &mut |drawing| {
if let Some(mut drawing) = drawing
.inline
.as_mut()
.map(DrawingMut::Inline)
.or_else(|| drawing.anchor.as_mut().map(DrawingMut::Anchor))
{
drawing.remap_relationship(&relationship_remap);
}
});
visit_body_paragraphs_mut(&mut self.document.body.content, &mut |paragraph| {
for hyperlink in &mut paragraph.hyperlinks {
if let Some(id) = &mut hyperlink.rel_id
&& let Some(updated) = relationship_remap.get(id)
{
id.clone_from(updated);
}
}
if let Some(section) = paragraph
.properties
.as_mut()
.and_then(|properties| properties.sect_pr.as_mut())
{
for reference in section
.header_refs
.iter_mut()
.chain(&mut section.footer_refs)
{
if let Some(updated) = relationship_remap.get(&reference.rel_id) {
reference.rel_id.clone_from(updated);
}
}
}
});
if let Some(section) = &mut self.document.body.sect_pr {
for reference in section
.header_refs
.iter_mut()
.chain(&mut section.footer_refs)
{
if let Some(updated) = relationship_remap.get(&reference.rel_id) {
reference.rel_id.clone_from(updated);
}
}
}
}
let related_parts = self.related_story_parts();
let live_nested_relationships = self
.identifiers
.authored_nested_story_relationship_ids
.get(&owner_identity)
.cloned()
.unwrap_or_default();
let nested_image_relationship_ids = self
.package
.get_part_rels(&owner)
.into_iter()
.flat_map(|relationships| &relationships.items)
.filter(|relationship| {
relationship.rel_type == rel_types::IMAGE
&& live_nested_relationships.contains(&relationship.id)
})
.map(|relationship| relationship.id.clone())
.collect::<HashSet<_>>();
let current_document_xml = self.document.to_xml()?;
let authored_drawing_slots = authored_drawing_slots_in_source_order(
¤t_document_xml,
&modeled_drawing_ids,
&nested_image_relationship_ids,
)?;
let mut occupied_drawings = self.identifiers.preserved_drawing_ids.clone();
if let Ok(live_ids) = drawing_ids_in_xml(¤t_document_xml) {
occupied_drawings.extend(live_ids);
}
for (_, _, id) in &authored_drawing_slots {
occupied_drawings.remove(id);
}
for part in &related_parts {
if !self.identifiers.authored_story_parts.contains(part)
&& let Some(xml) = self.package.get_part(part)
&& let Ok(live_ids) = drawing_ids_in_xml(xml)
{
occupied_drawings.extend(live_ids);
}
}
let mut drawing_edits = Vec::with_capacity(authored_drawing_slots.len());
let mut canonical_drawing_ids = Vec::with_capacity(authored_drawing_slots.len());
let mut canonical_nested_drawing_ids = HashSet::new();
for (slot, span, old_id) in authored_drawing_slots {
let id = reserve_u32(&mut occupied_drawings, 1, "drawing")?;
if matches!(slot, AuthoredMainDrawing::Nested) {
canonical_nested_drawing_ids.insert(id);
}
canonical_drawing_ids.push((old_id, id));
drawing_edits.push((span.start, span.end, id.to_string().into_bytes()));
}
if !drawing_edits.is_empty() {
let mut updated = current_document_xml;
drawing_edits.sort_by_key(|(start, _, _)| *start);
for (start, end, replacement) in drawing_edits.into_iter().rev() {
updated.splice(start..end, replacement);
}
self.document = CT_Document::from_xml(&updated)?;
}
if let Some(retained_main_xml) = retained_main_xml {
let mut retained = remap_xml_relationship_ids(&retained_main_xml, &relationship_remap)?;
let slots = authored_drawing_slots_in_source_order(
&retained,
&modeled_drawing_ids,
&nested_image_relationship_ids,
)?;
if slots.len() == canonical_drawing_ids.len()
&& slots
.iter()
.zip(&canonical_drawing_ids)
.all(|((_, _, old), (expected, _))| old == expected)
{
for ((_, span, _), (_, id)) in slots
.into_iter()
.rev()
.zip(canonical_drawing_ids.iter().rev())
{
retained.splice(span, id.to_string().into_bytes());
}
self.document = CT_Document::from_xml(&retained)?;
self.package.set_part(&owner, retained);
}
}
if canonical_nested_drawing_ids.is_empty() {
self.identifiers
.authored_nested_story_drawing_ids
.remove(&owner_identity);
} else {
self.identifiers
.authored_nested_story_drawing_ids
.insert(owner_identity.clone(), canonical_nested_drawing_ids);
}
self.canonicalize_related_story_relationships(&related_parts, &mut occupied_drawings)?;
self.identifiers.drawing_ids = occupied_drawings;
self.canonicalize_image_parts()?;
Ok(())
}
fn active_header_footer_parts(&self) -> Vec<String> {
let mut parts = Vec::new();
for (relationship_id, is_header) in self.header_footer_rel_ids() {
if let Some(part) = self.header_footer_part_name(&relationship_id, is_header)
&& !parts.contains(&part)
{
parts.push(part);
}
}
parts
}
fn remap_authored_nested_story_relationships(
&mut self,
physical_owner: &str,
registered: &HashSet<String>,
relationship_remap: &HashMap<String, String>,
) -> Result<bool> {
let xml = self.document.to_xml()?;
let occurrences =
xml_relationship_ids_in_order_with_bindings(&xml, &self.body_namespace_bindings)?;
let live = registered
.iter()
.filter(|id| occurrences.contains(id))
.cloned()
.collect::<HashSet<_>>();
if !relationship_remap.is_empty() && !live.is_empty() {
let updated = remap_xml_relationship_ids_with_bindings(
&xml,
relationship_remap,
&self.body_namespace_bindings,
)?;
self.document = CT_Document::from_xml(&updated)?;
}
if live.is_empty() {
self.identifiers
.authored_nested_story_relationship_ids
.remove(physical_owner);
} else {
let remapped = live
.iter()
.map(|id| relationship_remap.get(id).unwrap_or(id).clone())
.collect();
self.identifiers
.authored_nested_story_relationship_ids
.insert(physical_owner.to_owned(), remapped);
}
Ok(!live.is_empty())
}
fn related_story_parts(&self) -> Vec<String> {
let mut parts = self.active_header_footer_parts();
if let Some(relationships) = self.package.get_part_rels(&self.doc_part_name) {
for relationship in &relationships.items {
if matches!(
relationship.rel_type.as_str(),
rel_types::FOOTNOTES | rel_types::ENDNOTES | rel_types::COMMENTS
) && relationship_is_internal(relationship)
{
let part =
OpcPackage::resolve_rel_target(&self.doc_part_name, &relationship.target);
if self.package.contains_part(&part) && !parts.contains(&part) {
parts.push(part);
}
}
}
}
parts
}
fn main_story_relationship_ids(&self) -> Vec<String> {
let mut modeled = Vec::new();
collect_relationship_ids(&self.document.body.content, &mut modeled);
if let Some(section) = &self.document.body.sect_pr {
for reference in section.header_refs.iter().chain(§ion.footer_refs) {
if !modeled.contains(&reference.rel_id) {
modeled.push(reference.rel_id.clone());
}
}
}
let owner = relationship_owner_identity(&self.doc_part_name);
let nested = self
.identifiers
.authored_nested_story_relationship_ids
.get(&owner)
.cloned()
.unwrap_or_default();
let semantic = modeled
.iter()
.cloned()
.chain(nested.iter().cloned())
.collect::<HashSet<_>>();
if let Ok(xml) = self.document.to_xml()
&& let Some(ordered) = authored_relationship_ids_in_source_order(&xml, &semantic)
{
return ordered;
}
let mut nested = nested.into_iter().collect::<Vec<_>>();
nested.sort();
for id in nested {
if !modeled.contains(&id) {
modeled.push(id);
}
}
modeled
}
fn canonicalize_related_story_relationships(
&mut self,
parts: &[String],
occupied_drawings: &mut HashSet<u32>,
) -> Result<()> {
let mut partially_authored_drawing_slots = HashMap::new();
for owner in parts {
if self.identifiers.authored_story_parts.contains(owner) {
continue;
}
let owner_identity = relationship_owner_identity(owner);
let partially_authored = self
.identifiers
.authored_story_relationship_ids
.get(&owner_identity)
.cloned()
.unwrap_or_default();
if partially_authored.is_empty() {
continue;
}
let xml = self
.package
.get_part(owner)
.ok_or_else(|| Error::Other(format!("story part {owner} is missing")))?;
let authored_image_relationship_ids = self
.package
.get_part_rels(owner)
.into_iter()
.flat_map(|relationships| &relationships.items)
.filter(|relationship| {
relationship.rel_type == rel_types::IMAGE
&& partially_authored.contains(&relationship.id)
})
.map(|relationship| relationship.id.clone())
.collect::<HashSet<_>>();
let drawing_slots =
authored_drawing_slots_in_source_order(xml, &[], &authored_image_relationship_ids)?
.into_iter()
.map(|(_, span, id)| (span, id))
.collect::<Vec<_>>();
for (_, id) in &drawing_slots {
occupied_drawings.remove(id);
}
partially_authored_drawing_slots.insert(owner.clone(), drawing_slots);
}
for owner in parts {
let owner_identity = relationship_owner_identity(owner);
let fully_authored = self.identifiers.authored_story_parts.contains(owner);
let partially_authored = self
.identifiers
.authored_story_relationship_ids
.get(&owner_identity)
.cloned()
.unwrap_or_default();
if !fully_authored && partially_authored.is_empty() {
continue;
}
let xml = self
.package
.get_part(owner)
.ok_or_else(|| Error::Other(format!("story part {owner} is missing")))?
.to_vec();
let referenced = xml_relationship_ids_in_order(&xml)?;
let preserved = self
.identifiers
.preserved_relationship_ids
.get(&owner_identity)
.cloned()
.unwrap_or_default();
let relationships = self
.package
.get_part_rels(owner)
.map(|relationships| relationships.items.clone())
.unwrap_or_default();
let semantic = referenced
.into_iter()
.filter(|id| {
(fully_authored && !preserved.contains(id) || partially_authored.contains(id))
&& relationships
.iter()
.any(|relationship| relationship.id == *id)
})
.collect::<Vec<_>>();
let mut occupied = preserved.clone();
occupied.extend(
relationships
.iter()
.filter(|relationship| !semantic.contains(&relationship.id))
.map(|relationship| relationship.id.clone()),
);
let mut cursor = if semantic.is_empty() {
0
} else {
preserved
.iter()
.filter_map(|id| id.strip_prefix("rId")?.parse::<u32>().ok())
.max()
.map_or(Ok(0), |maximum| {
maximum.checked_add(1).ok_or_else(|| {
Error::Other("relationship id range is exhausted".to_owned())
})
})?
};
let mut remap = HashMap::new();
for old in semantic {
remap.insert(
old,
reserve_relationship_from_cursor(&mut occupied, &mut cursor)?,
);
}
if let Some(owner_relationships) = self.package.get_part_rels_mut(owner) {
for relationship in &mut owner_relationships.items {
if let Some(updated) = remap.get(&relationship.id) {
relationship.id.clone_from(updated);
}
}
let canonicalized_relationship_ids = if fully_authored {
owner_relationships
.items
.iter()
.filter(|relationship| !preserved.contains(&relationship.id))
.map(|relationship| relationship.id.clone())
.collect::<HashSet<_>>()
} else {
remap.values().cloned().collect::<HashSet<_>>()
};
if !canonicalized_relationship_ids.is_empty() {
let mut authored = owner_relationships
.items
.iter()
.filter(|relationship| {
canonicalized_relationship_ids.contains(&relationship.id)
})
.cloned()
.collect::<Vec<_>>();
authored.sort_by_key(|relationship| {
relationship
.id
.strip_prefix("rId")
.and_then(|value| value.parse::<u32>().ok())
.unwrap_or(u32::MAX)
});
let mut authored = authored.into_iter();
for relationship in &mut owner_relationships.items {
if canonicalized_relationship_ids.contains(&relationship.id) {
*relationship = authored
.next()
.expect("each authored relationship slot has a value");
}
}
}
}
let mut drawing_edits = Vec::new();
if !fully_authored {
for (span, _) in partially_authored_drawing_slots
.remove(owner)
.unwrap_or_default()
{
let id = reserve_u32(occupied_drawings, 1, "drawing")?;
drawing_edits.push((span.start, span.end, id.to_string().into_bytes()));
}
}
let mut xml = xml;
drawing_edits.sort_by_key(|(start, _, _)| *start);
for (start, end, replacement) in drawing_edits.into_iter().rev() {
xml.splice(start..end, replacement);
}
let xml = remap_xml_relationship_ids(&xml, &remap)?;
if let Some(ids) = self
.identifiers
.authored_story_relationship_ids
.get_mut(&owner_identity)
{
*ids = ids
.drain()
.map(|id| remap.get(&id).cloned().unwrap_or(id))
.collect();
}
let xml = if fully_authored {
rewrite_authored_doc_pr_ids(&xml, occupied_drawings)?
} else {
xml
};
self.package.set_part(owner, xml);
}
Ok(())
}
fn canonicalize_image_parts(&mut self) -> Result<()> {
let mut ordered = Vec::new();
let main_xml = self.document.to_xml()?;
let mut owners = vec![(
self.doc_part_name.clone(),
xml_relationship_ids_in_order(&main_xml)?,
)];
for owner in self.related_story_parts() {
let owner_identity = relationship_owner_identity(&owner);
if self.identifiers.authored_story_parts.contains(&owner)
|| self
.identifiers
.authored_story_relationship_ids
.contains_key(&owner_identity)
{
let ids = self
.package
.get_part(&owner)
.map(xml_relationship_ids_in_order)
.transpose()?
.unwrap_or_default();
owners.push((owner, ids));
}
}
for (owner, ids) in &owners {
let Some(relationships) = self.package.get_part_rels(owner) else {
continue;
};
for id in ids {
if let Some(relationship) = relationships.get_by_id(id)
&& relationship.rel_type == rel_types::IMAGE
{
let part = OpcPackage::resolve_rel_target(owner, &relationship.target);
if !self.identifiers.part_is_preserved(&part) && !ordered.contains(&part) {
ordered.push(part);
}
}
}
}
let mut occupied = self.identifiers.preserved_part_names.clone();
occupied.extend(
self.package
.parts
.keys()
.filter(|part| !ordered.contains(part))
.map(|part| part_name_identity(part)),
);
let mut remap = HashMap::new();
for old in ordered {
let extension = old
.rsplit_once('.')
.map(|(_, extension)| extension)
.unwrap_or("bin")
.to_owned();
remap.insert(
old,
reserve_part_from_set(&mut occupied, "/word/media", "image", &extension)?,
);
}
for (owner, _) in &owners {
if let Some(relationships) = self.package.get_part_rels_mut(owner) {
for relationship in &mut relationships.items {
if relationship.rel_type == rel_types::IMAGE {
let old = OpcPackage::resolve_rel_target(owner, &relationship.target);
if let Some(updated) = remap.get(&old) {
relationship.target = relative_descendant_target(owner, updated);
}
}
}
}
}
let mut moved = Vec::new();
for (old, updated) in remap {
if old != updated
&& let Some(bytes) = self.package.remove_part(&old)
{
moved.push((updated.clone(), bytes));
if let Some(content_type) = self.package.content_types.remove_override(&old) {
self.package
.content_types
.add_override(&updated, &content_type);
}
}
}
for (part_name, bytes) in moved {
self.package.set_part(&part_name, bytes);
}
Ok(())
}
/// Commit staged package state without discarding reusable layout work.
pub(crate) fn commit_staged_mutation(&mut self, mut candidate: Self) {
candidate.package_signatures_invalidated |= candidate
.package
.package_rels
.items
.iter()
.any(|relationship| relationship.rel_type == rel_types::DIGITAL_SIGNATURE_ORIGIN)
&& (self.package_signatures_invalidated
|| crate::embedded::synchronized_package_mutation_invalidates_signature(
&self.package,
&candidate.package,
));
std::mem::swap(
&mut self.normal_layout_engine,
&mut candidate.normal_layout_engine,
);
std::mem::swap(
&mut self.bundled_fallback_layout_engine,
&mut candidate.bundled_fallback_layout_engine,
);
*self = candidate;
}
/// Open a document from a file path.
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let package = OpcPackage::open(path)?;
Self::from_package(package)
}
/// Open a password-protected agile-encrypted document from a file path.
#[cfg(all(feature = "agile-encryption", not(target_arch = "wasm32")))]
pub fn open_encrypted<P: AsRef<Path>>(path: P, password: &str) -> Result<Self> {
let file = std::fs::File::open(path)?;
let package = OpcPackage::from_encrypted_reader(file, password)?;
Self::from_package(package)
}
/// Open a document from bytes.
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
Self::from_bytes_with_limits(bytes, PackageReadLimits::UNBOUNDED)
}
/// Open a password-protected agile-encrypted document from bytes.
#[cfg(all(feature = "agile-encryption", not(target_arch = "wasm32")))]
pub fn from_encrypted_bytes(bytes: &[u8], password: &str) -> Result<Self> {
Self::from_encrypted_bytes_with_limits(bytes, password, PackageReadLimits::UNBOUNDED)
}
/// Open an agile-encrypted document while bounding OPC archive expansion.
#[cfg(all(feature = "agile-encryption", not(target_arch = "wasm32")))]
pub fn from_encrypted_bytes_with_limits(
bytes: &[u8],
password: &str,
limits: PackageReadLimits,
) -> Result<Self> {
let cursor = std::io::Cursor::new(bytes);
let package = OpcPackage::from_encrypted_reader_with_limits(cursor, password, limits)?;
Self::from_package(package)
}
/// Open a document from bytes while bounding OPC archive expansion.
pub fn from_bytes_with_limits(bytes: &[u8], limits: PackageReadLimits) -> Result<Self> {
let cursor = std::io::Cursor::new(bytes);
let package = OpcPackage::from_reader_with_limits(cursor, limits)?;
Self::from_package(package)
}
/// Return the exact Word package class declared for the main document part.
pub fn package_class(&self) -> Result<WordPackageClass> {
validated_word_package_class(&self.package).map(|(_, class)| class)
}
/// Serialize a staged copy as the requested Word package class.
///
/// This changes only the main part's content-type override. Macro projects
/// and all other package payloads remain present.
pub fn to_bytes_as(&self, class: WordPackageClass) -> Result<Vec<u8>> {
self.validate_model_resource_limits()?;
let mut candidate = self.clone_for_staging();
candidate.prepare_staged_output()?;
candidate
.package
.content_types
.add_override(&candidate.doc_part_name, class.content_type());
candidate.package_signatures_invalidated |=
crate::embedded::synchronized_package_mutation_invalidates_signature(
&self.package,
&candidate.package,
);
crate::embedded::persist_invalidated_package_signature(
&mut candidate.package,
candidate.package_signatures_invalidated,
)?;
let mut output = std::io::Cursor::new(Vec::new());
candidate.package.write_to(&mut output)?;
let bytes = output.into_inner();
Self::from_bytes(&bytes)?;
Ok(bytes)
}
/// Save a staged copy as the requested Word package class.
pub fn save_as_package_class<P: AsRef<Path>>(
&self,
path: P,
class: WordPackageClass,
) -> Result<()> {
let bytes = self.to_bytes_as(class)?;
write_atomic_file(
path.as_ref(),
&bytes,
"rdocx",
"invalid Word package file name",
"could not allocate Word package save staging file",
)?;
Ok(())
}
/// Verify package signatures without asserting certificate-chain trust.
#[cfg(feature = "digital-signatures")]
pub fn verify_signatures(
&self,
) -> std::result::Result<Vec<oxml_opc::SignatureReport>, oxml_opc::OpcError> {
self.package.verify_signatures()
}
/// Sign the current typed document with the strict RSA-SHA256 profile.
///
/// The private key must be PKCS#8 DER or one unencrypted PRIVATE KEY PEM
/// block. The certificate must be X.509 DER or one CERTIFICATE PEM block. Typed state is flushed and signed on a staged clone, so any
/// serialization, key, certificate, or signature failure leaves this
/// document unchanged. Certificate-chain trust remains caller policy.
#[cfg(all(feature = "digital-signatures", not(target_arch = "wasm32")))]
pub fn sign(
&mut self,
private_key_pkcs8_der: &[u8],
certificate_der: &[u8],
) -> Result<oxml_opc::SignatureReport> {
let mut candidate = self.clone_for_staging();
candidate.prepare_staged_output()?;
let report = candidate
.package
.sign(private_key_pkcs8_der, certificate_der)?;
self.commit_staged_mutation(candidate);
Ok(report)
}
pub(crate) fn from_package(package: OpcPackage) -> Result<Self> {
let (doc_part_name, _) = validated_word_package_class(&package)?;
let doc_xml = package
.get_part(&doc_part_name)
.ok_or(Error::NoDocumentPart)?;
let namespace_scopes = document_namespace_scopes(doc_xml)?;
let document = CT_Document::from_xml(doc_xml)?;
// Resolve the part a relationship of the given type points at.
let resolve_part = |rel_type: &str| -> Option<String> {
let rels = package.get_part_rels(&doc_part_name)?;
let rel = rels.items.iter().find(|relationship| {
relationship.rel_type == rel_type && relationship_is_internal(relationship)
})?;
Some(OpcPackage::resolve_rel_target(&doc_part_name, &rel.target))
};
// Try to load styles, remembering where they came from.
let styles_part_name = resolve_part(rel_types::STYLES);
let styles = match styles_part_name
.as_deref()
.and_then(|p| package.get_part(p))
{
Some(styles_xml) => CT_Styles::from_xml(styles_xml)?,
None => CT_Styles::new_default(),
};
// Try to load numbering definitions
let numbering_part_name = resolve_part(rel_types::NUMBERING);
let numbering = match numbering_part_name
.as_deref()
.and_then(|p| package.get_part(p))
{
Some(num_xml) => Some(CT_Numbering::from_xml(num_xml)?),
None => None,
};
let settings_part_name = resolve_part(rel_types::SETTINGS);
let settings = match settings_part_name
.as_deref()
.and_then(|part| package.get_part(part))
{
Some(xml) => Some(CT_Settings::from_xml(xml)?),
None => None,
};
let web_settings_part_name = resolve_part(rel_types::WEB_SETTINGS);
let web_settings = match web_settings_part_name
.as_deref()
.and_then(|part| package.get_part(part))
{
Some(xml) => Some(CT_WebSettings::from_xml(xml)?),
None => None,
};
let theme_part_name = resolve_part(rel_types::THEME);
let theme = match theme_part_name
.as_deref()
.and_then(|part| package.get_part(part))
{
Some(xml) => oxml_drawing::theme::CT_OfficeStyleSheet::from_xml(xml).ok(),
None => None,
};
let font_table_part_name = resolve_part(rel_types::FONT_TABLE);
let font_table = match font_table_part_name
.as_deref()
.and_then(|part| package.get_part(part))
{
Some(xml) => FontTable::from_xml(xml).ok(),
None => None,
};
// Core properties are a package-level relationship, not a document part.
let core_properties_part_name = package
.package_rels
.items
.iter()
.find(|relationship| {
relationship.rel_type == CORE_PROPERTIES_REL_TYPE
&& relationship_is_internal(relationship)
})
.map(|rel| OpcPackage::resolve_rel_target("/", &rel.target));
let core_properties = core_properties_part_name
.as_deref()
.and_then(|part| package.get_part(part))
.and_then(|xml| CoreProperties::from_xml(xml).ok())
.map(Box::new);
let application_properties_part_name = package
.package_rels
.items
.iter()
.find(|relationship| {
relationship.rel_type == rel_types::EXTENDED_PROPERTIES
&& relationship_is_internal(relationship)
})
.map(|rel| OpcPackage::resolve_rel_target("/", &rel.target));
let application_properties = application_properties_part_name
.as_deref()
.and_then(|part| package.get_part(part))
.and_then(|xml| AppProperties::from_xml(xml).ok())
.map(repair_application_version);
let custom_properties_part_name = package
.package_rels
.items
.iter()
.find(|relationship| {
relationship.rel_type == rel_types::CUSTOM_PROPERTIES
&& relationship_is_internal(relationship)
})
.map(|rel| OpcPackage::resolve_rel_target("/", &rel.target));
let custom_properties = custom_properties_part_name
.as_deref()
.and_then(|part| package.get_part(part))
.and_then(|xml| CustomProperties::from_xml(xml).ok());
let footnotes_part_name = resolve_part(rel_types::FOOTNOTES);
let footnotes_root_namespace_declarations = footnotes_part_name
.as_deref()
.and_then(|part| package.get_part(part))
.map(root_namespace_declarations)
.transpose()?
.unwrap_or_default();
let footnotes = footnotes_part_name
.as_deref()
.and_then(|part| package.get_part(part))
.and_then(|xml| rdocx_oxml::footnotes::CT_Footnotes::from_xml(xml).ok())
.unwrap_or_default();
let comments_part_name = resolve_part(rel_types::COMMENTS);
let comments = match comments_part_name
.as_deref()
.and_then(|part| package.get_part(part))
{
Some(xml) => Some(rdocx_oxml::comments::CT_Comments::from_xml(xml)?),
None => None,
};
let comments_extended_part_name = resolve_part(crate::comments::COMMENTS_EXTENDED_REL_TYPE);
let comments_extended = match comments_extended_part_name
.as_deref()
.and_then(|part| package.get_part(part))
{
Some(xml) => Some(rdocx_oxml::comments_extended::CT_CommentsEx::from_xml(xml)?),
None => None,
};
let glossary = crate::building_block::load_glossary(&package, &doc_part_name)?;
let (glossary_part_name, glossary) = match glossary {
Some((part_name, glossary)) => (Some(part_name), Some(glossary)),
None => (None, None),
};
let package_signatures_invalidated =
crate::embedded::known_invalid_package_signature_on_open(&package);
let identifiers = DocumentIdentifiers::scan(&package)?;
Ok(Document {
package,
document,
root_namespace_declarations: namespace_scopes.root_declarations,
body_namespace_declarations: namespace_scopes.body_declarations,
body_namespace_bindings: namespace_scopes.body_bindings,
styles,
numbering,
core_properties,
application_properties,
custom_properties,
core_properties_part_name,
application_properties_part_name,
custom_properties_part_name,
custom_properties_owned: false,
doc_part_name,
styles_part_name,
numbering_part_name,
settings,
settings_part_name,
settings_owned: false,
web_settings,
web_settings_part_name,
web_settings_owned: false,
theme,
theme_part_name,
theme_dirty: false,
font_table,
font_table_part_name,
font_table_dirty: false,
identifiers,
footnotes,
footnotes_part_name,
footnotes_root_namespace_declarations,
footnotes_dirty: false,
comments,
comments_part_name,
comments_dirty: false,
comments_extended,
comments_extended_part_name,
comments_owned: false,
comments_extended_owned: false,
embedded_invalidated_signatures: HashSet::new(),
package_signatures_invalidated,
glossary,
glossary_part_name,
glossary_dirty: false,
layout_cache: Mutex::new(None),
normal_layout_engine: Mutex::new(None),
deterministic_layout_cache: Mutex::new(None),
bundled_fallback_layout_engine: Mutex::new(None),
})
}
/// Clear layouts derived from the current document state.
pub(crate) fn invalidate_layout(&mut self) {
self.layout_cache
.get_mut()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
self.deterministic_layout_cache
.get_mut()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
}
#[cfg(test)]
pub(crate) fn redaction_cache_identity(
&self,
) -> (Option<usize>, Option<usize>, Option<usize>, Option<usize>) {
let layout = self
.layout_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.map(|value| Arc::as_ptr(value) as usize);
let normal_engine = self
.normal_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.map(|value| value as *const _ as usize);
let deterministic = self
.deterministic_layout_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.map(|value| Arc::as_ptr(value) as usize);
let bundled_engine = self
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.map(|value| value as *const _ as usize);
(layout, normal_engine, deterministic, bundled_engine)
}
/// Return the normal-font layout, computing it once after each mutation.
fn cached_layout(&self) -> Result<Arc<rdocx_layout::WordLayoutResult>> {
let mut cache = self
.layout_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(layout) = cache.as_ref() {
return Ok(Arc::clone(layout));
}
let input = self.build_layout_input()?;
#[cfg(test)]
record_layout_invocation();
let mut engine = self
.normal_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let engine = engine.get_or_insert_with(rdocx_layout::engine::Engine::new);
let layout = Arc::new(rdocx_layout::layout_document_with_reusable_engine(
engine, &input,
)?);
*cache = Some(Arc::clone(&layout));
Ok(layout)
}
/// Return the bundled-font-only layout, computing it once after mutation.
fn cached_deterministic_layout(&self) -> Result<Arc<rdocx_layout::WordLayoutResult>> {
let mut cache = self
.deterministic_layout_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(layout) = cache.as_ref() {
return Ok(Arc::clone(layout));
}
let input = self.build_layout_input()?;
#[cfg(test)]
record_layout_invocation();
let layout = Arc::new(rdocx_layout::layout_document_deterministic_with_provenance(
&input,
)?);
*cache = Some(Arc::clone(&layout));
Ok(layout)
}
fn layout_for_options(
&self,
options: RenderOptions,
deterministic: bool,
) -> Result<Arc<rdocx_layout::WordLayoutResult>> {
if options.revision_style != rdocx_layout::RevisionStyle::Plain
&& options.revision_view != rdocx_layout::RevisionView::Tracked
{
return Err(Error::Other(
"revision style applies only with tracked view".to_owned(),
));
}
if options.revision_view == rdocx_layout::RevisionView::Accepted {
return if deterministic {
self.cached_deterministic_layout()
} else {
self.cached_layout()
};
}
let mut input = self.build_layout_input()?;
input.revision_view = options.revision_view;
input.revision_style = options.revision_style;
#[cfg(test)]
record_layout_invocation();
let layout = if deterministic {
rdocx_layout::layout_document_deterministic_with_provenance(&input)?
} else {
let mut engine = self
.normal_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let engine = engine.get_or_insert_with(rdocx_layout::engine::Engine::new);
rdocx_layout::layout_document_with_reusable_engine(engine, &input)?
};
Ok(Arc::new(layout))
}
/// Save the document to a file path.
///
/// The package is staged in a synced sibling file and renamed over `path`
/// through [`oxml_opc::write_atomic_file`], so a failed save leaves an
/// existing file as it was. A symbolic link at `path` is kept and the file
/// it names is replaced, and on Unix that file keeps its permission bits.
///
/// A `.docx`, `.docm`, `.dotx`, or `.dotm` extension selects the main
/// part content type, so a template saved as `.docx` declares a document.
/// Any other extension keeps the opened class, and `save_encrypted` and
/// the Flat OPC saves ignore the extension. When the class changes, a
/// main part that carries a VBA project cannot be saved under a
/// macro-free extension, because the project would remain in a file that
/// claims to carry none. [`Document::save_as_package_class`] performs
/// that conversion explicitly and keeps the VBA part.
///
pub fn save<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
self.validate_model_resource_limits()?;
let path = path.as_ref();
if let Some(class) = self.package_class_for_path(path)? {
return self.save_as_package_class(path, class);
}
let mut candidate = self.clone_for_staging();
candidate.prepare_staged_output()?;
crate::embedded::persist_invalidated_package_signature(
&mut candidate.package,
candidate.package_signatures_invalidated,
)?;
candidate.package.save(path)?;
Ok(())
}
/// Save the document to a byte vector.
pub fn to_bytes(&mut self) -> Result<Vec<u8>> {
#[cfg(test)]
eprintln!("FX211_SAVE_STAGE resource-before");
self.validate_model_resource_limits()?;
#[cfg(test)]
eprintln!("FX211_SAVE_STAGE resource-after");
#[cfg(test)]
eprintln!("FX211_SAVE_STAGE clone-before");
let mut candidate = self.clone_for_staging();
#[cfg(test)]
eprintln!("FX211_SAVE_STAGE clone-after");
#[cfg(test)]
eprintln!("FX211_SAVE_STAGE prepare-before");
candidate.prepare_staged_output()?;
#[cfg(test)]
eprintln!("FX211_SAVE_STAGE prepare-after");
crate::embedded::persist_invalidated_package_signature(
&mut candidate.package,
candidate.package_signatures_invalidated,
)?;
let mut buf = std::io::Cursor::new(Vec::new());
candidate.package.write_to(&mut buf)?;
Ok(buf.into_inner())
}
/// Serialize the bytes that [`Document::save`] writes to `path`.
pub fn to_bytes_for_path<P: AsRef<Path>>(&mut self, path: P) -> Result<Vec<u8>> {
match self.package_class_for_path(path.as_ref())? {
Some(class) => self.to_bytes_as(class),
None => self.to_bytes(),
}
}
/// The class a save to `path` converts to, or `None` to keep the opened class.
fn package_class_for_path(&self, path: &Path) -> Result<Option<WordPackageClass>> {
let Some(target) = WordPackageClass::from_path(path) else {
return Ok(None);
};
// Read the override itself: a new compact document has no main part
// bytes until staging, so `package_class` cannot validate it yet.
let current = self
.package
.content_types
.override_for(&self.doc_part_name)
.and_then(WordPackageClass::from_content_type);
if current == Some(target) {
return Ok(None);
}
let carries_vba =
self.package
.get_part_rels(&self.doc_part_name)
.is_some_and(|relationships| {
relationships.get_by_type(rel_types::VBA_PROJECT).is_some()
});
if carries_vba && !target.is_macro_enabled() {
return Err(Error::Other(format!(
"cannot save {}: the document carries a VBA project and the extension names a macro-free class, save it as .docm or .dotm",
path.display()
)));
}
Ok(Some(target))
}
/// Save a password-protected document using the fixed Agile write profile.
#[cfg(all(feature = "agile-encryption", not(target_arch = "wasm32")))]
pub fn save_encrypted<P: AsRef<Path>>(&self, path: P, password: &str) -> Result<()> {
let bytes = self.to_encrypted_bytes(password)?;
write_atomic_file(
path.as_ref(),
&bytes,
"rdocx",
"invalid file name",
"could not allocate encrypted-save staging file",
)?;
Ok(())
}
/// Save a password-protected document to a byte vector.
#[cfg(all(feature = "agile-encryption", not(target_arch = "wasm32")))]
pub fn to_encrypted_bytes(&self, password: &str) -> Result<Vec<u8>> {
self.validate_model_resource_limits()?;
let mut candidate = self.clone_for_staging();
candidate.prepare_staged_output()?;
crate::embedded::persist_invalidated_package_signature(
&mut candidate.package,
candidate.package_signatures_invalidated,
)?;
let mut encrypted = Vec::new();
candidate
.package
.write_encrypted_to(&mut encrypted, password)?;
Ok(encrypted)
}
pub(crate) fn prepare_staged_package(&mut self) -> Result<()> {
#[cfg(test)]
eprintln!("FX211_PREPARE_STAGE preflight-before");
self.preflight_flush_required_bundles()?;
#[cfg(test)]
eprintln!("FX211_PREPARE_STAGE preflight-after");
#[cfg(test)]
eprintln!("FX211_PREPARE_STAGE typed-identifiers-before");
self.canonicalize_typed_identifiers()?;
#[cfg(test)]
eprintln!("FX211_PREPARE_STAGE typed-identifiers-after");
#[cfg(test)]
eprintln!("FX211_PREPARE_STAGE signature-before");
let retained_signature_invalidated =
self.retained_package_signature_would_be_invalidated()?;
#[cfg(test)]
eprintln!("FX211_PREPARE_STAGE signature-after");
#[cfg(test)]
eprintln!("FX211_PREPARE_STAGE related-flush-before");
self.flush_dirty_related_story_models()?;
#[cfg(test)]
eprintln!("FX211_PREPARE_STAGE related-flush-after");
#[cfg(test)]
eprintln!("FX211_PREPARE_STAGE drawing-identifiers-before");
self.canonicalize_drawing_ids()?;
#[cfg(test)]
eprintln!("FX211_PREPARE_STAGE drawing-identifiers-after");
#[cfg(test)]
eprintln!("FX211_PREPARE_STAGE refresh-before");
self.refresh_related_story_caches()?;
#[cfg(test)]
eprintln!("FX211_PREPARE_STAGE refresh-after");
self.package_signatures_invalidated |= retained_signature_invalidated;
#[cfg(test)]
eprintln!("FX211_PREPARE_STAGE flush-before");
let result = self.flush_to_package();
#[cfg(test)]
eprintln!("FX211_PREPARE_STAGE flush-after");
result
}
/// Stage every public output. A comments model that no longer matches its
/// part is written back even when no mutation marked it dirty. An
/// unchanged comments part keeps its producer bytes, as styles and the
/// other modelled parts do, so a save without a comment edit neither
/// rewrites it nor invalidates a package signature over it.
pub(crate) fn prepare_staged_output(&mut self) -> Result<()> {
if let (Some(comments), Some(part_name)) = (&self.comments, &self.comments_part_name) {
self.comments_dirty |= self
.package
.get_part(part_name)
.and_then(|xml| rdocx_oxml::comments::CT_Comments::from_xml(xml).ok())
.as_ref()
!= Some(comments);
}
self.prepare_staged_package()
}
pub(crate) fn prepare_and_reopen_staged(mut self) -> Result<Self> {
self.prepare_staged_package()?;
#[cfg(test)]
if FAIL_NEXT_STAGED_REOPEN.replace(false) {
return self.reopen_prepared_staged_with_limits(PackageReadLimits {
max_entries: 0,
..PackageReadLimits::UNBOUNDED
});
}
self.reopen_prepared_staged()
}
pub(crate) fn reopen_prepared_staged(self) -> Result<Self> {
self.reopen_prepared_staged_with_limits(PackageReadLimits::UNBOUNDED)
}
pub(crate) fn reopen_prepared_staged_with_limits(
self,
limits: PackageReadLimits,
) -> Result<Self> {
let provenance = self.identifiers.clone();
let custom_properties_owned = self.custom_properties_owned;
let settings_owned = self.settings_owned;
let owned_settings = settings_owned.then(|| self.settings.clone());
let web_settings_owned = self.web_settings_owned;
let owned_web_settings = web_settings_owned.then(|| self.web_settings.clone());
let comments_owned = self.comments_owned;
let comments_extended_owned = self.comments_extended_owned;
let embedded_invalidated_signatures = self.embedded_invalidated_signatures.clone();
let package_signatures_invalidated = self.package_signatures_invalidated;
let mut output = std::io::Cursor::new(Vec::new());
self.package.write_to(&mut output)?;
let mut reopened = Self::from_bytes_with_limits(output.get_ref(), limits)?;
reopened
.identifiers
.reject_new_repeated_drawing_ids(&provenance)?;
reopened.identifiers.reconcile_provenance(&provenance);
reopened.custom_properties_owned = custom_properties_owned;
reopened.settings_owned = settings_owned;
if let Some(settings) = owned_settings {
reopened.settings = settings;
}
reopened.web_settings_owned = web_settings_owned;
if let Some(web_settings) = owned_web_settings {
reopened.web_settings = web_settings;
}
reopened.comments_owned = comments_owned;
reopened.comments_extended_owned = comments_extended_owned;
reopened.embedded_invalidated_signatures = embedded_invalidated_signatures;
reopened.package_signatures_invalidated = package_signatures_invalidated;
Ok(reopened)
}
fn preflight_flush_required_bundles(&mut self) -> Result<()> {
self.reserve_styles_bundle()?;
if self.numbering.is_some() {
self.reserve_numbering_bundle()?;
}
if self.footnotes_dirty && !self.footnotes.footnotes.is_empty() {
self.reserve_footnotes_bundle()?;
}
if let Some(part_name) = self.comments_part_name.clone()
&& self.comments.is_some()
{
self.ensure_part_relationship_checked(
&part_name,
rel_types::COMMENTS,
crate::comments::COMMENTS_CONTENT_TYPE,
)
.map_err(|error| {
Error::Other(format!("comments relationship allocation failed: {error}"))
})?;
}
if let Some(part_name) = self.comments_extended_part_name.clone()
&& self.comments_extended.is_some()
{
self.ensure_part_relationship_checked(
&part_name,
crate::comments::COMMENTS_EXTENDED_REL_TYPE,
crate::comments::COMMENTS_EXTENDED_CONTENT_TYPE,
)
.map_err(|error| {
Error::Other(format!(
"comments-extended relationship allocation failed: {error}"
))
})?;
}
if self.core_properties.is_some() {
self.reserve_core_properties_bundle()?;
}
if self.application_properties.is_some() {
self.reserve_application_properties_bundle()?;
}
if self.custom_properties.is_some() {
self.reserve_custom_properties_bundle()?;
}
Ok(())
}
/// Write the in-memory document/styles back into the OPC package parts.
pub(crate) fn flush_to_package(&mut self) -> Result<()> {
self.flush_document_to_package()?;
// Serialize the styles part. A document opened without one still gets
// rdocx's defaults written out, so make sure it is reachable: an
// unreferenced, untyped part would simply be ignored by Word.
let styles_xml = self.styles.to_xml()?;
let styles_part_name = self.styles_part_name.clone();
let styles_part = self
.reserve_document_part_bundle(
styles_part_name.as_deref(),
DEFAULT_STYLES_PART,
rel_types::STYLES,
STYLES_CONTENT_TYPE,
)
.map_err(|error| {
Error::Other(format!("styles relationship allocation failed: {error}"))
})?;
self.styles_part_name = Some(styles_part.clone());
let styles_changed = self
.package
.get_part(&styles_part)
.and_then(|xml| CT_Styles::from_xml(xml).ok())
.as_ref()
!= Some(&self.styles);
if styles_changed {
let styles_xml = if let Some(source) = self.package.get_part(&styles_part) {
retain_styles_root_attributes(source, styles_xml)?
} else {
styles_xml
};
self.package.set_part(&styles_part, styles_xml);
}
// Serialize numbering definitions if we have any
if let Some(numbering_xml) = self
.numbering
.as_ref()
.map(CT_Numbering::to_xml)
.transpose()?
{
let numbering_part_name = self.numbering_part_name.clone();
let numbering_part = self
.reserve_document_part_bundle(
numbering_part_name.as_deref(),
DEFAULT_NUMBERING_PART,
rel_types::NUMBERING,
NUMBERING_CONTENT_TYPE,
)
.map_err(|error| {
Error::Other(format!("numbering relationship allocation failed: {error}"))
})?;
self.numbering_part_name = Some(numbering_part.clone());
let numbering_changed = self
.package
.get_part(&numbering_part)
.and_then(|xml| CT_Numbering::from_xml(xml).ok())
.as_ref()
!= self.numbering.as_ref();
if numbering_changed {
self.package.set_part(&numbering_part, numbering_xml);
}
}
// F-155 exposes settings as a read-only projection. Parsed settings
// retain their complete producer bytes and are written back only to
// the relationship-resolved part they came from.
if let (Some(settings), Some(part_name)) = (&self.settings, &self.settings_part_name) {
let changed = self
.package
.get_part(part_name)
.and_then(|xml| CT_Settings::from_xml(xml).ok())
.as_ref()
!= Some(settings);
if changed {
self.package.set_part(part_name, settings.to_xml()?);
}
}
if let (Some(web_settings), Some(part_name)) =
(&self.web_settings, &self.web_settings_part_name)
{
let changed = self
.package
.get_part(part_name)
.and_then(|xml| CT_WebSettings::from_xml(xml).ok())
.as_ref()
!= Some(web_settings);
if changed {
self.package.set_part(part_name, web_settings.to_xml()?);
}
}
if self.theme_dirty {
let theme = self
.theme
.as_ref()
.ok_or_else(|| Error::Other("dirty theme model is missing".to_owned()))?;
let part_name = self
.theme_part_name
.as_ref()
.ok_or_else(|| Error::Other("dirty theme part name is missing".to_owned()))?;
self.package.set_part(
part_name,
theme.to_xml().map_err(|error| {
Error::Other(format!("theme serialization failed: {error}"))
})?,
);
}
if self.font_table_dirty {
let table = self
.font_table
.as_ref()
.ok_or_else(|| Error::Other("dirty font-table model is missing".to_owned()))?;
let part_name = self
.font_table_part_name
.as_ref()
.ok_or_else(|| Error::Other("dirty font-table part name is missing".to_owned()))?;
self.package.set_part(part_name, table.to_xml()?);
}
self.flush_dirty_related_story_models()?;
if let (Some(comments), Some(part_name)) = (
&self.comments_extended,
self.comments_extended_part_name.clone(),
) {
let changed = self
.package
.get_part(&part_name)
.and_then(|xml| rdocx_oxml::comments_extended::CT_CommentsEx::from_xml(xml).ok())
.as_ref()
!= Some(comments);
if changed {
self.package.set_part(&part_name, comments.to_xml()?);
}
self.ensure_part_relationship_checked(
&part_name,
crate::comments::COMMENTS_EXTENDED_REL_TYPE,
crate::comments::COMMENTS_EXTENDED_CONTENT_TYPE,
)
.map_err(|error| {
Error::Other(format!(
"comments-extended relationship allocation failed: {error}"
))
})?;
}
if self.glossary_dirty {
let glossary = self
.glossary
.as_ref()
.ok_or_else(|| Error::Other("dirty glossary model is missing".to_owned()))?;
let part_name = self
.glossary_part_name
.as_ref()
.ok_or_else(|| Error::Other("dirty glossary part name is missing".to_owned()))?;
self.package.set_part(part_name, glossary.to_xml()?);
}
// Serialize core properties to the package relationship's target.
if let Some(core_xml) = self
.core_properties
.as_deref()
.map(CoreProperties::to_xml)
.transpose()?
{
let core_part = self.reserve_core_properties_bundle()?;
let changed = self
.package
.get_part(&core_part)
.and_then(|xml| CoreProperties::from_xml(xml).ok())
.as_ref()
!= self.core_properties.as_deref();
if changed {
self.package.set_part(&core_part, core_xml);
}
}
if let Some(application_xml) = self
.application_properties
.as_ref()
.map(AppProperties::to_xml)
.transpose()?
{
let application_part = self.reserve_application_properties_bundle()?;
let changed = self
.package
.get_part(&application_part)
.and_then(|xml| AppProperties::from_xml(xml).ok())
.as_ref()
!= self.application_properties.as_ref();
if changed {
self.package.set_part(&application_part, application_xml);
}
}
if let Some(custom_xml) = self
.custom_properties
.as_ref()
.map(CustomProperties::to_xml)
.transpose()?
{
let custom_part = self.reserve_custom_properties_bundle()?;
let changed = self
.package
.get_part(&custom_part)
.and_then(|xml| CustomProperties::from_xml(xml).ok())
.as_ref()
!= self.custom_properties.as_ref();
if changed {
self.package.set_part(&custom_part, custom_xml);
}
}
Ok(())
}
pub(crate) fn flush_dirty_related_story_models(&mut self) -> Result<()> {
// Existing footnote XML remains the authority. Typed additions are
// inserted into that root once so producer namespace declarations and
// unmodelled root children never pass through the lossy typed model.
if self.footnotes_dirty && !self.footnotes.footnotes.is_empty() {
let footnotes_part_name = self.footnotes_part_name.clone();
let footnotes_part = self
.reserve_document_part_bundle(
footnotes_part_name.as_deref(),
"/word/footnotes.xml",
rel_types::FOOTNOTES,
"application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
)
.map_err(|error| {
Error::Other(format!("footnotes relationship allocation failed: {error}"))
})?;
self.footnotes_part_name = Some(footnotes_part.clone());
let xml = if let Some(existing_xml) = self.package.get_part(&footnotes_part) {
let existing = rdocx_oxml::footnotes::CT_Footnotes::from_xml(existing_xml)?;
let additions = self
.footnotes
.footnotes
.iter()
.filter(|footnote| {
existing.footnotes.iter().all(|candidate| {
candidate.id != footnote.id || candidate.note_type != footnote.note_type
})
})
.map(serialized_footnote_fragment)
.collect::<Result<Vec<_>>>()?;
if existing.footnotes.len() + additions.len() != self.footnotes.footnotes.len() {
return Err(Error::Other(
"typed footnote mutation is not an additive package update".to_owned(),
));
}
append_story_fragments_to_root(existing_xml, b"footnotes", b"footnote", &additions)?
} else {
let mut footnotes = self.footnotes.clone();
for footnote in &mut footnotes.footnotes {
for paragraph in &mut footnote.paragraphs {
close_typed_story_drawing_namespaces(paragraph)?;
}
}
footnotes.to_xml_footnotes()?
};
self.package.set_part(&footnotes_part, xml);
self.footnotes_dirty = false;
}
// An existing comments part is modelled and flushed to its resolved
// relationship target. An absent part remains absent until the later
// comment API creates one deliberately.
if self.comments_dirty
&& let (Some(comments), Some(part_name)) =
(&self.comments, self.comments_part_name.clone())
{
let mut comments = comments.clone();
for comment in &mut comments.comments {
for paragraph in &mut comment.paragraphs {
close_typed_story_drawing_namespaces(paragraph)?;
}
}
let xml = comments.to_xml()?;
self.package.set_part(&part_name, xml);
self.comments_dirty = false;
self.ensure_part_relationship_checked(
&part_name,
rel_types::COMMENTS,
crate::comments::COMMENTS_CONTENT_TYPE,
)
.map_err(|error| {
Error::Other(format!("comments relationship allocation failed: {error}"))
})?;
}
Ok(())
}
fn flush_document_to_package(&mut self) -> Result<()> {
// A read-only save with unsafe retained namespace shadows keeps the
// producer's complete scopes and every retained raw subtree byte for
// byte. A modified document fails closed when canonical serialization
// could change the meaning or bytes of retained content.
let existing_document_xml = self.package.get_part(&self.doc_part_name);
#[cfg(test)]
let typed_document_is_unchanged = {
eprintln!("FX211_FLUSH_STAGE reparse-before");
let parsed = existing_document_xml.and_then(|xml| CT_Document::from_xml(xml).ok());
eprintln!("FX211_FLUSH_STAGE reparse-after");
eprintln!("FX211_FLUSH_STAGE equality-before");
let unchanged = parsed.as_ref() == Some(&self.document);
eprintln!("FX211_FLUSH_STAGE equality-after");
eprintln!("FX211_FLUSH_STAGE temporary-drop-before");
drop(parsed);
eprintln!("FX211_FLUSH_STAGE temporary-drop-after");
unchanged
};
#[cfg(not(test))]
let typed_document_is_unchanged = existing_document_xml
.and_then(|xml| CT_Document::from_xml(xml).ok())
.as_ref()
== Some(&self.document);
#[cfg(test)]
eprintln!("FX211_FLUSH_STAGE nested-scopes-before");
let nested_namespace_owners = existing_document_xml
.map(nested_modeled_namespace_owners)
.transpose()?
.unwrap_or_default();
#[cfg(test)]
eprintln!("FX211_FLUSH_STAGE nested-scopes-after");
let unsafe_prefix = unsafe_serializer_namespace_prefix(
&self.root_namespace_declarations,
&self.body_namespace_declarations,
existing_document_xml,
)
.or_else(|| unsafe_nested_namespace_prefix(&nested_namespace_owners));
let doc_xml = if typed_document_is_unchanged {
existing_document_xml
.expect("compared existing document XML")
.to_vec()
} else {
if let Some(prefix) = unsafe_prefix {
return Err(Error::Other(format!(
"cannot serialize a modified document with a shadowed `{prefix}` namespace"
)));
}
#[cfg(test)]
eprintln!("FX211_FLUSH_STAGE serialize-before");
let serialized = self.document.to_xml()?;
#[cfg(test)]
eprintln!("FX211_FLUSH_STAGE serialize-after");
replay_nested_namespace_declarations(&serialized, &nested_namespace_owners)?
};
let namespace_scopes = document_namespace_scopes(&doc_xml)?;
self.package.set_part(&self.doc_part_name, doc_xml);
self.root_namespace_declarations = namespace_scopes.root_declarations;
self.body_namespace_declarations = namespace_scopes.body_declarations;
self.body_namespace_bindings = namespace_scopes.body_bindings;
Ok(())
}
fn refresh_related_story_caches(&mut self) -> Result<()> {
if let Some(part_name) = self.footnotes_part_name.as_deref()
&& let Some(xml) = self.package.get_part(part_name)
{
self.footnotes = rdocx_oxml::footnotes::CT_Footnotes::from_xml(xml)?;
self.footnotes_dirty = false;
}
if let Some(part_name) = self.comments_part_name.as_deref()
&& let Some(xml) = self.package.get_part(part_name)
{
self.comments = Some(rdocx_oxml::comments::CT_Comments::from_xml(xml)?);
self.comments_dirty = false;
}
Ok(())
}
/// Make sure `part_name` is reachable from the main document: it needs a
/// relationship of `rel_type` and a content-type override.
pub(crate) fn ensure_part_relationship_checked(
&mut self,
part_name: &str,
rel_type: &str,
content_type: &str,
) -> Result<()> {
let doc_part_name = self.doc_part_name.clone();
let already_linked =
self.package
.get_part_rels(&doc_part_name)
.is_some_and(|relationships| {
relationships.items.iter().any(|relationship| {
relationship.rel_type == rel_type
&& relationship_is_internal(relationship)
&& OpcPackage::resolve_rel_target(&doc_part_name, &relationship.target)
== part_name
})
});
let pending_id = if already_linked {
None
} else {
Some(
self.identifiers
.reserve_relationship_id_checked(&doc_part_name)?,
)
};
self.package
.content_types
.add_override(part_name, content_type);
self.identifiers.register_content_type_override(part_name);
if let Some(id) = pending_id {
// Relationship targets are relative to the source part's directory.
let target = relative_target(&doc_part_name, part_name);
self.package
.get_or_create_part_rels(&doc_part_name)
.add_with_id(&id, rel_type, &target);
if bundle_relationship_order(rel_type).is_some() {
self.identifiers
.authored_bundle_relationship_ids
.entry(relationship_owner_identity(&doc_part_name))
.or_default()
.insert(id);
}
}
Ok(())
}
pub(crate) fn reserve_document_part_bundle(
&mut self,
existing: Option<&str>,
preferred: &str,
rel_type: &str,
content_type: &str,
) -> Result<String> {
self.identifiers.observe_package_graph(&self.package)?;
let part_name = match existing {
Some(part_name) => part_name.to_owned(),
None => self.identifiers.reserve_preferred_part_name(preferred)?,
};
let owner = self.doc_part_name.clone();
let already_linked = self
.package
.get_part_rels(&owner)
.is_some_and(|relationships| {
relationships.items.iter().any(|relationship| {
relationship.rel_type == rel_type
&& relationship_is_internal(relationship)
&& OpcPackage::resolve_rel_target(&owner, &relationship.target) == part_name
})
});
let pending_id = if already_linked {
None
} else {
Some(
self.identifiers
.reserve_bundle_relationship_id_checked(&owner, rel_type)?,
)
};
self.package
.content_types
.add_override(&part_name, content_type);
self.identifiers.register_content_type_override(&part_name);
if let Some(id) = pending_id {
let target = relative_target(&owner, &part_name);
self.package
.get_or_create_part_rels(&owner)
.add_with_id(&id, rel_type, &target);
}
Ok(part_name)
}
fn reserve_core_properties_bundle(&mut self) -> Result<String> {
let existing = self.core_properties_part_name.clone();
let part_name = self.reserve_package_properties_bundle(
existing.as_deref(),
DEFAULT_CORE_PROPERTIES_PART,
rel_types::CORE_PROPERTIES,
content_types::CORE_PROPERTIES,
)?;
self.core_properties_part_name = Some(part_name.clone());
Ok(part_name)
}
fn reserve_application_properties_bundle(&mut self) -> Result<String> {
let existing = self.application_properties_part_name.clone();
let part_name = self.reserve_package_properties_bundle(
existing.as_deref(),
DEFAULT_APP_PROPERTIES_PART,
rel_types::EXTENDED_PROPERTIES,
content_types::EXTENDED_PROPERTIES,
)?;
self.application_properties_part_name = Some(part_name.clone());
Ok(part_name)
}
fn reserve_custom_properties_bundle(&mut self) -> Result<String> {
let existing = self.custom_properties_part_name.clone();
let part_name = self.reserve_package_properties_bundle(
existing.as_deref(),
"/docProps/custom.xml",
rel_types::CUSTOM_PROPERTIES,
content_types::CUSTOM_PROPERTIES,
)?;
self.custom_properties_part_name = Some(part_name.clone());
Ok(part_name)
}
fn reserve_package_properties_bundle(
&mut self,
existing: Option<&str>,
preferred: &str,
rel_type: &str,
content_type: &str,
) -> Result<String> {
self.identifiers.observe_package_graph(&self.package)?;
let part_name = match existing {
Some(part_name) => part_name.to_owned(),
None => self.identifiers.reserve_preferred_part_name(preferred)?,
};
let already_linked = self.package.package_rels.items.iter().any(|relationship| {
relationship.rel_type == rel_type
&& relationship_is_internal(relationship)
&& OpcPackage::resolve_rel_target("/", &relationship.target) == part_name
});
let pending_id = if already_linked {
None
} else {
Some(self.identifiers.reserve_relationship_id_checked("/")?)
};
self.package
.content_types
.add_override(&part_name, content_type);
self.identifiers.register_content_type_override(&part_name);
if let Some(id) = pending_id {
let target = part_name.strip_prefix('/').unwrap_or(&part_name);
self.package.package_rels.add_with_id(&id, rel_type, target);
}
Ok(part_name)
}
pub(crate) fn add_internal_relationship_checked(
&mut self,
owner: &str,
rel_type: &str,
target: &str,
) -> Result<String> {
let id = self.identifiers.reserve_relationship_id_checked(owner)?;
self.package
.get_or_create_part_rels(owner)
.add_with_id(&id, rel_type, target);
Ok(id)
}
pub(crate) fn add_relative_internal_relationship_checked(
&mut self,
owner: &str,
rel_type: &str,
target_part: &str,
) -> Result<String> {
let id = self.add_internal_relationship_checked(
owner,
rel_type,
&relative_target(owner, target_part),
)?;
if owner != self.doc_part_name {
self.identifiers
.authored_story_relationship_ids
.entry(relationship_owner_identity(owner))
.or_default()
.insert(id.clone());
}
Ok(id)
}
fn add_image_relationship_checked(
&mut self,
owner: &str,
image_data: &[u8],
filename: &str,
) -> Result<String> {
let (part_name, format) = self.reserve_image_part(image_data, filename)?;
self.install_reserved_image_part(&part_name, image_data, format);
self.add_relative_internal_relationship_checked(owner, rel_types::IMAGE, &part_name)
}
pub(crate) fn add_external_relationship_checked(
&mut self,
owner: &str,
rel_type: &str,
target: &str,
) -> Result<String> {
let id = self.identifiers.reserve_relationship_id_checked(owner)?;
self.package.get_or_create_part_rels(owner).items.push(
oxml_opc::relationship::Relationship {
id: id.clone(),
rel_type: rel_type.to_owned(),
target: target.to_owned(),
target_mode: Some("External".to_owned()),
},
);
if owner != self.doc_part_name {
self.identifiers
.authored_story_relationship_ids
.entry(relationship_owner_identity(owner))
.or_default()
.insert(id.clone());
}
Ok(id)
}
pub(crate) fn add_html_image_relationship_to_story(
&mut self,
story: &StoryId,
image_data: &[u8],
filename: &str,
) -> Result<String> {
self.story_source_and_owner(story)?;
let owner = story.part_name.clone();
let relationship_id = self.add_image_relationship_checked(&owner, image_data, filename)?;
if owner == self.doc_part_name && story.kind != StoryKind::Body {
self.identifiers
.register_nested_story_relationship(story, relationship_id.clone());
}
Ok(relationship_id)
}
pub(crate) fn add_html_hyperlink_relationship_to_story(
&mut self,
story: &StoryId,
url: &str,
) -> Result<String> {
self.story_source_and_owner(story)?;
let owner = story.part_name.clone();
let relationship_id =
self.add_external_relationship_checked(&owner, rel_types::HYPERLINK, url)?;
if owner == self.doc_part_name && story.kind != StoryKind::Body {
self.identifiers
.register_nested_story_relationship(story, relationship_id.clone());
}
Ok(relationship_id)
}
pub(crate) fn reserve_html_drawing_id(&mut self, story: &StoryId) -> Result<u32> {
self.story_source_and_owner(story)?;
let drawing_id = self.identifiers.reserve_drawing_id()?;
if story.part_name == self.doc_part_name && story.kind != StoryKind::Body {
self.identifiers
.register_nested_story_drawing(story, drawing_id);
}
Ok(drawing_id)
}
/// Stage a chart, its editable workbook, and the Word drawing that reaches them.
fn add_chart_package(
&mut self,
source: ChartPackageSource<'_>,
width: Length,
height: Length,
) -> Result<()> {
if width.to_emu() <= 0 || height.to_emu() <= 0 {
return Err(Error::Other(
"chart width and height must be positive".to_owned(),
));
}
let mut candidate = self.clone_for_staging();
candidate
.identifiers
.observe_package_graph(&candidate.package)?;
if matches!(&source, ChartPackageSource::Authored { .. }) {
candidate.ensure_authored_chart_theme()?;
}
let chart_part = candidate
.identifiers
.reserve_part_name("/word/charts", "chart", "xml")?;
let workbook_part =
candidate
.identifiers
.reserve_part_name("/word/embeddings", "Workbook", "xlsx")?;
let document_part = candidate.doc_part_name.clone();
let document_relationship_id = candidate
.add_relative_internal_relationship_checked(
&document_part,
rel_types::CHART,
&chart_part,
)
.map_err(|error| {
Error::Other(format!("chart relationship allocation failed: {error}"))
})?;
let workbook_relationship_id = candidate
.identifiers
.reserve_relationship_id_checked(&chart_part)
.map_err(|error| {
Error::Other(format!(
"chart workbook relationship allocation failed: {error}"
))
})?;
candidate
.package
.get_or_create_part_rels(&chart_part)
.add_with_id(
&workbook_relationship_id,
rel_types::PACKAGE,
&relative_target(&chart_part, &workbook_part),
);
let (chart_xml, workbook_bytes) = match source {
ChartPackageSource::Typed { chart, workbook } => (
chart_with_workbook_relationship(chart, &workbook_relationship_id)?,
workbook
.to_xlsx_bytes()
.map_err(|error| Error::Other(format!("invalid chart workbook: {error}")))?,
),
ChartPackageSource::Authored { kind, data } => {
oxml_chart::authored_chart_parts(kind, data, &workbook_relationship_id)
.map_err(|error| Error::Other(format!("invalid chart data: {error}")))?
}
};
let mut inline =
CT_Inline::new_chart(&document_relationship_id, width.to_emu(), height.to_emu());
inline.doc_pr_id = candidate.identifiers.reserve_drawing_id()?;
let drawing = CT_Drawing::inline(inline);
let run = CT_R {
alt_drawings: Vec::new(),
properties: None,
content: vec![RunContent::Drawing(drawing)],
extra_xml: Vec::new(),
extra_xml_positions: Vec::new(),
};
let mut paragraph = CT_P::new();
paragraph.runs.push(run);
candidate
.document
.body
.content
.push(BodyContent::Paragraph(paragraph));
candidate.document.to_xml()?;
candidate.package.set_part(&chart_part, chart_xml);
candidate.package.set_part(&workbook_part, workbook_bytes);
candidate
.package
.content_types
.add_override(&chart_part, content_types::CHART);
candidate
.identifiers
.register_content_type_override(&chart_part);
candidate
.package
.content_types
.add_override(&workbook_part, content_types::EMBEDDED_WORKBOOK);
candidate
.identifiers
.register_content_type_override(&workbook_part);
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(())
}
fn ensure_authored_chart_theme(&mut self) -> Result<()> {
let document_part = self.doc_part_name.clone();
let reusable = self
.package
.get_part_rels(&document_part)
.and_then(|relationships| {
relationships.items.iter().find(|relationship| {
relationship.rel_type == rel_types::THEME
&& relationship_is_internal(relationship)
})
})
.and_then(|relationship| {
let part_name =
OpcPackage::resolve_rel_target(&document_part, &relationship.target);
if self.package.content_types.content_type_for(&part_name)
!= Some(content_types::THEME)
{
return None;
}
let bytes = if self.theme_dirty
&& self.theme_part_name.as_deref() == Some(part_name.as_str())
{
self.theme.as_ref()?.to_xml().ok()?
} else {
self.package.get_part(&part_name)?.to_vec()
};
let theme = oxml_drawing::theme::CT_OfficeStyleSheet::from_xml(&bytes).ok()?;
Some((part_name, theme, bytes))
});
if let Some((part_name, theme, bytes)) = reusable {
if self.theme_dirty && self.theme_part_name.as_deref() == Some(part_name.as_str()) {
self.package.set_part(&part_name, bytes);
}
self.theme = Some(theme);
self.theme_part_name = Some(part_name);
return Ok(());
}
let theme = oxml_drawing::theme::CT_OfficeStyleSheet::office_default();
let theme_xml = theme
.to_xml()
.map_err(|error| Error::Other(format!("invalid default Office theme: {error}")))?;
self.identifiers.observe_package_graph(&self.package)?;
let theme_part = self
.identifiers
.reserve_preferred_part_name(DEFAULT_THEME_PART)?;
self.package.set_part(&theme_part, theme_xml);
self.package
.content_types
.add_override(&theme_part, content_types::THEME);
self.identifiers.register_content_type_override(&theme_part);
let target = relative_target(&document_part, &theme_part);
let relationships = self.package.get_or_create_part_rels(&document_part);
if let Some(relationship) = relationships
.items
.iter_mut()
.find(|relationship| relationship.rel_type == rel_types::THEME)
{
relationship.target = target;
relationship.target_mode = None;
} else {
let id = self
.identifiers
.reserve_relationship_id_checked(&document_part)?;
relationships.add_with_id(&id, rel_types::THEME, &target);
}
self.theme = Some(theme);
self.theme_part_name = Some(theme_part);
self.theme_dirty = false;
Ok(())
}
pub(crate) fn generated_table_paragraph_positions(
&self,
) -> Result<Vec<(ContentLocation, std::ops::Range<usize>)>> {
let mut result = Vec::new();
for story in self.stories()? {
if story.kind == StoryKind::Comment {
continue;
}
let (source, _) = self.story_source_and_owner(&story)?;
let xml = self
.package
.get_part(&source.part_name)
.unwrap_or(source.xml.as_ref());
let owner = scan_story_owners(xml, source.root_kind)?
.into_iter()
.find(|owner| owner.kind == story.kind && owner.owner_index == story.owner_index)
.ok_or_else(|| {
Error::Other(
"generated paragraph owner disappeared from retained source".into(),
)
})?;
for (index, item) in scan_story_items(xml, &owner)?.iter().enumerate() {
let spans = match item.kind {
StoryItemKind::Paragraph => vec![(vec![index], item.full.clone())],
StoryItemKind::ContentControl => scan_story_control_paragraphs(xml, item)?
.into_iter()
.enumerate()
.map(|(paragraph, span)| (vec![index, paragraph], span))
.collect(),
_ => continue,
};
for (path, range) in spans {
let location =
ContentLocation::new(story.clone(), StoryItemKind::Paragraph, path);
self.story_range_paragraph_source(&location)?;
result.push((location, range));
}
}
}
result.sort_by_key(|(location, range)| {
(location.story().part_name().to_owned(), range.start)
});
Ok(result)
}
/// Exact physical story-owner content, from the existing checked inventory.
pub(crate) fn generated_table_story_sources(&self) -> Result<Vec<GeneratedStoryOwner>> {
let mut result = Vec::new();
let mut seen = HashSet::new();
for source in self.story_sources()? {
let xml = if source.part_name == self.doc_part_name {
self.package
.get_part(&source.part_name)
.unwrap_or(source.xml.as_ref())
} else {
source.xml.as_ref()
};
for owner in scan_story_owners(xml, source.root_kind)? {
if matches!(owner.kind, StoryKind::Comment | StoryKind::TableCell)
|| !seen.insert((owner.kind, source.part_name.clone(), owner.owner_index))
{
continue;
}
let mut reader = quick_xml::Reader::from_reader(&xml[owner.full.clone()]);
let mut buffer = Vec::new();
let start = match reader.read_event_into(&mut buffer).map_err(|error| {
Error::Other(format!("story owner start scan failed: {error}"))
})? {
Event::Start(_) => owner.full.start + reader.buffer_position() as usize,
Event::Empty(_) => continue,
_ => return Err(Error::Other("story owner has no start element".into())),
};
let end = story_owner_content_end(xml, &owner)?;
let scope = story_namespace_scope_at(xml, start)?;
result.push(GeneratedStoryOwner {
story: StoryId {
kind: owner.kind,
part_name: source.part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
},
range: start..end,
xml: xml.to_vec(),
namespaces: scope,
});
}
}
Ok(result)
}
fn story_sources(&self) -> Result<Vec<StorySource<'_>>> {
#[cfg(test)]
STORY_SOURCE_BUILDS.set(STORY_SOURCE_BUILDS.get() + 1);
let mut sources = vec![StorySource {
root_kind: StoryKind::Body,
part_name: self.doc_part_name.clone(),
xml: Cow::Owned(self.document.to_xml()?),
}];
let mut seen = HashSet::new();
seen.insert((StoryKind::Body, self.doc_part_name.clone()));
for (relationship_id, is_header) in self.header_footer_rel_ids() {
let kind = if is_header {
StoryKind::Header
} else {
StoryKind::Footer
};
let part_name = self
.header_footer_part_name(&relationship_id, is_header)
.ok_or_else(|| {
Error::Other(format!(
"{kind:?} reference {relationship_id} has no valid internal relationship"
))
})?;
if seen.insert((kind, part_name.clone())) {
let xml = self.package.get_part(&part_name).ok_or_else(|| {
Error::Other(format!("{kind:?} story targets missing part {part_name}"))
})?;
sources.push(StorySource {
root_kind: kind,
part_name,
xml: Cow::Borrowed(xml),
});
}
}
if let Some(relationships) = self.package.get_part_rels(&self.doc_part_name) {
for relationship in &relationships.items {
let kind = match relationship.rel_type.as_str() {
rel_types::FOOTNOTES => StoryKind::Footnote,
rel_types::ENDNOTES => StoryKind::Endnote,
rel_types::COMMENTS => StoryKind::Comment,
_ => continue,
};
if !relationship_is_internal(relationship) {
continue;
}
let part_name =
OpcPackage::resolve_rel_target(&self.doc_part_name, &relationship.target);
if !seen.insert((kind, part_name.clone())) {
return Err(Error::Other(format!(
"{kind:?} story part {part_name} is referenced more than once"
)));
}
let part_xml = self.package.get_part(&part_name).ok_or_else(|| {
Error::Other(format!("{kind:?} story targets missing part {part_name}"))
})?;
let xml = if kind == StoryKind::Comment {
Cow::Owned(part_xml.to_vec())
} else {
Cow::Borrowed(part_xml)
};
sources.push(StorySource {
root_kind: kind,
part_name,
xml,
});
}
}
Ok(sources)
}
/// Discover every supported Word story owner in stable document and
/// relationship order.
pub fn stories(&self) -> Result<Vec<StoryId>> {
let mut stories = Vec::new();
let mut seen = HashSet::new();
for source in self.story_sources()? {
for owner in scan_story_owners(&source.xml, source.root_kind)? {
let identity = (owner.kind, source.part_name.clone(), owner.owner_index);
if seen.insert(identity) {
stories.push(StoryId {
kind: owner.kind,
part_name: source.part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
});
}
}
}
Ok(stories)
}
fn story_source_and_owner<'a>(
&'a self,
story: &StoryId,
) -> Result<(StorySource<'a>, StoryOwnerSpan)> {
let mut sources = self.story_sources()?;
let source = sources.iter().position(|source| {
source.part_name == story.part_name
&& (source.root_kind == story.kind
|| matches!(story.kind, StoryKind::TableCell | StoryKind::TextBox))
});
let source = match source {
Some(index) => sources.swap_remove(index),
None => {
if let Some(actual) = sources
.iter()
.find(|source| source.part_name == story.part_name)
.map(|source| source.root_kind)
{
return Err(StoryError::WrongOwner {
expected: story.kind,
actual,
}
.into());
}
return Err(StoryError::OwnerNotFound {
story: story.clone(),
}
.into());
}
};
let owner = scan_story_owners(&source.xml, source.root_kind)?
.into_iter()
.find(|owner| owner.kind == story.kind && owner.owner_index == story.owner_index)
.ok_or_else(|| StoryError::OwnerNotFound {
story: story.clone(),
})?;
if story.fingerprint != owner.fingerprint {
return Err(StoryError::Stale {
story: story.clone(),
}
.into());
}
Ok((source, owner))
}
/// Bind an existing control to a snapshot-checked placeholder entry.
pub fn bind_building_block_placeholder(
&mut self,
control: &ContentLocation,
entry: &crate::BuildingBlockInfo,
) -> Result<()> {
self.checked_building_block(entry)?;
if entry.block.gallery.as_deref() != Some("placeholder") {
return Err(Error::Other(
"placeholder binding requires the placeholder gallery".to_owned(),
));
}
if control.item_kind != StoryItemKind::ContentControl
|| control.index_path.len() != 1
|| control.is_end
{
return Err(Error::Other(
"placeholder binding requires an existing content control location".to_owned(),
));
}
let mut candidate = self.clone_for_staging();
candidate.prepare_staged_package()?;
let (source, owner) = candidate.fragment_story_source_and_owner(&control.story)?;
let part = source.part_name.clone();
let mut xml = source.xml.into_owned();
let item = scan_story_items(&xml, &owner)?
.get(control.index_path[0])
.cloned()
.ok_or_else(|| Error::Other("stale content control index".to_owned()))?;
if item.kind != StoryItemKind::ContentControl {
return Err(Error::Other(
"content control location changed kind".to_owned(),
));
}
let scope = story_namespace_scope_at(&xml, item.full.start)?;
let raw = close_content_fragment_namespaces(&xml[item.full.clone()], &scope)?;
let updated = crate::building_block::bind_placeholder_xml(&raw, entry)?;
xml.splice(item.full, updated);
set_story_source_xml(&mut candidate, &part, xml)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
pub(crate) fn preserve_glossary_drawing_ids_staged(&mut self) -> Result<()> {
if let Some(glossary) = &self.glossary {
let ids = drawing_ids_in_xml(&glossary.to_xml()?)?;
self.identifiers.drawing_ids.extend(ids.iter().copied());
self.identifiers.preserved_drawing_ids.extend(ids);
}
Ok(())
}
pub(crate) fn project_fragment_part_content(
&mut self,
source_part: &str,
mut selected: Vec<u8>,
) -> Result<()> {
if source_part != self.doc_part_name {
let source_rels = self
.package
.get_part_rels(source_part)
.cloned()
.unwrap_or_default();
let mut relationship_map = BTreeMap::new();
for relationship_id in crate::field::relationship_ids_in_xml(&selected)? {
let relationship = source_rels.get_by_id(&relationship_id).ok_or_else(|| {
Error::Other(format!(
"fragment source relationship {relationship_id} is missing from {source_part}"
))
})?;
let destination_part = self.doc_part_name.clone();
let new_id = if relationship_is_internal(relationship) {
let target_part =
OpcPackage::resolve_rel_target(source_part, &relationship.target);
self.add_internal_relationship_checked(
&destination_part,
&relationship.rel_type,
&relative_target(&destination_part, &target_part),
)?
} else {
self.add_external_relationship_checked(
&destination_part,
&relationship.rel_type,
&relationship.target,
)?
};
relationship_map.insert(relationship_id, new_id);
}
selected = crate::field::patch_relationship_ids(&selected, &relationship_map)?;
}
let main_xml = self
.package
.get_part(&self.doc_part_name)
.ok_or_else(|| Error::Other("document fragment main part is missing".to_owned()))?
.to_vec();
let main_owner = scan_story_owners(&main_xml, StoryKind::Body)?
.into_iter()
.find(|owner| owner.kind == StoryKind::Body)
.ok_or_else(|| Error::Other("document fragment main body is missing".to_owned()))?;
let main_items = direct_story_content_items(&main_xml, &main_owner)?;
let main_end = story_owner_content_end(&main_xml, &main_owner)?;
let main_start = main_items.first().map_or(main_end, |item| item.full.start);
let mut updated = main_xml;
updated.splice(main_start..main_end, selected);
self.package.set_part(&self.doc_part_name, updated);
Ok(())
}
fn fragment_story_source_and_owner<'a>(
&'a self,
story: &StoryId,
) -> Result<(StorySource<'a>, StoryOwnerSpan)> {
let (canonical, _) = self.story_source_and_owner(story)?;
let xml = self.package.get_part(&canonical.part_name).ok_or_else(|| {
Error::Other(format!(
"document fragment story part {} is missing",
canonical.part_name
))
})?;
let owner = scan_story_owners(xml, canonical.root_kind)?
.into_iter()
.find(|owner| owner.kind == story.kind && owner.owner_index == story.owner_index)
.ok_or_else(|| StoryError::OwnerNotFound {
story: story.clone(),
})?;
Ok((
StorySource {
xml: Cow::Borrowed(xml),
..canonical
},
owner,
))
}
pub(crate) fn story_paragraph_mut(&mut self, location: &ContentLocation) -> Result<&mut CT_P> {
let (part_name, paragraph_slot, ordinal, cell_route) = {
let (source, owner) = self.story_source_and_owner(&location.story)?;
let part_name = source.part_name.clone();
let source_xml = source.xml.into_owned();
// A paragraph inside a block content control of the body is the
// control item followed by the paragraph's position among the
// paragraphs of that control.
let (item_index, ordinal) = match location.index_path.as_slice() {
[item_index] => (*item_index, None),
[item_index, ordinal] if location.story.kind == StoryKind::Body => {
(*item_index, Some(*ordinal))
}
_ => {
return Err(StoryError::InvalidPath {
path: location.index_path.clone(),
}
.into());
}
};
let items = scan_story_items(&source_xml, &owner)?;
let item = items.get(item_index).ok_or(StoryError::OutOfBounds {
index: item_index,
len: items.len(),
})?;
if location.item_kind != StoryItemKind::Paragraph {
return Err(Error::Other(
"comment positions must identify paragraphs".to_owned(),
));
}
if ordinal.is_some()
&& (item.kind != StoryItemKind::ContentControl || !item.direct_owner_child)
{
return Err(Error::Other(
"a two-segment comment position must start with a block content control"
.to_owned(),
));
}
if ordinal.is_none() && item.kind != location.item_kind {
return Err(StoryError::KindMismatch {
expected: location.item_kind,
actual: item.kind,
}
.into());
}
// A paragraph item is a direct child of its owner. In the body it
// resolves to its direct body slot, the count of direct items
// before it, and in a cell to its position among the cell's
// direct paragraphs. Neither counts the paragraphs inside a block
// content control. The body section properties are serialized
// last, so they never precede a paragraph.
let preceding = items[..item_index].iter();
let paragraph_slot = if location.story.kind == StoryKind::Body {
preceding.filter(|item| item.direct_owner_child).count()
} else {
preceding
.filter(|item| item.kind == StoryItemKind::Paragraph)
.count()
};
let cell_route = (location.story.kind == StoryKind::TableCell
&& part_name == self.doc_part_name)
.then(|| modeled_main_cell_route(&source_xml, &owner))
.transpose()?;
(part_name, paragraph_slot, ordinal, cell_route)
};
if part_name != self.doc_part_name {
return Err(Error::Other(
"path-aware comments currently support the main document story".to_owned(),
));
}
match location.story.kind {
StoryKind::Body => {
match (self.document.body.content.get_mut(paragraph_slot), ordinal) {
(Some(BodyContent::Paragraph(paragraph)), None) => Ok(paragraph),
(Some(BodyContent::ContentControl(control)), Some(ordinal)) => control
.content
.iter_mut()
.filter_map(|child| match child {
SdtContent::Paragraph(paragraph) => Some(paragraph),
_ => None,
})
.nth(ordinal)
.ok_or_else(|| {
Error::Other(format!(
"comment content control has no paragraph {ordinal}"
))
}),
_ => Err(Error::Other("comment body paragraph is missing".to_owned())),
}
}
StoryKind::TableCell => {
let (content_index, mut cell_index) = cell_route.ok_or_else(|| {
Error::Other("comment position has no modeled table-cell route".to_owned())
})?;
let content = self
.document
.body
.content
.get_mut(content_index)
.ok_or_else(|| Error::Other("comment table container is missing".to_owned()))?;
let cell = match content {
BodyContent::Table(table) => nth_cell_in_table(table, &mut cell_index),
BodyContent::ContentControl(control) => {
nth_cell_in_control(control, &mut cell_index)
}
BodyContent::Paragraph(_) | BodyContent::RawXml(_) => None,
}
.ok_or_else(|| Error::Other("comment table cell is missing".to_owned()))?;
cell.content
.iter_mut()
.filter_map(|child| match child {
CellContent::Paragraph(paragraph) => Some(paragraph),
CellContent::Table(_) | CellContent::ContentControl(_) => None,
})
.nth(paragraph_slot)
.ok_or_else(|| Error::Other("comment cell paragraph is missing".to_owned()))
}
_ => Err(Error::Other(
"path-aware comments support body and table-cell paragraphs".to_owned(),
)),
}
}
/// Insert a checked run through its physical story paragraph owner.
pub(crate) fn insert_story_run_staged(
&mut self,
position: &crate::StoryRunPosition,
run: CT_R,
) -> Result<()> {
let (source, item) = self.story_range_paragraph_source(&position.location)?;
if item.kind != StoryItemKind::Paragraph {
return Err(Error::Other(
"field insertion needs a paragraph location".into(),
));
}
let part_name = source.part_name.clone();
let mut xml = source.xml.into_owned();
let scope = story_namespace_scope_at(&xml, item.full.start)?;
let closed = close_content_fragment_namespaces(&xml[item.full.clone()], &scope)?;
let mut paragraph = CT_P::from_xml_fragment(&closed)?;
paragraph
.insert_accepted_run(position.run_index, run)
.map_err(|error| Error::Other(format!("field insertion boundary: {error}")))?;
let replacement = serialize_content_fragment(BodyContent::Paragraph(paragraph))?;
let replacement = close_content_fragment_namespaces(&replacement, &scope)?;
xml.splice(item.full, replacement);
set_story_source_xml(self, &part_name, xml)
}
pub(crate) fn anchor_story_range(
&mut self,
range: &crate::comments::StoryRunRange,
anchor: RangeAnchor<'_>,
label: &str,
endpoint_probe: bool,
) -> Result<()> {
let start = &range.start.location;
let end = &range.end.location;
let start_run = range.start.run_index;
let end_run = range.end.run_index;
if start.story != end.story {
return Err(Error::Other(format!(
"{label} range endpoints must be ordered in one story owner"
)));
}
let nested_body_control = [start, end].iter().any(|location| {
location.index_path.len() == 2
&& self
.story_item_source(&ContentLocation::new(
location.story.clone(),
StoryItemKind::ContentControl,
vec![location.index_path[0]],
))
.is_ok_and(|(_, control)| !control.direct_owner_child)
});
// Multiple paragraphs must be qualified before either endpoint changes
// the owner's fingerprint. The raw path below publishes both spans once.
if start == end
&& start.story.kind() == StoryKind::Body
&& (start.index_path.len() == 2 || end.index_path.len() == 2)
&& !nested_body_control
{
if start.index_path > end.index_path {
return Err(Error::Other(format!(
"{label} range endpoints must be ordered in one story owner"
)));
}
let mut first = self.story_paragraph_mut(start)?.clone();
let mut last = if start == end {
first.clone()
} else {
self.story_paragraph_mut(end)?.clone()
};
if start == end {
first
.anchor_accepted_range(Some(start_run), Some(end_run), anchor)
.map_err(|error| {
Error::Other(format!("{label} range cannot be anchored: {error}"))
})?;
} else {
first
.anchor_accepted_range(Some(start_run), None, anchor)
.map_err(|error| {
Error::Other(format!("{label} range cannot be anchored: {error}"))
})?;
last.anchor_accepted_range(None, Some(end_run), anchor)
.map_err(|error| {
Error::Other(format!("{label} range cannot be anchored: {error}"))
})?;
}
*self.story_paragraph_mut(start)? = first;
if start != end {
*self.story_paragraph_mut(end)? = last;
}
return Ok(());
}
let (source, start_item) = self.story_range_paragraph_source(start)?;
let part_name = source.part_name.clone();
let source_xml = source.xml.into_owned();
let (_, end_item) = self.story_range_paragraph_source(end)?;
if start_item.full.start > end_item.full.start {
return Err(Error::Other(format!(
"{label} range endpoints must be ordered in one story owner"
)));
}
let read_paragraph = |item: &StoryItemSpan| -> Result<CT_P> {
if item.kind != StoryItemKind::Paragraph {
return Err(Error::Other(
"comment positions must identify paragraphs".to_owned(),
));
}
let scope = story_namespace_scope_at(&source_xml, item.full.start)?;
let closed = close_content_fragment_namespaces(&source_xml[item.full.clone()], &scope)?;
Ok(CT_P::from_xml_fragment(&closed)?)
};
let mut first = read_paragraph(&start_item)?;
let mut last = if start_item.full == end_item.full {
first.clone()
} else {
read_paragraph(&end_item)?
};
for (boundary, index, paragraph) in [("start", start_run, &first), ("end", end_run, &last)]
{
let count = paragraph.accepted_run_paths().len();
if index > count {
return Err(Error::Other(format!(
"{label} range {boundary} run index {index} exceeds paragraph run count {count}"
)));
}
}
if start_item.full == end_item.full {
if start_run > end_run {
return Err(Error::Other(format!(
"{label} range start must not follow its end"
)));
}
first
.anchor_accepted_range(Some(start_run), Some(end_run), anchor)
.map_err(|error| {
Error::Other(format!("{label} range cannot be anchored: {error}"))
})?;
} else {
first
.anchor_accepted_range(Some(start_run), None, anchor)
.map_err(|error| {
Error::Other(format!("{label} range cannot be anchored: {error}"))
})?;
last.anchor_accepted_range(None, Some(end_run), anchor)
.map_err(|error| {
Error::Other(format!("{label} range cannot be anchored: {error}"))
})?;
}
let same_paragraph = start_item.full == end_item.full;
let mut edits = vec![(start_item.full, first)];
if !same_paragraph {
edits.push((end_item.full, last));
}
edits.sort_by_key(|item| std::cmp::Reverse(item.0.start));
let mut updated = source_xml;
for (span, paragraph) in edits {
let scope = story_namespace_scope_at(&updated, span.start)?;
let xml = serialize_content_fragment(BodyContent::Paragraph(paragraph))?;
let xml = close_content_fragment_namespaces(&xml, &scope)?;
updated.splice(span, xml);
}
let updated = match anchor {
RangeAnchor::Comment(id) if !endpoint_probe => {
self.comment_move_source_with_namespaces(&part_name, updated, id, false)?
}
_ => updated,
};
set_story_source_xml(self, &part_name, updated)
}
/// Stage a comment move using physical source edits, then bind the surviving
/// destination paragraphs and accepted run boundaries to their new owners.
pub(crate) fn move_comment_story_range(
&mut self,
entry: &crate::comments::StoryRangeRef,
target: &StoryRunRange,
id: i32,
) -> Result<()> {
// Validate both endpoints before any source is removed. The probe never
// publishes its synthesized reference or changes the requested axis.
let mut probe = self.clone_for_staging();
probe.anchor_story_range(target, RangeAnchor::Comment(id), "comment", true)?;
let endpoints = [&target.start, &target.end]
.map(|position| {
let (source, paragraph) = self.story_range_paragraph_source(&position.location)?;
Ok((source.part_name.clone(), paragraph.full.start))
})
.into_iter()
.collect::<Result<Vec<_>>>()?;
let (part_name, xml, selected) = self.story_range_marker_spans(entry, true)?;
let (edits, reference, reference_span) = comment_move_source_edits(&xml, &selected)?;
let mut reference_axis = None;
for (location, _) in self.story_range_paragraphs()? {
if location.story != entry.range().start.location.story {
continue;
}
let (source, paragraph) = self.story_range_paragraph_source(&location)?;
if !paragraph.full.contains(&reference_span.start) {
continue;
}
let scope = story_namespace_scope_at(source.xml.as_ref(), paragraph.full.start)?;
let raw =
close_content_fragment_namespaces(&source.xml[paragraph.full.clone()], &scope)?;
let paragraph_model = CT_P::from_xml_fragment(&raw)?;
let indices = paragraph_model
.accepted_run_paths()
.iter()
.enumerate()
.filter_map(|(index, path)| {
paragraph_model
.accepted_run(path)
.filter(|run| {
run.content.iter().any(|content| {
matches!(content,
RunContent::CommentReference { id: found, .. } if *found == id)
})
})
.map(|_| index)
})
.collect::<Vec<_>>();
if indices.len() != 1 || reference_axis.is_some() {
return Err(Error::Other(
"comment reference has no unique accepted run boundary".into(),
));
}
reference_axis = Some((paragraph.full.start, indices[0]));
}
let (reference_paragraph, reference_index) = reference_axis.ok_or_else(|| {
Error::Other("comment reference is outside the supported accepted run axis".into())
})?;
let map_offset = |offset: usize| -> Result<usize> {
if edits.iter().any(|span| span.contains(&offset)) {
return Err(Error::Other(
"comment move destination is inside a removed marker-only owner".into(),
));
}
Ok(offset
- edits
.iter()
.filter(|span| span.end <= offset)
.map(|span| span.len())
.sum::<usize>())
};
let mut updated = xml;
for span in edits.iter().rev() {
updated.drain(span.clone());
}
let updated = self.comment_move_source_with_namespaces(&part_name, updated, id, true)?;
set_story_source_xml(self, &part_name, updated)?;
let stories = self.stories()?;
let rebase = |position: &StoryRunPosition,
endpoint: &(String, usize)|
-> Result<StoryRunPosition> {
let original = position.location.story();
let story = stories
.iter()
.find(|story| {
story.kind == original.kind
&& story.part_name == original.part_name
&& story.owner_index == original.owner_index
})
.ok_or_else(|| Error::Other("comment move destination story disappeared".into()))?;
let expected = if endpoint.0 == part_name {
map_offset(endpoint.1)?
} else {
endpoint.1
};
let (source, owner) = self.story_source_and_owner(story)?;
let mut found = None;
for (index, item) in scan_story_items(source.xml.as_ref(), &owner)?
.iter()
.enumerate()
{
let paths = match item.kind {
StoryItemKind::Paragraph => vec![(vec![index], item.full.start)],
StoryItemKind::ContentControl => {
scan_story_control_paragraphs(source.xml.as_ref(), item)?
.iter()
.enumerate()
.map(|(child, span)| (vec![index, child], span.start))
.collect()
}
_ => Vec::new(),
};
for (path, offset) in paths {
if offset == expected {
if found.is_some() {
return Err(Error::Other("ambiguous comment move destination".into()));
}
found = Some(path);
}
}
}
let path = found.ok_or_else(|| {
Error::Other("comment move destination paragraph disappeared".into())
})?;
let removed_before_boundary = endpoint.0 == part_name
&& endpoint.1 == reference_paragraph
&& position.run_index > reference_index
&& edits.iter().any(|span| {
span.start <= reference_span.start && reference_span.end <= span.end
});
Ok(StoryRunPosition {
location: ContentLocation::new(story.clone(), StoryItemKind::Paragraph, path),
run_index: position.run_index - usize::from(removed_before_boundary),
})
};
let rebound = StoryRunRange {
start: rebase(&target.start, &endpoints[0])?,
end: rebase(&target.end, &endpoints[1])?,
};
self.anchor_story_range(&rebound, RangeAnchor::Comment(id), "comment", false)?;
// Replace only the uniquely synthesized selected reference run. The
// original run carries its namespace closure, formatting and raw attrs.
self.restore_comment_reference_run(id, reference)
}
pub(crate) fn remove_comment_source_for_move(
&mut self,
entry: &crate::comments::StoryRangeRef,
) -> Result<Vec<u8>> {
let (part, xml, selected) = self.story_range_marker_spans(entry, true)?;
let (edits, reference, _) = comment_move_source_edits(&xml, &selected)?;
let mut updated = xml;
for span in edits.iter().rev() {
updated.drain(span.clone());
}
let updated = self.comment_move_source_with_namespaces(
&part,
updated,
match entry.kind() {
crate::comments::StoryRangeKind::Comment { id } => *id,
_ => return Err(Error::Other("expected comment move source".into())),
},
true,
)?;
set_story_source_xml(self, &part, updated)?;
Ok(reference)
}
pub(crate) fn restore_comment_reference_run(
&mut self,
id: i32,
reference: Vec<u8>,
) -> Result<()> {
let ranges = self.story_ranges()?;
let matching = ranges
.iter()
.filter(|entry| {
matches!(entry.kind(),
crate::comments::StoryRangeKind::Comment { id: found } if *found == id)
})
.collect::<Vec<_>>();
if matching.len() != 1 {
return Err(Error::Other(
"moved comment has no unique destination range".into(),
));
}
let (source, owner) =
self.story_source_and_owner(matching[0].range().end.location.story())?;
let part = source.part_name.clone();
let span = comment_reference_run_span(source.xml.as_ref(), &owner.full, id)?;
let mut xml = source.xml.into_owned();
xml.splice(span, reference);
let xml = self.comment_move_source_with_namespaces(&part, xml, id, false)?;
set_story_source_xml(self, &part, xml)
}
// Source discovery retains the established canonical fingerprint axis.
// Before a move replaces that source, replay its original qualified nested
// declarations so the replacement does not erase preserved raw aliases.
fn comment_move_source_with_namespaces(
&self,
part: &str,
xml: Vec<u8>,
id: i32,
source_removal: bool,
) -> Result<Vec<u8>> {
if part != self.doc_part_name {
return Ok(xml);
}
let original = self
.package
.get_part(part)
.ok_or_else(|| Error::Other("comment move main source is missing".into()))?;
// Apply the same proved source edits to the original namespace authority.
// Whole transported runs disappear, while mixed source runs keep their
// declarations and every non-reference child in their original order.
let mut remaining_source = original.to_vec();
if source_removal {
let selected = crate::comments::comment_source_markers(original)?
.into_iter()
.filter(|marker| marker.id == id)
.map(|marker| marker.span)
.collect::<Vec<_>>();
if selected.len() != 3 {
return Err(Error::Other(
"comment move original source lacks one qualified pair and reference".into(),
));
}
let (edits, _, _) = comment_move_source_edits(original, &selected)?;
for span in edits.into_iter().rev() {
remaining_source.drain(span);
}
}
let original = remaining_source.as_slice();
let mut owners = nested_modeled_namespace_owners(original)?;
let original_spans = modeled_owner_spans(original)?;
let spans = modeled_owner_spans(&xml)?;
for owner in &mut owners {
let has_pair = |source: &[u8], span: &ModeledOwnerSpan| -> Result<bool> {
let mut scope = span
.bindings
.iter()
.map(|(name, uri)| (namespace_prefix(name), uri.clone()))
.collect::<BTreeMap<_, _>>();
for (name, uri) in &owner.declarations {
let prefix = namespace_prefix(name);
scope.insert(prefix, uri.clone());
}
let closed =
close_content_fragment_namespaces(&source[span.start..span.end], &scope)?;
Ok(CT_P::comment_range_text_from_source(&closed, id)?.is_some())
};
let mut original_selected = false;
for span in original_spans
.iter()
.filter(|span| span.local_name == owner.local_name)
{
let markers = candidate_namespace_owner_markers(
&original[span.start..span.end],
&owner.declarations,
&span.bindings,
&owner.markers,
)?;
if marker_multiset_matches(&owner.markers, &markers, true)
&& has_pair(original, span)?
{
original_selected = true;
}
}
let mut matching = Vec::new();
for span in spans
.iter()
.filter(|span| span.local_name == owner.local_name)
{
let markers = candidate_namespace_owner_markers(
&xml[span.start..span.end],
&owner.declarations,
&span.bindings,
&owner.markers,
)?;
if marker_multiset_matches(&owner.markers, &markers, true) {
matching.push(span);
}
}
let selected = matching
.iter()
.map(|span| has_pair(&xml, span))
.collect::<Result<Vec<_>>>()?;
if original_selected || selected.iter().any(|selected| *selected) {
let [span] = matching.as_slice() else {
return Err(Error::Other(
"comment move has ambiguous retained namespace owner".into(),
));
};
owner.snapshot = logical_owner_snapshot(
&xml[span.start..span.end],
&span.bindings,
&owner.declarations,
)?;
}
}
replay_nested_namespace_declarations(&xml, &owners)
}
fn story_range_marker_spans(
&self,
entry: &crate::comments::StoryRangeRef,
move_comment_reference: bool,
) -> Result<(String, Vec<u8>, Vec<Range<usize>>)> {
use crate::comments::StoryRangeKind;
let (source, owner) = self.story_source_and_owner(entry.range().start.location.story())?;
let part_name = source.part_name.clone();
let xml = source.xml.as_ref();
let mut paragraphs = Vec::new();
for item in scan_story_items(xml, &owner)? {
match item.kind {
StoryItemKind::Paragraph => paragraphs.push(item.full),
StoryItemKind::ContentControl => {
paragraphs.extend(scan_story_control_paragraphs(xml, &item)?);
}
_ => {}
}
}
let (start_tag, end_tag, identity) = match entry.kind() {
StoryRangeKind::Bookmark { id, .. } => (
b"bookmarkStart".as_slice(),
b"bookmarkEnd".as_slice(),
id.to_string(),
),
StoryRangeKind::Comment { id } => (
b"commentRangeStart".as_slice(),
b"commentRangeEnd".as_slice(),
id.to_string(),
),
StoryRangeKind::Permission { id, .. } => (
b"permStart".as_slice(),
b"permEnd".as_slice(),
id.to_string(),
),
StoryRangeKind::Proofing { kind } => {
(b"proofErr".as_slice(), b"proofErr".as_slice(), kind.clone())
}
};
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(false);
let mut ranges = Vec::new();
let mut ordinal = 0usize;
let mut stack = Vec::<(bool, Vec<u8>, usize)>::new();
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("range removal scan failed: {error}")))?;
let is_word = word_element(&namespace);
drop(namespace);
let after = reader.buffer_position() as usize;
let hidden = stack.iter().any(|(word, name, start)| {
*word
&& matches!(
name.as_slice(),
b"del" | b"moveFrom" | b"smartTag" | b"customXml" | b"fldSimple"
)
|| *word && name == b"txbxContent" && *start > owner.full.start
});
if let Event::End(_) = &event {
stack.pop();
}
if let Event::Start(element) = &event {
stack.push((is_word, element.local_name().as_ref().to_vec(), before));
}
let element = match &event {
Event::Start(element) | Event::Empty(element)
if paragraphs
.iter()
.any(|paragraph| paragraph.contains(&before))
&& !hidden
&& is_word
&& matches!(
element.local_name().as_ref(),
b"bookmarkStart"
| b"bookmarkEnd"
| b"commentRangeStart"
| b"commentRangeEnd"
| b"permStart"
| b"permEnd"
| b"proofErr"
| b"commentReference"
) =>
{
element
}
Event::Eof => break,
_ => {
buffer.clear();
continue;
}
};
let mut id_value = None;
let mut type_value = None;
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| Error::Other(error.to_string()))?;
let (namespace, local) = reader.resolver().resolve_attribute(attribute.key);
if word_element(&namespace) && matches!(local.as_ref(), b"id" | b"type") {
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|error| Error::Other(error.to_string()))?;
if local.as_ref() == b"id" {
id_value = Some(value.into_owned());
} else {
type_value = Some(value.into_owned());
}
}
}
let name = element.local_name();
let name = name.as_ref();
let paired = matches!(
name,
b"bookmarkStart"
| b"bookmarkEnd"
| b"commentRangeStart"
| b"commentRangeEnd"
| b"permStart"
| b"permEnd"
) || name == b"proofErr"
&& matches!(
type_value.as_deref(),
Some("spellStart" | "spellEnd" | "gramStart" | "gramEnd")
);
let this_ordinal = paired.then(|| {
let current = ordinal;
ordinal += 1;
current
});
let matched = if name == b"commentReference" {
move_comment_reference
&& matches!(entry.kind(), StoryRangeKind::Comment { .. })
&& id_value.as_deref() == Some(identity.as_str())
} else {
(name == start_tag || name == end_tag)
&& this_ordinal.is_some_and(|index| {
index == entry.start_ordinal || index == entry.end_ordinal
})
&& if matches!(entry.kind(), StoryRangeKind::Proofing { .. }) {
type_value.as_deref() == Some(format!("{identity}Start").as_str())
|| type_value.as_deref() == Some(format!("{identity}End").as_str())
} else {
id_value.as_deref() == Some(identity.as_str())
}
};
if matched {
let end = if matches!(event, Event::Empty(_)) {
after
} else {
story_element_end(xml, before)?
};
ranges.push(before..end);
}
buffer.clear();
}
let expected =
if move_comment_reference && matches!(entry.kind(), StoryRangeKind::Comment { .. }) {
3
} else {
2
};
if ranges.len() != expected {
return Err(Error::Other(format!(
"range marker identity {} has {} selected elements, expected {expected}",
identity,
ranges.len()
)));
}
Ok((part_name, xml.to_vec(), ranges))
}
pub(crate) fn remove_story_range_markers(
&mut self,
entry: &crate::comments::StoryRangeRef,
move_comment_reference: bool,
) -> Result<()> {
let (part_name, mut updated, ranges) =
self.story_range_marker_spans(entry, move_comment_reference)?;
for range in ranges.into_iter().rev() {
updated.splice(range, []);
}
set_story_source_xml(self, &part_name, updated)
}
fn story_item_source<'a>(
&'a self,
location: &ContentLocation,
) -> Result<(StorySource<'a>, StoryItemSpan)> {
let (source, owner) = self.story_source_and_owner(&location.story)?;
if location.index_path.len() != 1 {
return Err(StoryError::InvalidPath {
path: location.index_path.clone(),
}
.into());
}
let items = scan_story_items(source.xml.as_ref(), &owner)?;
let index = location.index_path[0];
let item = items.get(index).cloned().ok_or(StoryError::OutOfBounds {
index,
len: items.len(),
})?;
if item.kind != location.item_kind {
return Err(StoryError::KindMismatch {
expected: location.item_kind,
actual: item.kind,
}
.into());
}
Ok((source, item))
}
fn story_range_paragraph_source<'a>(
&'a self,
location: &ContentLocation,
) -> Result<(StorySource<'a>, StoryItemSpan)> {
if location.index_path.len() == 1 {
return self.story_item_source(location);
}
if location.index_path.len() != 2 || location.item_kind != StoryItemKind::Paragraph {
return Err(StoryError::InvalidPath {
path: location.index_path.clone(),
}
.into());
}
let (source, owner) = self.story_source_and_owner(&location.story)?;
let items = scan_story_items(source.xml.as_ref(), &owner)?;
let control_index = location.index_path[0];
let control = items.get(control_index).ok_or(StoryError::OutOfBounds {
index: control_index,
len: items.len(),
})?;
if control.kind != StoryItemKind::ContentControl {
return Err(StoryError::KindMismatch {
expected: StoryItemKind::ContentControl,
actual: control.kind,
}
.into());
}
let paragraphs = scan_story_control_paragraphs(source.xml.as_ref(), control)?;
let paragraph_index = location.index_path[1];
let full = paragraphs
.get(paragraph_index)
.cloned()
.ok_or(StoryError::OutOfBounds {
index: paragraph_index,
len: paragraphs.len(),
})?;
Ok((
source,
StoryItemSpan {
kind: StoryItemKind::Paragraph,
scan: full.clone(),
full,
direct_owner_child: false,
complex_field: false,
complex_ancestors: Vec::new(),
sdt_context: None,
},
))
}
/// Traverse one story's supported content without constructing a second
/// document tree.
pub fn story_items<'a>(&'a self, story: &StoryId) -> Result<Vec<StoryItemRef<'a>>> {
let (source, owner) = self.story_source_and_owner(story)?;
let spans = scan_story_items(&source.xml, &owner)?;
Ok(spans
.into_iter()
.enumerate()
.map(|(index, item)| StoryItemRef {
document: self,
location: ContentLocation {
story: story.clone(),
item_kind: item.kind,
index_path: vec![index],
is_end: false,
},
})
.collect())
}
pub(crate) fn patch_story_paragraph_field_sources(
&mut self,
location: &ContentLocation,
replacements: &[(Vec<u8>, Vec<u8>)],
) -> Result<()> {
// Earlier writes may have changed the owning story's fingerprint.
// Refresh only that fingerprint, keeping its physical part and ordinal.
let mut location = location.clone();
location.story = self
.stories()?
.into_iter()
.find(|story| {
story.kind == location.story.kind
&& story.part_name == location.story.part_name
&& story.owner_index == location.story.owner_index
})
.ok_or_else(|| {
Error::Other("field cache owner disappeared during staging".to_owned())
})?;
let (source, paragraph) = self.story_range_paragraph_source(&location)?;
let part_name = source.part_name.clone();
let mut xml = source.xml.into_owned();
let mut edits = Vec::new();
let mut cursor = 0;
for (original, replacement) in replacements {
if original.is_empty() {
return Err(Error::Other("empty field source identity".to_owned()));
}
let offset = crate::field::find_typed_field_source(
&xml,
paragraph.full.start,
paragraph.full.end,
original,
cursor,
)?
.ok_or_else(|| {
Error::Other("field source no longer belongs to its checked paragraph".to_owned())
})?;
let start = paragraph.full.start + offset;
cursor = offset + original.len();
edits.push((start..(start + original.len()), replacement));
}
for (range, replacement) in edits.into_iter().rev() {
xml.splice(range, replacement.iter().copied());
}
set_story_source_xml(self, &part_name, xml)?;
self.invalidate_layout();
Ok(())
}
pub(crate) fn text_box_cache_paragraphs(&self) -> Result<Vec<TextBoxCacheParagraph>> {
let (sources, paragraphs) = self.story_range_paragraph_sources()?;
let text_boxes = sources
.iter()
.map(|source| {
Ok(scan_story_owners(source.xml.as_ref(), source.root_kind)?
.into_iter()
.filter(|owner| owner.kind == StoryKind::TextBox)
.collect::<Vec<_>>())
})
.collect::<Result<Vec<_>>>()?;
let mut result = Vec::new();
let mut seen = HashSet::new();
for (source_index, paragraph, location, xml) in paragraphs {
if !matches!(
location.story.kind,
StoryKind::TextBox | StoryKind::TableCell
) {
continue;
}
let source = &sources[source_index];
let text_box = text_boxes[source_index]
.iter()
.filter(|owner| {
owner.full.start <= paragraph.start && owner.full.end >= paragraph.end
})
.min_by_key(|owner| owner.full.len());
if let Some(text_box) = text_box
&& seen.insert((source.part_name.clone(), paragraph.start, paragraph.end))
{
result.push(TextBoxCacheParagraph {
location,
xml,
owner_index: text_box.owner_index,
paragraph_index: paragraph.start,
});
}
}
result.sort_by(|left, right| {
(
left.location.story.part_name.as_str(),
left.owner_index,
left.paragraph_index,
)
.cmp(&(
right.location.story.part_name.as_str(),
right.owner_index,
right.paragraph_index,
))
});
let mut ordinals = HashMap::<(String, usize), usize>::new();
for paragraph in &mut result {
let ordinal = ordinals
.entry((
paragraph.location.story.part_name.clone(),
paragraph.owner_index,
))
.or_default();
paragraph.paragraph_index = *ordinal;
*ordinal += 1;
}
Ok(result)
}
/// Snapshot one checked range paragraph, including a paragraph within a block control.
/// This does not expand the ordinary story-item inventory.
#[doc(hidden)]
pub fn story_range_paragraph_snapshot(
&self,
location: &ContentLocation,
) -> Result<StoryItemSnapshot> {
let (source, item) = self.story_range_paragraph_source(location)?;
let scope = story_namespace_scope_at(source.xml.as_ref(), item.full.start)?;
let xml = close_content_fragment_namespaces(&source.xml.as_ref()[item.full], &scope)?;
let text = CT_P::from_xml_fragment(&xml)?.accepted_text();
let containing = if let [control, _] = location.index_path() {
ContentLocation::new(
location.story().clone(),
StoryItemKind::ContentControl,
vec![*control],
)
} else {
location.clone()
};
let direct_body_index = StoryItemRef {
document: self,
location: containing,
}
.direct_body_index()?;
Ok(StoryItemSnapshot {
location: location.clone(),
direct_body_index,
direct_child: location.index_path().len() == 1,
text: Some(text),
xml,
})
}
// Guard the unique newly placed pair against the actual main source after
// existing namespace replay. Only this unpublished candidate's document part
// is flushed, leaving pending comment models and identifier state untouched.
pub(crate) fn comment_literal_range_text(&mut self, id: i32) -> Result<Option<String>> {
let xml = self.comment_move_source_with_namespaces(
&self.doc_part_name,
self.document.to_xml()?,
id,
false,
)?;
let part = self.doc_part_name.clone();
set_story_source_xml(self, &part, xml)?;
self.flush_document_to_package()?;
let source = self.package.get_part(&self.doc_part_name).ok_or_else(|| {
Error::Other("comment literal guard has no main document source".into())
})?;
Ok(CT_P::comment_range_text_from_source(source, id)?)
}
// Keep canonical locations and fingerprints while restoring only the proved
// namespace context used by rich comment projection. Physical event offsets
// qualify every owner before a restored declaration can reinterpret a name.
fn namespace_restored_main_source(&self) -> Result<Vec<u8>> {
let canonical = self.document.to_xml()?;
let original = self
.package
.get_part(&self.doc_part_name)
.ok_or_else(|| Error::Other("rich comment projection has no main source".into()))?;
let mut namespace_owners = nested_modeled_namespace_owners(original)?;
if namespace_owners.is_empty() {
return Ok(canonical);
}
let canonical_spans = modeled_owner_spans(&canonical)?;
let mut needed = Vec::new();
for owner in namespace_owners.drain(..) {
let original_scope = story_namespace_scope_at(original, owner.source.start)?;
let original_owner = close_content_fragment_namespaces(
&original[owner.source.clone()],
&original_scope,
)?;
let mut redundant = 0;
for target in canonical_spans.iter().filter(|span| {
span.local_name == owner.local_name
&& owner
.declarations
.iter()
.all(|declaration| span.declarations.contains(declaration))
}) {
let scope = story_namespace_scope_at(&canonical, target.start)?;
let target_owner = close_content_fragment_namespaces(
&canonical[target.start..target.end],
&scope,
)?;
// Full owner payload, names and attributes prove the unique
// source counterpart. Only redundant namespace declarations may
// differ, with every resolved namespace fact still identical.
if let Some((_, changed)) =
comment_namespace_source_boundaries(&original_owner, &target_owner)?
&& changed.is_empty()
{
redundant += 1;
}
}
if redundant != 1 {
needed.push(owner);
}
}
let restored = replay_nested_namespace_declarations(&canonical, &needed)?;
if restored == canonical {
return Ok(canonical);
}
let (boundaries, _) = comment_namespace_source_boundaries(&canonical, &restored)?
.ok_or_else(|| {
Error::Other("rich comment namespace replay changed physical source".into())
})?;
let owners = scan_story_owners(&canonical, StoryKind::Body)?;
let restored_owners = scan_story_owners(&restored, StoryKind::Body)?;
if owners.len() != restored_owners.len()
|| owners.iter().any(|owner| {
!restored_owners.iter().any(|other| {
other.kind == owner.kind
&& other.owner_index == owner.owner_index
&& boundaries.get(&owner.full.start) == Some(&other.full.start)
&& boundaries.get(&owner.full.end) == Some(&other.full.end)
})
})
{
return Err(Error::Other(
"rich comment namespace replay changes story ownership".into(),
));
}
Ok(restored)
}
pub(crate) fn comment_story_range_paragraphs(&self) -> Result<Vec<(ContentLocation, Vec<u8>)>> {
let mut paragraphs = self.story_range_paragraphs()?;
let canonical = self.document.to_xml()?;
let restored = self.namespace_restored_main_source()?;
if restored == canonical {
return Ok(paragraphs);
}
let (boundaries, _) = comment_namespace_source_boundaries(&canonical, &restored)?
.ok_or_else(|| {
Error::Other("rich comment namespace replay changed physical source".into())
})?;
let owners = scan_story_owners(&canonical, StoryKind::Body)?
.into_iter()
.map(|owner| {
let story = StoryId {
kind: owner.kind,
part_name: self.doc_part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
};
(story, owner)
})
.collect::<Vec<_>>();
let mut paragraph_spans = HashMap::new();
for (location, span) in story_range_paragraph_spans(&canonical, &owners)?
.into_iter()
.flatten()
{
if paragraph_spans.insert(location, span).is_some() {
return Err(Error::Other(
"rich comment has duplicate physical paragraph path".into(),
));
}
}
let scopes = story_namespace_scopes_at(
&restored,
paragraph_spans
.values()
.filter_map(|span| boundaries.get(&span.start).copied()),
)?;
for (location, xml) in &mut paragraphs {
if location.story.part_name != self.doc_part_name {
continue;
}
let span = paragraph_spans.get(location).ok_or_else(|| {
Error::Other("rich comment paragraph has no checked physical path".into())
})?;
let start = *boundaries.get(&span.start).ok_or_else(|| {
Error::Other("rich comment paragraph has no physical namespace boundary".into())
})?;
let end = *boundaries.get(&span.end).ok_or_else(|| {
Error::Other("rich comment paragraph has no physical namespace boundary".into())
})?;
let scope = scopes.get(&start).ok_or_else(|| {
Error::Other("rich comment paragraph namespace scope was not inventoried".into())
})?;
*xml = close_content_fragment_namespaces(&restored[start..end], scope)?;
}
Ok(paragraphs)
}
#[doc(hidden)]
pub fn story_range_paragraphs(&self) -> Result<Vec<(ContentLocation, Vec<u8>)>> {
let (_, paragraphs) = self.story_range_paragraph_sources()?;
Ok(paragraphs
.into_iter()
.map(|(_, _, location, xml)| (location, xml))
.collect())
}
/// List every checked range paragraph in story order, with the index of
/// its source and its span there, after building each package source once.
///
/// Stories keep the identities and order of [`Self::stories`]. Each source
/// is scanned for its owners once and its paragraph scopes are inventoried
/// in one pass.
#[allow(clippy::type_complexity)] // Sources and their located paragraph spans form one inventory.
fn story_range_paragraph_sources(
&self,
) -> Result<(
Vec<StorySource<'_>>,
Vec<(usize, Range<usize>, ContentLocation, Vec<u8>)>,
)> {
let sources = self.story_sources()?;
let mut paragraphs = Vec::new();
let mut seen = HashSet::new();
let mut story_index = 0usize;
for (source_index, source) in sources.iter().enumerate() {
let source_xml = source.xml.as_ref();
let mut owners = Vec::new();
for owner in scan_story_owners(source_xml, source.root_kind)? {
let identity = (owner.kind, source.part_name.clone(), owner.owner_index);
if !seen.insert(identity) {
continue;
}
let story = StoryId {
kind: owner.kind,
part_name: source.part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
};
owners.push((story, owner));
}
let spans = story_range_paragraph_spans(source_xml, &owners)?;
let scopes = story_namespace_scopes_at(
source_xml,
spans.iter().flatten().map(|(_, span)| span.start),
)?;
for story_spans in spans {
for (location, span) in story_spans {
let scope = scopes.get(&span.start).ok_or_else(|| {
Error::Other("story paragraph namespace scope was not inventoried".into())
})?;
let xml = close_content_fragment_namespaces(&source_xml[span.clone()], scope)?;
paragraphs.push((story_index, source_index, span, location, xml));
}
story_index += 1;
}
}
paragraphs.sort_by_key(|(story_index, _, span, _, _)| (*story_index, span.start));
Ok((
sources,
paragraphs
.into_iter()
.map(|(_, source_index, span, location, xml)| (source_index, span, location, xml))
.collect(),
))
}
/// Materialize every story item after building each package source once.
///
/// Unlike repeated calls through [`StoryItemRef`], this owned projection
/// serializes the main document once and scans each story owner once for
/// the complete snapshot.
pub fn story_item_snapshots(&self) -> Result<Vec<StoryItemSnapshot>> {
let mut snapshots = Vec::new();
let mut seen = HashSet::new();
for source in self.story_sources()? {
let owners = scan_story_owners(source.xml.as_ref(), source.root_kind)?;
let owner_scopes = story_namespace_scopes_at(
source.xml.as_ref(),
owners.iter().map(|owner| owner.full.start),
)?;
let mut inventories = Vec::new();
for owner in owners {
let identity = (owner.kind, source.part_name.clone(), owner.owner_index);
if !seen.insert(identity) {
continue;
}
let story = StoryId {
kind: owner.kind,
part_name: source.part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
};
let scope = owner_scopes.get(&owner.full.start).ok_or_else(|| {
Error::Other("story owner namespace scope was not inventoried".to_owned())
})?;
let items = scan_story_items_with_scope(source.xml.as_ref(), &owner, scope)?;
let direct_items = if owner.kind == StoryKind::Body {
let mut direct = Vec::new();
for item in items.iter().filter(|item| item.direct_owner_child) {
if item.kind != StoryItemKind::PreservedNode
|| !content_fragment_root_is_section_properties(
source.xml.as_ref(),
item,
)?
{
direct.push(item.clone());
}
}
direct
} else {
Vec::new()
};
inventories.push((story, items, direct_items));
}
let item_scopes = story_namespace_scopes_at(
source.xml.as_ref(),
inventories
.iter()
.flat_map(|(_, items, _)| items.iter().map(|item| item.scan.start)),
)?;
for (story, items, direct_items) in inventories {
let mut direct_cursor = 0usize;
for (index, item) in items.iter().enumerate() {
while direct_items
.get(direct_cursor)
.is_some_and(|direct| direct.full.end <= item.full.start)
{
direct_cursor += 1;
}
let direct_body_index = direct_items.get(direct_cursor).and_then(|direct| {
(direct.full.start <= item.full.start && item.full.end <= direct.full.end)
.then_some(direct_cursor)
});
let scope = item_scopes.get(&item.scan.start).ok_or_else(|| {
Error::Other("story item namespace scope was not inventoried".to_owned())
})?;
let text =
story_item_text_with_scope(source.xml.as_ref(), item, scope, &items)?;
let xml = if item.complex_field {
complex_story_field_xml(source.xml.as_ref(), item)?
} else {
source.xml[item.full.clone()].to_vec()
};
snapshots.push(StoryItemSnapshot {
location: ContentLocation {
story: story.clone(),
item_kind: item.kind,
index_path: vec![index],
is_end: false,
},
direct_body_index,
direct_child: item.direct_owner_child,
text,
xml,
});
}
}
}
Ok(snapshots)
}
fn story_link_info(
&self,
story: &StoryId,
xml: &[u8],
link: StoryLinkSpan,
scope: &BTreeMap<String, String>,
) -> Result<LinkInfo> {
let text_item = StoryItemSpan {
kind: StoryItemKind::Paragraph,
full: link.full.clone(),
scan: link.full,
direct_owner_child: false,
complex_field: false,
complex_ancestors: Vec::new(),
sdt_context: None,
};
let text = story_item_text_with_scope(xml, &text_item, scope, &[])?.unwrap_or_default();
let url = link
.rel_id
.as_deref()
.map(|relationship_id| self.hyperlink_url_for_validated_story(story, relationship_id))
.transpose()?;
Ok(LinkInfo {
text,
url,
anchor: link.anchor,
rel_id: link.rel_id,
})
}
/// Return one story's modeled hyperlinks in physical source order together
/// with the checked location of the item that owns each hyperlink.
pub fn story_links(&self, story: &StoryId) -> Result<Vec<(ContentLocation, LinkInfo)>> {
let (source, owner) = self.story_source_and_owner(story)?;
let links = story_link_spans(source.xml.as_ref(), &owner, story)?;
let link_scopes = story_namespace_scopes_at(
source.xml.as_ref(),
links.iter().map(|(_, link)| link.full.start),
)?;
links
.into_iter()
.map(|(location, link)| {
let scope = link_scopes.get(&link.full.start).ok_or_else(|| {
Error::Other("story hyperlink namespace scope was not inventoried".to_owned())
})?;
let info = self.story_link_info(story, source.xml.as_ref(), link, scope)?;
Ok((location, info))
})
.collect()
}
/// Materialize every modeled story hyperlink from one package inventory.
pub fn story_link_snapshots(&self) -> Result<Vec<(ContentLocation, LinkInfo)>> {
let mut snapshots = Vec::new();
let mut seen = HashSet::new();
for source in self.story_sources()? {
let owners = scan_story_owners(source.xml.as_ref(), source.root_kind)?;
let owner_scopes = story_namespace_scopes_at(
source.xml.as_ref(),
owners.iter().map(|owner| owner.full.start),
)?;
let mut inventories = Vec::new();
for owner in owners {
let identity = (owner.kind, source.part_name.clone(), owner.owner_index);
if !seen.insert(identity) {
continue;
}
let story = StoryId {
kind: owner.kind,
part_name: source.part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
};
let scope = owner_scopes.get(&owner.full.start).ok_or_else(|| {
Error::Other("story owner namespace scope was not inventoried".to_owned())
})?;
let items = scan_story_items_with_scope(source.xml.as_ref(), &owner, scope)?;
inventories.push((story, items));
}
let item_scopes = story_namespace_scopes_at(
source.xml.as_ref(),
inventories
.iter()
.flat_map(|(_, items)| items.iter().map(|item| item.scan.start)),
)?;
let mut link_inventories = Vec::new();
for (story, items) in inventories {
let links =
ordered_story_link_spans(source.xml.as_ref(), &story, items, &item_scopes)?;
link_inventories.push((story, links));
}
let link_scopes = story_namespace_scopes_at(
source.xml.as_ref(),
link_inventories
.iter()
.flat_map(|(_, links)| links.iter().map(|(_, link)| link.full.start)),
)?;
for (story, links) in link_inventories {
for (location, link) in links {
let scope = link_scopes.get(&link.full.start).ok_or_else(|| {
Error::Other(
"story hyperlink namespace scope was not inventoried".to_owned(),
)
})?;
let info = self.story_link_info(&story, source.xml.as_ref(), link, scope)?;
snapshots.push((location, info));
}
}
}
Ok(snapshots)
}
fn append_fragment_to_story(
&mut self,
story: &StoryId,
mut fragment: ContentFragment,
) -> Result<()> {
let (source, owner) = self.story_source_and_owner(story)?;
let part_name = source.part_name.clone();
let source_xml = source.xml.into_owned();
let destination = ContentLocation::end(story.clone());
let (boundary, _, _) = validated_content_boundary(&source_xml, &owner, &destination)?;
fragment.source_part_name = Some(part_name.clone());
fragment.source_story_kind = Some(story.kind);
fragment.source_owner_index = Some(story.owner_index);
let fragment_xml = content_fragment_for_insertion(self, story, &fragment)?;
let mut updated = source_xml;
insert_story_fragment(&mut updated, &owner, boundary, fragment_xml)?;
set_story_source_xml(self, &part_name, updated)
}
/// Append an inline picture paragraph to one checked story owner.
pub fn add_picture_to_story(
&mut self,
story: &StoryId,
image_data: &[u8],
image_filename: &str,
width: Length,
height: Length,
) -> Result<()> {
self.insert_picture_to_story(
story,
None,
image_data,
image_filename,
Some(width),
Some(height),
)?;
Ok(())
}
/// Insert an inline picture at one checked physical run boundary.
/// The media, owning relationship and drawing are published together.
pub fn insert_picture_at(
&mut self,
position: &StoryRunPosition,
image_data: &[u8],
image_filename: &str,
width: Option<Length>,
height: Option<Length>,
) -> Result<()> {
self.insert_picture_with_options_at(
position,
image_data,
image_filename,
width,
height,
None,
)
.map(|_| ())
}
/// Import picture metadata and placement through the same checked owner transaction.
pub fn insert_picture_with_options_at(
&mut self,
position: &StoryRunPosition,
image_data: &[u8],
image_filename: &str,
width: Option<Length>,
height: Option<Length>,
options: Option<PictureOptions>,
) -> Result<PictureOccurrence> {
let format = oxml_media::ImageFormat::sniff(image_data)
.ok_or_else(|| Error::Other("picture image bytes are unsupported".to_owned()))?;
if matches!(
format,
oxml_media::ImageFormat::Png
| oxml_media::ImageFormat::Jpeg
| oxml_media::ImageFormat::Gif
| oxml_media::ImageFormat::Bmp
| oxml_media::ImageFormat::Webp
) && oxml_media::probe(image_data).is_none()
{
return Err(Error::Other(
"picture image bytes are unsupported".to_owned(),
));
}
let (width, height) = match (width, height) {
(Some(width), Some(height)) if width.to_emu() > 0 && height.to_emu() > 0 => {
(width, height)
}
(None, None) => {
let size = oxml_media::probe(image_data)
.and_then(|info| info.native_size(72.0))
.ok_or_else(|| Error::UnavailableImageDimensions {
filename: image_filename.to_owned(),
})?;
(Length::emu(size.width_emu), Length::emu(size.height_emu))
}
_ => {
return Err(Error::Other(
"picture dimensions require two positive lengths or neither".to_owned(),
));
}
};
let mut options = options.unwrap_or(PictureOptions {
width,
height,
crop: None,
anchor: None,
name: None,
description: None,
title: None,
decorative: false,
});
options.width = width;
options.height = height;
validate_picture_options(&options)?;
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let snapshot = candidate.story_range_paragraph_snapshot(&position.location)?;
let original_paragraph = CT_P::from_xml_fragment(snapshot.xml())?;
let original = original_paragraph.accepted_view();
if position.run_index > original.runs.len() {
return Err(Error::Other("picture run boundary is out of range".into()));
}
let ordinal = original
.runs
.iter()
.take(position.run_index)
.flat_map(|run| {
run.content
.iter()
.filter_map(|content| match content {
RunContent::Drawing(drawing) => Some(drawing),
_ => None,
})
.chain(run.alt_drawings.iter())
})
.filter(|drawing| {
drawing
.inline
.as_ref()
.is_some_and(|i| !i.embed_id.is_empty())
|| drawing
.anchor
.as_ref()
.is_some_and(|a| !a.embed_id.is_empty())
})
.count();
let relationship = candidate.add_image_relationship_checked(
position.location.story.part_name(),
image_data,
image_filename,
)?;
let drawing = picture_drawing(
&relationship,
candidate.identifiers.reserve_drawing_id()?,
&options,
);
let mut paragraph = CT_P::new();
paragraph.runs.push(CT_R {
alt_drawings: Vec::new(),
properties: None,
content: vec![RunContent::Drawing(drawing)],
extra_xml: Vec::new(),
extra_xml_positions: Vec::new(),
});
close_typed_story_drawing_namespaces(&mut paragraph)?;
candidate.insert_story_run_staged(position, paragraph.runs.remove(0))?;
let reopened = candidate.prepare_and_reopen_staged()?;
let picture = reopened
.pictures()?
.into_iter()
.find(|picture| {
picture.location.story.kind == position.location.story.kind
&& picture.location.story.part_name == position.location.story.part_name
&& picture.location.story.owner_index == position.location.story.owner_index
&& picture.location.index_path == position.location.index_path
&& picture.ordinal == ordinal
})
.ok_or_else(|| Error::Other("inserted picture occurrence was not projected".into()))?;
self.commit_staged_mutation(reopened);
Ok(picture)
}
/// Insert an inline picture paragraph after checked story content.
///
/// Omitting `after` appends to the story. Width and height must either both
/// be present or both be omitted. Omitted dimensions use the image's native
/// size at 72 DPI.
pub fn insert_picture_to_story(
&mut self,
story: &StoryId,
after: Option<&ContentLocation>,
image_data: &[u8],
image_filename: &str,
width: Option<Length>,
height: Option<Length>,
) -> Result<ContentLocation> {
self.insert_picture_to_story_with_options(
story,
after,
image_data,
image_filename,
width,
height,
None,
)
}
/// Import picture metadata and placement through the same checked owner transaction.
pub fn insert_picture_to_story_with_options(
&mut self,
story: &StoryId,
after: Option<&ContentLocation>,
image_data: &[u8],
image_filename: &str,
width: Option<Length>,
height: Option<Length>,
options: Option<PictureOptions>,
) -> Result<ContentLocation> {
let format = oxml_media::ImageFormat::sniff(image_data)
.ok_or_else(|| Error::Other("picture image bytes are unsupported".to_owned()))?;
if matches!(
format,
oxml_media::ImageFormat::Png
| oxml_media::ImageFormat::Jpeg
| oxml_media::ImageFormat::Gif
| oxml_media::ImageFormat::Bmp
| oxml_media::ImageFormat::Webp
) && oxml_media::probe(image_data).is_none()
{
return Err(Error::Other(
"picture image bytes are unsupported".to_owned(),
));
}
let (width, height) = match (width, height) {
(Some(width), Some(height)) => (width, height),
(None, None) => {
let native_size = oxml_media::probe(image_data)
.and_then(|info| info.native_size(72.0))
.ok_or_else(|| Error::UnavailableImageDimensions {
filename: image_filename.to_owned(),
})?;
(
Length::emu(native_size.width_emu),
Length::emu(native_size.height_emu),
)
}
_ => {
return Err(Error::Other(
"picture width and height must both be provided or both be omitted".to_owned(),
));
}
};
let mut options = options.unwrap_or(PictureOptions {
width,
height,
crop: None,
anchor: None,
name: None,
description: None,
title: None,
decorative: false,
});
options.width = width;
options.height = height;
validate_picture_options(&options)?;
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let (source, mut story_owner) = candidate.story_source_and_owner(story)?;
let part_name = source.part_name.clone();
let mut source_xml = source.xml.into_owned();
// The ordinary story projection is typed XML. For picture insertion,
// use the retained main part when it still describes the current model.
if part_name == candidate.doc_part_name
&& let Some(retained) = candidate.package.get_part(&part_name)
&& CT_Document::from_xml(retained).ok().as_ref() == Some(&candidate.document)
&& retained != source_xml
{
source_xml = retained.to_vec();
story_owner = scan_story_owners(&source_xml, StoryKind::Body)?
.into_iter()
.find(|owner| owner.kind == story.kind && owner.owner_index == story.owner_index)
.ok_or_else(|| Error::Other("picture story source disappeared".to_owned()))?;
}
let direct_items = direct_story_content_items(&source_xml, &story_owner)?;
let (boundary, inserted_direct_index) = match after {
Some(after) => {
if after.story != *story {
return Err(Error::Other(
"picture placement must belong to the target story".to_owned(),
));
}
let item = validated_direct_content_item(&source_xml, &story_owner, after)?;
let index = direct_items
.iter()
.position(|candidate| candidate.full == item.full)
.ok_or_else(|| Error::Other("picture placement was not found".to_owned()))?;
(item.full.end, index + 1)
}
None => (
story_owner_content_end(&source_xml, &story_owner)?,
direct_items.len(),
),
};
let relationship_id =
candidate.add_image_relationship_checked(&part_name, image_data, image_filename)?;
let drawing_id = candidate.identifiers.reserve_drawing_id()?;
let drawing = picture_drawing(&relationship_id, drawing_id, &options);
let mut paragraph = CT_P::new();
paragraph.runs.push(CT_R {
alt_drawings: Vec::new(),
properties: None,
content: vec![RunContent::Drawing(drawing)],
extra_xml: Vec::new(),
extra_xml_positions: Vec::new(),
});
close_typed_story_drawing_namespaces(&mut paragraph)?;
let mut fragment = ContentFragment::paragraph(paragraph)?;
fragment.source_part_name = Some(part_name.clone());
fragment.source_story_kind = Some(story.kind);
fragment.source_owner_index = Some(story.owner_index);
let fragment_xml = content_fragment_for_insertion(&candidate, story, &fragment)?;
let mut updated = source_xml;
insert_story_fragment(&mut updated, &story_owner, boundary, fragment_xml)?;
set_story_source_xml(&mut candidate, &part_name, updated)?;
if part_name == candidate.doc_part_name {
candidate
.identifiers
.register_nested_story_relationship(story, relationship_id);
candidate
.identifiers
.register_nested_story_drawing(story, drawing_id);
}
let reopened = candidate.prepare_and_reopen_staged()?;
let location = {
let refreshed_story = reopened
.stories()?
.into_iter()
.find(|candidate| {
candidate.kind == story.kind
&& candidate.part_name == story.part_name
&& candidate.owner_index == story.owner_index
})
.ok_or_else(|| Error::Other("inserted picture story was not found".to_owned()))?;
let (source, owner) = reopened.story_source_and_owner(&refreshed_story)?;
let direct = direct_story_content_items(source.xml.as_ref(), &owner)?;
let inserted = direct.get(inserted_direct_index).ok_or_else(|| {
Error::Other("inserted picture paragraph was not found".to_owned())
})?;
let projected = scan_story_items(source.xml.as_ref(), &owner)?;
let index = projected
.iter()
.position(|item| item.full == inserted.full)
.ok_or_else(|| {
Error::Other("inserted picture paragraph was not projected".to_owned())
})?;
ContentLocation::new(refreshed_story, StoryItemKind::Paragraph, vec![index])
};
self.commit_staged_mutation(reopened);
Ok(location)
}
/// Append one configured picture paragraph to a checked story owner.
pub fn add_picture_with_options(
&mut self,
story: &StoryId,
image_data: &[u8],
image_filename: &str,
options: PictureOptions,
) -> Result<()> {
validate_picture_options(&options)?;
if oxml_media::probe(image_data).is_none() {
return Err(Error::Other(format!(
"picture `{image_filename}` is unsupported or malformed"
)));
}
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
candidate.story_source_and_owner(story)?;
let owner = story.part_name.clone();
let relationship_id =
candidate.add_image_relationship_checked(&owner, image_data, image_filename)?;
let drawing_id = candidate.identifiers.reserve_drawing_id()?;
let drawing = picture_drawing(&relationship_id, drawing_id, &options);
let mut paragraph = CT_P::new();
paragraph.runs.push(CT_R {
alt_drawings: Vec::new(),
properties: None,
content: vec![RunContent::Drawing(drawing)],
extra_xml: Vec::new(),
extra_xml_positions: Vec::new(),
});
candidate.append_authored_paragraph_to_story(story, paragraph)?;
if owner == candidate.doc_part_name && story.kind != StoryKind::Body {
candidate
.identifiers
.register_nested_story_relationship(story, relationship_id);
candidate
.identifiers
.register_nested_story_drawing(story, drawing_id);
}
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
fn append_internal_hyperlink_staged(
&mut self,
location: &ContentLocation,
text: &str,
bookmark: &str,
tooltip: Option<&str>,
) -> Result<StoryRunPosition> {
for value in [Some(text), Some(bookmark), tooltip].into_iter().flatten() {
oxml_core::xml::reject_non_xml_characters("internal hyperlink", value)?;
}
crate::comments::validate_bookmark_name(bookmark)?;
let matching = self.story_ranges()?.into_iter().filter(|entry| {
matches!(entry.kind(), crate::StoryRangeKind::Bookmark { name, .. } if name == bookmark)
}).count();
if matching != 1 {
return Err(Error::Other(
"internal hyperlink bookmark is missing or ambiguous".into(),
));
}
let story = self
.stories()?
.into_iter()
.find(|story| {
story.kind == location.story.kind
&& story.part_name == location.story.part_name
&& story.owner_index == location.story.owner_index
})
.ok_or_else(|| Error::Other("hyperlink story disappeared".into()))?;
let refreshed =
ContentLocation::new(story, location.item_kind, location.index_path.clone());
let run_index = self.edit_story_paragraph(&refreshed, |paragraph| {
let index = paragraph.run_count();
paragraph.add_internal_hyperlink(text, bookmark, tooltip);
Ok(index)
})?;
let story = self
.stories()?
.into_iter()
.find(|story| {
story.kind == location.story.kind
&& story.part_name == location.story.part_name
&& story.owner_index == location.story.owner_index
})
.ok_or_else(|| Error::Other("hyperlink story disappeared".into()))?;
Ok(StoryRunPosition {
location: ContentLocation::new(story, location.item_kind, location.index_path.clone()),
run_index,
})
}
/// Add a checked internal link without allocating an external relationship.
pub fn add_internal_hyperlink_at(
&mut self,
location: &ContentLocation,
text: &str,
bookmark: &str,
tooltip: Option<&str>,
) -> Result<StoryRunPosition> {
self.story_range_paragraph_snapshot(location)?;
let mut candidate = self.clone_for_staging();
let result =
candidate.append_internal_hyperlink_staged(location, text, bookmark, tooltip)?;
let reopened = candidate.prepare_and_reopen_staged()?;
let story = reopened
.stories()?
.into_iter()
.find(|s| {
s.kind == result.location.story.kind
&& s.part_name == result.location.story.part_name
&& s.owner_index == result.location.story.owner_index
})
.ok_or_else(|| Error::Other("hyperlink story disappeared".into()))?;
self.commit_staged_mutation(reopened);
Ok(StoryRunPosition {
location: ContentLocation::new(
story,
result.location.item_kind,
result.location.index_path,
),
run_index: result.run_index,
})
}
/// Stage a heading bookmark and source link together, with unique IDs and
/// one caller publication. Existing complete heading bookmarks are reused.
pub fn add_heading_hyperlink_at(
&mut self,
source: &ContentLocation,
heading: &ContentLocation,
text: &str,
tooltip: Option<&str>,
) -> Result<StoryRunPosition> {
self.story_range_paragraph_snapshot(source)?;
let item = self.story_range_paragraph_snapshot(heading)?;
let paragraph = CT_P::from_xml_fragment(&item.xml)?;
let mut level = paragraph.properties.as_ref().and_then(|p| p.outline_lvl);
let mut style = paragraph
.properties
.as_ref()
.and_then(|p| p.style_id.as_deref());
let mut seen = HashSet::new();
while level.is_none() {
let Some(id) = style else {
break;
};
if !seen.insert(id) {
return Err(Error::Other("heading style ancestry is cyclic".into()));
}
let Some(value) = self.styles.get_by_id(id) else {
break;
};
level = value.ppr.as_ref().and_then(|p| p.outline_lvl);
style = value.based_on.as_deref();
}
if !level.is_some_and(|level| level < 9)
&& Self::detect_heading_level_for_toc(¶graph).is_none()
{
return Err(Error::Other(
"heading target is not an outline heading".into(),
));
}
let run_count = paragraph.accepted_run_paths().len();
let ranges = self.story_ranges()?;
let existing = ranges.iter().find_map(|entry| {
let crate::StoryRangeKind::Bookmark { name, .. } = entry.kind() else {
return None;
};
let range = entry.range();
(crate::comments::validate_bookmark_name(name).is_ok()
&& range.start.location == *heading
&& range.end.location == *heading
&& range.start.run_index == 0
&& range.end.run_index == run_count)
.then(|| name.clone())
});
let mut candidate = self.clone_for_staging();
let name = match existing {
Some(name) => name,
None => {
let names = ranges
.iter()
.filter_map(|entry| match entry.kind() {
crate::StoryRangeKind::Bookmark { name, .. } => Some(name.as_str()),
_ => None,
})
.collect::<HashSet<_>>();
let mut suffix = 1u32;
let name = loop {
let name = format!("Heading_{suffix}");
if !names.contains(name.as_str()) {
break name;
}
suffix = suffix
.checked_add(1)
.ok_or_else(|| Error::Other("heading bookmark names exhausted".into()))?;
};
candidate.add_story_bookmark(
&name,
crate::StoryRunRange {
start: crate::StoryRunPosition {
location: heading.clone(),
run_index: 0,
},
end: crate::StoryRunPosition {
location: heading.clone(),
run_index: run_count,
},
},
)?;
name
}
};
let result = candidate.append_internal_hyperlink_staged(source, text, &name, tooltip)?;
let reopened = candidate.prepare_and_reopen_staged()?;
let story = reopened
.stories()?
.into_iter()
.find(|s| {
s.kind == source.story.kind
&& s.part_name == source.story.part_name
&& s.owner_index == source.story.owner_index
})
.ok_or_else(|| Error::Other("heading hyperlink source disappeared".into()))?;
self.commit_staged_mutation(reopened);
Ok(StoryRunPosition {
location: ContentLocation::new(story, source.item_kind, source.index_path.clone()),
run_index: result.run_index,
})
}
/// Append one modeled DrawingML text box with a VML compatibility fallback.
pub fn add_text_box_to_story(
&mut self,
story: &StoryId,
text: &str,
options: TextBoxOptions,
) -> Result<()> {
validate_text_box_options(&options)?;
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
candidate.story_source_and_owner(story)?;
let drawing_id = candidate.identifiers.reserve_drawing_id()?;
let alternate = authored_text_box_alternate_content(drawing_id, text, &options)?;
let drawing =
rdocx_oxml::drawing::parse_alternate_content(&alternate, &[]).ok_or_else(|| {
Error::Other("authored text box did not reopen as DrawingML".to_owned())
})?;
let mut paragraph = CT_P::new();
paragraph.runs.push(CT_R {
properties: None,
content: Vec::new(),
extra_xml: vec![alternate],
extra_xml_positions: vec![0],
alt_drawings: vec![drawing],
});
candidate.append_authored_paragraph_to_story(story, paragraph)?;
if story.part_name == candidate.doc_part_name && story.kind != StoryKind::Body {
candidate
.identifiers
.register_nested_story_drawing(story, drawing_id);
}
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
fn append_authored_paragraph_to_story(
&mut self,
story: &StoryId,
mut paragraph: CT_P,
) -> Result<()> {
self.story_source_and_owner(story)?;
if story.kind == StoryKind::Body && story.part_name == self.doc_part_name {
self.document
.body
.content
.push(BodyContent::Paragraph(paragraph));
} else {
close_typed_story_drawing_namespaces(&mut paragraph)?;
self.append_fragment_to_story(story, ContentFragment::paragraph(paragraph)?)?;
}
Ok(())
}
/// Append one external hyperlink paragraph to a checked story owner.
pub fn add_hyperlink_to_story(&mut self, story: &StoryId, text: &str, url: &str) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
candidate.story_source_and_owner(story)?;
let owner = story.part_name.clone();
let relationship_id =
candidate.add_external_relationship_checked(&owner, rel_types::HYPERLINK, url)?;
let mut paragraph = CT_P::new();
crate::Paragraph {
inner: &mut paragraph,
}
.add_hyperlink(text, &relationship_id);
paragraph
.hyperlinks
.last_mut()
.expect("the authored hyperlink was appended")
.extra_attributes
.push(("xmlns:r".to_owned(), drawing_ns::R.to_owned()));
let preserve_typed_body = story.kind == StoryKind::Body && owner == candidate.doc_part_name;
if preserve_typed_body {
candidate
.document
.body
.content
.push(BodyContent::Paragraph(paragraph));
} else {
let fragment = ContentFragment::paragraph(paragraph)?;
candidate.append_fragment_to_story(story, fragment)?;
}
if owner == candidate.doc_part_name {
candidate
.identifiers
.register_nested_story_relationship(story, relationship_id);
}
candidate.prepare_staged_package()?;
candidate.clone_for_staging().reopen_prepared_staged()?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Add an external hyperlink relationship to a checked story owner.
pub fn add_hyperlink_relationship_to_story(
&mut self,
story: &StoryId,
url: &str,
) -> Result<String> {
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
candidate.story_source_and_owner(story)?;
let owner = story.part_name.clone();
let matching_before = candidate
.package
.get_part_rels(&owner)
.into_iter()
.flat_map(|relationships| &relationships.items)
.filter(|relationship| {
relationship.rel_type == rel_types::HYPERLINK
&& relationship.target == url
&& relationship.target_mode.as_deref() == Some("External")
})
.count();
candidate.add_external_relationship_checked(&owner, rel_types::HYPERLINK, url)?;
let reopened = candidate.prepare_and_reopen_staged()?;
let relationship_id = reopened
.package
.get_part_rels(&owner)
.into_iter()
.flat_map(|relationships| &relationships.items)
.filter(|relationship| {
relationship.rel_type == rel_types::HYPERLINK
&& relationship.target == url
&& relationship.target_mode.as_deref() == Some("External")
})
.nth(matching_before)
.map(|relationship| relationship.id.clone())
.ok_or_else(|| Error::Other("authored hyperlink relationship was lost".to_owned()))?;
self.commit_staged_mutation(reopened);
Ok(relationship_id)
}
/// Resolve and validate one internal relationship in a checked story scope.
pub fn validate_internal_relationship_for_story(
&self,
story: &StoryId,
relationship_id: &str,
expected_type: &str,
) -> Result<String> {
self.story_source_and_owner(story)?;
let relationships = self
.package
.get_part_rels(&story.part_name)
.ok_or_else(|| {
Error::Other(format!(
"story owner {} has no relationship scope",
story.part_name
))
})?;
let relationship = relationships.get_by_id(relationship_id).ok_or_else(|| {
Error::Other(format!(
"story owner {} has no relationship {relationship_id}",
story.part_name
))
})?;
if relationship.rel_type != expected_type {
return Err(Error::Other(format!(
"story relationship {relationship_id} has type {}, expected {expected_type}",
relationship.rel_type
)));
}
if !relationship_is_internal(relationship) {
return Err(Error::Other(format!(
"story relationship {relationship_id} must be internal"
)));
}
let target = OpcPackage::resolve_rel_target(&story.part_name, &relationship.target);
if !self.package.contains_part(&target) {
return Err(Error::Other(format!(
"story relationship {relationship_id} targets missing part {target}"
)));
}
Ok(target)
}
/// Inventory supported pictures once per package source, in story and
/// physical source order. Only the existing accepted paragraph projection
/// admits a drawing. Unknown XML and unselected alternatives stay opaque.
pub fn pictures(&self) -> Result<Vec<PictureOccurrence>> {
let mut result = Vec::new();
let mut seen = HashSet::new();
for source in self.story_sources()? {
let owners = scan_story_owners(&source.xml, source.root_kind)?;
for owner in owners {
let story = StoryId {
kind: owner.kind,
part_name: source.part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
};
for (location, span) in
story_range_paragraph_spans(&source.xml, &[(story.clone(), owner.clone())])?
.into_iter()
.flatten()
{
let scope = story_namespace_scope_at(&source.xml, span.start)?;
let closed =
close_content_fragment_namespaces(&source.xml[span.clone()], &scope)?;
let paragraph = CT_P::from_xml_fragment(&closed)?;
let accepted = paragraph.accepted_view();
let drawings = accepted.runs.iter().flat_map(|run| {
run.content
.iter()
.filter_map(|content| match content {
RunContent::Drawing(drawing) => Some(drawing),
_ => None,
})
.chain(run.alt_drawings.iter())
});
let mut occurrences = Vec::new();
let mut claimed = HashSet::new();
for drawing in drawings {
let (
raw,
embed,
link,
chart,
_projected_width,
_projected_height,
name,
description,
title,
decorative,
anchored,
) = if let Some(inline) = &drawing.inline {
(
&inline.raw_xml,
&inline.embed_id,
&inline.link_id,
&inline.chart_rel_id,
inline.extent_cx.0,
inline.extent_cy.0,
&inline.name,
&inline.description,
&inline.title,
inline.decorative,
false,
)
} else if let Some(anchor) = &drawing.anchor {
(
&anchor.raw_xml,
&anchor.embed_id,
&anchor.link_id,
&anchor.chart_rel_id,
anchor.extent_cx.0,
anchor.extent_cy.0,
&anchor.name,
&anchor.description,
&anchor.title,
anchor.decorative,
true,
)
} else {
continue;
};
if chart.is_some() || (embed.is_empty() && link.is_none()) {
continue;
}
let raw = raw.as_ref().ok_or_else(|| {
Error::Other("picture has no physical source identity".into())
})?;
if raw.is_empty() {
return Err(Error::Other("picture source is empty".into()));
}
let Some((width, height)) = picture_source_extent(raw, &scope, anchored)?
else {
continue;
};
let matches = source.xml[span.clone()]
.windows(raw.len())
.enumerate()
.filter(|(_, bytes)| *bytes == raw.as_slice())
.map(|(offset, _)| span.start + offset)
.collect::<Vec<_>>();
// Physical copies that cannot be distinguished by the
// admitted projection are not silently reinterpreted.
let count = accepted
.runs
.iter()
.flat_map(|run| {
run.content
.iter()
.filter_map(|c| match c {
RunContent::Drawing(d) => Some(d),
_ => None,
})
.chain(run.alt_drawings.iter())
})
.filter(|d| {
d.inline.as_ref().and_then(|i| i.raw_xml.as_ref()) == Some(raw)
|| d.anchor.as_ref().and_then(|a| a.raw_xml.as_ref())
== Some(raw)
})
.count();
if matches.len() != count {
return Err(Error::Other(
"picture has ambiguous alternate physical copies".into(),
));
}
let start = matches
.into_iter()
.find(|start| !claimed.contains(start))
.ok_or_else(|| {
Error::Other("picture source correspondence is missing".into())
})?;
claimed.insert(start);
if width <= 0 || height <= 0 {
return Err(Error::Other("picture extent must be positive".into()));
}
let relationship_id = link.as_deref().unwrap_or(embed);
let (media_part_name, external_url, bytes, content_type) = if link.is_some()
{
let relationship = self
.package
.get_part_rels(&story.part_name)
.and_then(|rels| rels.get_by_id(relationship_id))
.ok_or_else(|| {
Error::Other("linked picture relationship is missing".into())
})?;
if relationship.rel_type != rel_types::IMAGE
|| relationship_is_internal(relationship)
{
return Err(Error::Other(
"linked picture must have an external image relationship"
.into(),
));
}
(
None,
Some(relationship.target.clone()),
Vec::new(),
String::new(),
)
} else {
let target = self.validate_internal_relationship_for_story(
&story,
embed,
rel_types::IMAGE,
)?;
let target = self
.package
.parts
.keys()
.filter(|name| {
OpcPackage::resolve_rel_target("/", name)
.eq_ignore_ascii_case(&target)
})
.min()
.cloned()
.ok_or_else(|| {
Error::Other("picture media identity is missing".into())
})?;
let bytes = self
.package
.get_part(&target)
.ok_or_else(|| Error::Other("picture media is missing".into()))?
.to_vec();
let content_type = self
.package
.content_types
.content_type_for(&target)
.filter(|kind| kind.starts_with("image/"))
.ok_or_else(|| {
Error::Other("picture media content type is unsupported".into())
})?
.to_owned();
(Some(target), None, bytes, content_type)
};
occurrences.push((
start,
PictureOccurrence {
location: location.clone(),
ordinal: 0,
relationship_id: relationship_id.to_owned(),
media_part_name,
external_url,
width,
height,
name: name.clone(),
description: description.clone(),
title: title.clone(),
decorative,
anchored,
content_type,
bytes,
source: raw.clone(),
},
));
}
occurrences.sort_by_key(|(start, _)| *start);
for (ordinal, (start, mut picture)) in occurrences.into_iter().enumerate() {
picture.ordinal = ordinal;
if seen.insert((source.part_name.clone(), start)) {
result.push(picture);
}
}
}
}
}
Ok(result)
}
/// Resize one frozen occurrence, preserving shared media and every other
/// physical drawing. Changed source or unsupported copies refuse atomically.
pub fn set_picture_size_at(
&mut self,
picture: &PictureOccurrence,
width: Length,
height: Length,
) -> Result<()> {
if picture.external_url.is_some() {
return Err(Error::Other(
"linked picture resizing is unsupported".into(),
));
}
let (width, height) = (width.to_emu(), height.to_emu());
if width <= 0 || height <= 0 {
return Err(Error::Other("picture dimensions must be positive".into()));
}
let current = self
.pictures()?
.into_iter()
.find(|current| {
current.location == picture.location && current.ordinal == picture.ordinal
})
.ok_or_else(|| Error::Other("picture occurrence is stale or foreign".into()))?;
if current != *picture {
return Err(Error::Other("picture occurrence source changed".into()));
}
if (width, height) == (picture.width, picture.height) {
return Ok(());
}
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let (source, owner) = candidate.story_source_and_owner(&picture.location.story)?;
let part_name = source.part_name.clone();
let spans = story_range_paragraph_spans(
&source.xml,
&[(picture.location.story.clone(), owner.clone())],
)?
.into_iter()
.flatten();
let span = spans
.into_iter()
.find(|(location, _)| location == &picture.location)
.map(|(_, span)| span)
.ok_or_else(|| Error::Other("picture paragraph disappeared".into()))?;
let scope = story_namespace_scope_at(&source.xml, span.start)?;
let paragraph = &source.xml[span.clone()];
let matches = paragraph
.windows(picture.source.len())
.enumerate()
.filter(|(_, bytes)| *bytes == picture.source.as_slice())
.map(|(offset, _)| offset)
.collect::<Vec<_>>();
let equal_before = candidate
.pictures()?
.into_iter()
.filter(|other| {
other.location == picture.location
&& other.ordinal < picture.ordinal
&& other.source == picture.source
})
.count();
let offset = *matches
.get(equal_before)
.ok_or_else(|| Error::Other("picture physical source disappeared".into()))?;
let closed = close_content_fragment_namespaces(&picture.source, &scope)?;
let (resized, count) =
resize_story_pictures(&closed, &picture.relationship_id, width, height)?;
if count != 1 {
return Err(Error::Other(
"picture occurrence has unsupported nested representation".into(),
));
}
// Namespace closure is projection-only. Numeric edits are transported
// back onto the exact physical owner without rewriting its bindings.
let (boundaries, _) = comment_namespace_source_boundaries(&picture.source, &closed)?
.ok_or_else(|| Error::Other("picture namespace closure changed source".into()))?;
let mut edits = Vec::new();
let mut before_reader = NsReader::from_reader(closed.as_slice());
let mut after_reader = NsReader::from_reader(resized.as_slice());
loop {
let start = before_reader.buffer_position() as usize;
let before = before_reader
.read_event()
.map_err(|e| Error::Other(e.to_string()))?;
let after = after_reader
.read_event()
.map_err(|e| Error::Other(e.to_string()))?;
if before != after {
let physical_start = boundaries
.iter()
.find_map(|(raw, closed)| (*closed == start).then_some(*raw))
.ok_or_else(|| Error::Other("picture edit has no physical boundary".into()))?;
let end = before_reader.buffer_position() as usize;
let physical_end = boundaries
.iter()
.find_map(|(raw, closed)| (*closed == end).then_some(*raw))
.ok_or_else(|| Error::Other("picture edit has no physical boundary".into()))?;
// Only numeric values change, the original element spelling
// and all other attributes remain byte-exact.
let Event::Empty(ref before_tag) = before else {
return Err(Error::Other("picture extent source is unsupported".into()));
};
let Event::Empty(ref after_tag) = after else {
return Err(Error::Other("picture extent source is unsupported".into()));
};
let mut tag = picture.source[physical_start..physical_end].to_vec();
let mut values = Vec::new();
for (a, b) in before_tag.attributes().zip(after_tag.attributes()) {
let (a, b) = (
a.map_err(|e| Error::Other(e.to_string()))?,
b.map_err(|e| Error::Other(e.to_string()))?,
);
if a.key != b.key {
return Err(Error::Other(
"picture resize changed attribute ownership".into(),
));
}
if a.value != b.value {
let (a_start, a_end) = story_attribute_value_span(&tag, a.key.as_ref())
.ok_or_else(|| {
Error::Other("picture extent attribute missing".into())
})?;
values.push((a_start..a_end, b.value.to_vec()));
}
}
values.sort_by_key(|(range, _)| range.start);
for (range, value) in values.into_iter().rev() {
tag.splice(range, value);
}
edits.push((physical_start..physical_end, tag));
}
if matches!(before, Event::Eof) {
break;
}
}
let mut raw = picture.source.clone();
for (range, value) in edits.into_iter().rev() {
raw.splice(range, value);
}
let mut updated = source.xml.into_owned();
updated.splice(
span.start + offset..span.start + offset + picture.source.len(),
raw,
);
set_story_source_xml(&mut candidate, &part_name, updated)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Fetch image bytes through a checked story-local relationship.
pub fn image_data_for_story(&self, story: &StoryId, relationship_id: &str) -> Result<Vec<u8>> {
let target = self.validate_internal_relationship_for_story(
story,
relationship_id,
rel_types::IMAGE,
)?;
self.package
.get_part(&target)
.map(ToOwned::to_owned)
.ok_or_else(|| Error::Other(format!("image relationship target {target} is missing")))
}
/// Replace a picture through a checked story-local relationship.
pub fn replace_image_for_story(
&mut self,
story: &StoryId,
relationship_id: &str,
image_data: &[u8],
) -> Result<()> {
self.validate_internal_relationship_for_story(story, relationship_id, rel_types::IMAGE)?;
self.replace_image_relationship(&story.part_name, relationship_id, image_data)
}
fn replace_image_relationship(
&mut self,
owner: &str,
relationship_id: &str,
image_data: &[u8],
) -> Result<()> {
let format = oxml_media::ImageFormat::sniff(image_data).ok_or_else(|| {
Error::Other("replacement bytes are not a supported image".to_owned())
})?;
let old_target = self
.package
.get_part_rels(owner)
.and_then(|relationships| relationships.get_by_id(relationship_id))
.filter(|relationship| {
relationship.rel_type == rel_types::IMAGE
&& relationship_is_internal(relationship)
})
.map(|relationship| OpcPackage::resolve_rel_target(owner, &relationship.target))
.ok_or_else(|| {
Error::Other(format!(
"relationship owner {owner} has no internal image relationship {relationship_id}"
))
})?;
if !self.package.contains_part(&old_target) {
return Err(Error::Other(format!(
"image relationship target {old_target} is missing"
)));
}
let mut candidate = self.clone_for_staging();
let reference_count = std::iter::once(("/", &candidate.package.package_rels))
.chain(
candidate
.package
.part_rels
.iter()
.map(|(source, relationships)| (source.as_str(), relationships)),
)
.flat_map(|(source, relationships)| {
relationships
.items
.iter()
.map(move |relationship| (source, relationship))
})
.filter(|(source, relationship)| {
relationship_is_internal(relationship)
&& OpcPackage::resolve_rel_target(source, &relationship.target) == old_target
})
.count();
let compatible_extension = old_target
.rsplit_once('.')
.and_then(|(_, extension)| oxml_media::ImageFormat::from_extension(extension))
== Some(format);
let new_target = if reference_count == 1 && compatible_extension {
old_target.clone()
} else {
candidate
.identifiers
.reserve_part_name("/word/media", "image", format.extension())?
};
if new_target != old_target {
let relationship = candidate
.package
.get_part_rels_mut(owner)
.and_then(|relationships| {
relationships
.items
.iter_mut()
.find(|relationship| relationship.id == relationship_id)
})
.ok_or_else(|| {
Error::Other(format!(
"image relationship {relationship_id} disappeared during replacement"
))
})?;
relationship.target = relative_target(owner, &new_target);
}
candidate.install_reserved_image_part(&new_target, image_data, format);
if new_target != old_target {
let old_target_is_referenced = std::iter::once(("/", &candidate.package.package_rels))
.chain(
candidate
.package
.part_rels
.iter()
.map(|(source, relationships)| (source.as_str(), relationships)),
)
.any(|(source, relationships)| {
relationships.items.iter().any(|relationship| {
relationship_is_internal(relationship)
&& OpcPackage::resolve_rel_target(source, &relationship.target)
== old_target
})
});
if !old_target_is_referenced {
candidate.package.remove_part(&old_target);
candidate.package.remove_part_rels(&old_target);
candidate.package.content_types.remove_override(&old_target);
candidate.identifiers.retire_authored_part(&old_target);
if let Some((_, extension)) = old_target.rsplit_once('.') {
let extension = extension.to_ascii_lowercase();
let default_is_used = candidate.package.parts.keys().any(|part_name| {
!candidate.package.content_types.contains_override(part_name)
&& part_name.rsplit_once('.').is_some_and(|(_, candidate)| {
candidate.eq_ignore_ascii_case(&extension)
})
});
if !default_is_used
&& candidate
.identifiers
.authored_content_type_defaults
.remove(&extension)
{
candidate.package.content_types.remove_default(&extension);
candidate
.identifiers
.content_type_defaults
.remove(&extension);
}
}
}
}
candidate.invalidate_layout();
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Resolve an external hyperlink through a checked story-local relationship.
pub fn hyperlink_url_for_story(
&self,
story: &StoryId,
relationship_id: &str,
) -> Result<String> {
self.story_source_and_owner(story)?;
self.hyperlink_url_for_validated_story(story, relationship_id)
}
fn hyperlink_url_for_validated_story(
&self,
story: &StoryId,
relationship_id: &str,
) -> Result<String> {
let relationships = self
.package
.get_part_rels(&story.part_name)
.ok_or_else(|| {
Error::Other(format!(
"story owner {} has no relationship scope",
story.part_name
))
})?;
let relationship = relationships.get_by_id(relationship_id).ok_or_else(|| {
Error::Other(format!(
"story owner {} has no relationship {relationship_id}",
story.part_name
))
})?;
if relationship.rel_type != rel_types::HYPERLINK
|| relationship.target_mode.as_deref() != Some("External")
{
return Err(Error::Other(format!(
"story relationship {relationship_id} is not an external hyperlink"
)));
}
Ok(relationship.target.clone())
}
/// Replace the visible text of one checked story item atomically.
/// Replace one checked story item in its actual package owner.
///
/// The item kind, physical story and surrounding bytes remain unchanged.
/// Relationship-bearing content must already belong to this story part.
pub fn replace_story_content(
&mut self,
location: &ContentLocation,
fragment: ContentFragment,
) -> Result<()> {
if location.is_end
|| location.item_kind != fragment.kind
|| location.item_kind == StoryItemKind::PreservedNode
{
return Err(Error::Other(
"story replacement requires the same modeled item kind".to_owned(),
));
}
reject_section_owning_content_fragment(&fragment)?;
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let (source, item) = if location.item_kind == StoryItemKind::Paragraph {
candidate.story_range_paragraph_source(location)?
} else {
candidate.story_item_source(location)?
};
if item.kind != location.item_kind {
return Err(StoryError::KindMismatch {
expected: location.item_kind,
actual: item.kind,
}
.into());
}
let part_name = source.part_name.clone();
let scope = story_namespace_scope_at(&source.xml, item.full.start)?;
let mut xml = source.xml.into_owned();
let replacement = content_fragment_for_insertion(&candidate, &location.story, &fragment)?;
let replacement = close_content_fragment_namespaces(&replacement, &scope)?;
xml.splice(item.full, replacement);
set_story_source_xml(&mut candidate, &part_name, xml)?;
candidate.reconcile_comment_removal(self)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Apply checked element content only when paragraph-owned section profiles
/// match the selected original. Generic content insertion keeps its stricter
/// section-owner refusal contract.
fn replace_checked_element_content(
&mut self,
location: &ContentLocation,
fragment: ContentFragment,
) -> Result<()> {
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE clone-before");
let mut candidate = self.clone_for_staging();
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE clone-after");
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE flush-before");
candidate.flush_to_package()?;
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE flush-after");
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE candidate-source-before");
let (source, item) = if location.item_kind == StoryItemKind::Paragraph {
candidate.story_range_paragraph_source(location)?
} else {
candidate.story_item_source(location)?
};
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE candidate-source-after");
if location.is_end || item.kind != fragment.kind || item.kind != location.item_kind {
return Err(Error::Other(
"element XML requires the same existing physical owner".into(),
));
}
let part_name = source.part_name.clone();
let scope = story_namespace_scope_at(&source.xml, item.full.start)?;
let original = close_content_fragment_namespaces(&source.xml[item.full.clone()], &scope)?;
let incoming = close_content_fragment_namespaces(&fragment.xml, &fragment.namespace_scope)?;
let profiles = |xml: &[u8]| -> Result<Vec<CT_SectPr>> {
let mut wrapped =
format!(r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body>"#).into_bytes();
wrapped.extend_from_slice(xml);
wrapped.extend_from_slice(b"</w:body></w:document>");
let document = CT_Document::from_xml(&wrapped)?;
let mut profiles = Vec::new();
visit_body_paragraphs(&document.body.content, false, &mut |paragraph| {
if let Some(section) = paragraph
.properties
.as_ref()
.and_then(|properties| properties.sect_pr.as_ref())
{
profiles.push(section.clone());
}
});
Ok(profiles)
};
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE profiles-before");
if profiles(&original)? != profiles(&incoming)? {
return Err(Error::Other("element XML changed or introduced paragraph-owned section properties are unsupported, use the checked section owner".into()));
}
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE profiles-after");
let mut xml = source.xml.into_owned();
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE insertion-before");
let replacement = content_fragment_for_insertion(&candidate, &location.story, &fragment)?;
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE insertion-after");
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE insertion-close-before");
let replacement = close_content_fragment_namespaces(&replacement, &scope)?;
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE insertion-close-after");
xml.splice(item.full, replacement);
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE setsource-before");
set_story_source_xml(&mut candidate, &part_name, xml)?;
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE setsource-after");
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE comments-before");
candidate.reconcile_comment_removal(self)?;
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE comments-after");
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE reopen-before");
let reopened = candidate.prepare_and_reopen_staged()?;
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE reopen-after");
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE commit-before");
self.commit_staged_mutation(reopened);
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE commit-after");
Ok(())
}
/// Namespace-complete XML for the held physical paragraph owner.
#[doc(hidden)]
pub fn paragraph_element_xml(&self, location: &ContentLocation) -> Result<Vec<u8>> {
Ok(self
.story_range_paragraph_snapshot(location)?
.xml()
.to_vec())
}
/// Namespace-complete XML for an accepted run in a held paragraph owner.
#[doc(hidden)]
pub fn run_element_xml(
&self,
location: &ContentLocation,
path: &rdocx_oxml::text::AcceptedRunPath,
) -> Result<Vec<u8>> {
let snapshot = self.story_range_paragraph_snapshot(location)?;
let paragraph = CT_P::from_xml_fragment(snapshot.xml())?;
let run = paragraph
.accepted_run(path)
.ok_or_else(|| Error::Other("element XML run owner is absent".into()))?;
let mut bytes = Vec::new();
run.to_xml(&mut Writer::new(&mut bytes))?;
let scope = BTreeMap::from([
("w".to_owned(), WORD_NAMESPACE.to_owned()),
("r".to_owned(), rdocx_oxml::namespace::R_NS.to_owned()),
]);
close_content_fragment_namespaces(&bytes, &scope)
}
/// Namespace-complete XML for an existing physical table owner.
#[doc(hidden)]
pub fn table_element_xml(&self, location: &ContentLocation) -> Result<Vec<u8>> {
let (source, item) = self.story_item_source(location)?;
if item.kind != StoryItemKind::Table || location.is_end {
return Err(Error::Other(
"element XML needs an existing physical table owner".into(),
));
}
let scope = story_namespace_scope_at(&source.xml, item.full.start)?;
close_content_fragment_namespaces(&source.xml[item.full], &scope)
}
/// Namespace-complete XML for a physical cell, including an empty cell.
#[doc(hidden)]
pub fn cell_element_xml(
&self,
table: &ContentLocation,
row: usize,
cell: usize,
) -> Result<Vec<u8>> {
let owner = self.story_table_cell(table, row, cell)?;
let (source, span) = self.story_source_and_owner(&owner)?;
let scope = story_namespace_scope_at(&source.xml, span.full.start)?;
close_content_fragment_namespaces(&source.xml[span.full], &scope)
}
/// Namespace-complete XML for an existing section properties owner.
#[doc(hidden)]
pub fn section_properties_xml(&self, index: usize) -> Result<Vec<u8>> {
let section = self
.section(index)
.ok_or_else(|| Error::Other("element XML section owner is absent".into()))?;
let mut bytes = Vec::new();
section.properties().to_xml(&mut Writer::new(&mut bytes))?;
let scope = BTreeMap::from([
("w".to_owned(), WORD_NAMESPACE.to_owned()),
("r".to_owned(), rdocx_oxml::namespace::R_NS.to_owned()),
]);
close_content_fragment_namespaces(&bytes, &scope)
}
/// Replace the checked existing paragraph owner atomically.
#[doc(hidden)]
pub fn replace_paragraph_xml(&mut self, location: &ContentLocation, xml: &str) -> Result<()> {
self.validate_model_resource_limits()?;
let selected = self.story_range_paragraph_snapshot(location)?;
if selected.xml() == xml.as_bytes() {
return Ok(());
}
let paragraph = CT_P::from_xml_fragment_checked(xml.as_bytes())?;
let (source, item) = self.story_range_paragraph_source(location)?;
let context = edited_paragraph_ancestor_context(&source.xml, item.full.start)?;
admit_edited_paragraph(selected.xml(), xml.as_bytes(), &context)?;
validate_element_owner_relationships(
self,
&location.story,
WordXmlOwner::Paragraph(¶graph),
)?;
validate_element_xml_identity_change(
self,
&location.story,
selected.xml(),
xml.as_bytes(),
)?;
let mut replacement = Vec::new();
paragraph.to_xml(&mut Writer::new(&mut replacement))?;
let fragment = ContentFragment {
kind: StoryItemKind::Paragraph,
xml: replacement,
namespace_scope: BTreeMap::new(),
source_part_name: Some(location.story.part_name.clone()),
source_story_kind: Some(location.story.kind),
source_owner_index: Some(location.story.owner_index),
};
self.replace_checked_element_content(location, fragment)
}
#[doc(hidden)]
/// Replace one existing accepted regular run through its physical
/// paragraph owner, never by inserting a run at an old numeric slot.
pub fn replace_run_xml(&mut self, position: &StoryRunPosition, xml: &str) -> Result<()> {
self.validate_model_resource_limits()?;
let snapshot = self.story_range_paragraph_snapshot(&position.location)?;
let mut paragraph = CT_P::from_xml_fragment(snapshot.xml())?;
let path = paragraph
.accepted_run_paths()
.get(position.run_index)
.cloned()
.ok_or_else(|| Error::Other("element XML run index is out of range".into()))?;
let current = paragraph
.accepted_run(&path)
.ok_or_else(|| Error::Other("element XML run owner is absent".into()))?;
if current
.content
.iter()
.any(|content| matches!(content, RunContent::Field(_)))
{
return Err(Error::Other(
"element XML regular run replacement cannot replace a field owner".into(),
));
}
let mut current_xml = Vec::new();
current.to_xml(&mut Writer::new(&mut current_xml))?;
let bindings = BTreeMap::from([
("w".to_owned(), WORD_NAMESPACE.to_owned()),
("r".to_owned(), rdocx_oxml::namespace::R_NS.to_owned()),
]);
let current_xml = close_content_fragment_namespaces(¤t_xml, &bindings)?;
if current_xml == xml.as_bytes() {
return Ok(());
}
let replacement = CT_R::from_xml_fragment_checked(xml.as_bytes())?;
validate_element_owner_relationships(
self,
&position.location.story,
WordXmlOwner::Run(&replacement),
)?;
validate_element_xml_identity_change(
self,
&position.location.story,
¤t_xml,
xml.as_bytes(),
)?;
if !paragraph.replace_accepted_run(&path, replacement)? {
return Err(Error::Other("element XML run owner disappeared".into()));
}
let mut fragment = ContentFragment::paragraph(paragraph)?;
fragment.source_part_name = Some(position.location.story.part_name.clone());
fragment.source_story_kind = Some(position.location.story.kind);
fragment.source_owner_index = Some(position.location.story.owner_index);
self.replace_checked_element_content(&position.location, fragment)
}
/// Replace one checked physical table atomically.
#[doc(hidden)]
pub fn replace_table_xml(&mut self, location: &ContentLocation, xml: &str) -> Result<()> {
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE resource-before");
self.validate_model_resource_limits()?;
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE resource-after");
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE source-before");
let (source, item) = self.story_item_source(location)?;
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE source-after");
if item.kind != StoryItemKind::Table || location.is_end {
return Err(Error::Other(
"element XML needs an existing physical table owner".into(),
));
}
let scope = story_namespace_scope_at(&source.xml, item.full.start)?;
let current = close_content_fragment_namespaces(&source.xml[item.full], &scope)?;
if current == xml.as_bytes() {
return Ok(());
}
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE checkedparse-before");
let table = CT_Tbl::from_xml_fragment_checked(xml.as_bytes())?;
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE checkedparse-after");
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE relationships-before");
validate_element_owner_relationships(self, &location.story, WordXmlOwner::Table(&table))?;
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE relationships-after");
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE identity-before");
validate_element_xml_identity_change(self, &location.story, ¤t, xml.as_bytes())?;
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE identity-after");
let fragment = ContentFragment {
kind: StoryItemKind::Table,
xml: xml.as_bytes().to_vec(),
namespace_scope: BTreeMap::new(),
source_part_name: Some(location.story.part_name.clone()),
source_story_kind: Some(location.story.kind),
source_owner_index: Some(location.story.owner_index),
};
#[cfg(test)]
eprintln!("FX211_REPLACE_STAGE element-helper-before");
#[cfg(test)]
{
let result = self.replace_checked_element_content(location, fragment);
eprintln!("FX211_REPLACE_STAGE element-helper-after");
result
}
#[cfg(not(test))]
self.replace_checked_element_content(location, fragment)
}
/// Replace exactly the source-qualified cell resolved by the held table.
#[doc(hidden)]
pub fn replace_cell_xml(
&mut self,
table: &ContentLocation,
row: usize,
cell: usize,
xml: &str,
) -> Result<()> {
self.validate_model_resource_limits()?;
let owner = self.story_table_cell(table, row, cell)?;
let (source, span) = self.story_source_and_owner(&owner)?;
let scope = story_namespace_scope_at(&source.xml, span.full.start)?;
let current = close_content_fragment_namespaces(&source.xml[span.full.clone()], &scope)?;
if current == xml.as_bytes() {
return Ok(());
}
let replacement = CT_Tc::from_xml_fragment_checked(xml.as_bytes())?;
if replacement.content.is_empty() {
return Err(Error::Other(
"element XML cell requires modeled block content".into(),
));
}
validate_element_owner_relationships(self, &owner, WordXmlOwner::Cell(&replacement))?;
validate_element_xml_identity_change(self, &owner, ¤t, xml.as_bytes())?;
let original_table = self.story_table_model(table)?;
let original_cell = original_table
.rows
.get(row)
.and_then(|row| row.cells.get(cell))
.ok_or_else(|| Error::Other("element XML original physical cell disappeared".into()))?;
let profiles = |cell: &CT_Tc| {
let mut profiles = Vec::new();
visit_cell(cell, false, &mut |paragraph| {
if let Some(section) = paragraph
.properties
.as_ref()
.and_then(|properties| properties.sect_pr.as_ref())
{
profiles.push(section.clone());
}
});
profiles
};
if profiles(original_cell) != profiles(&replacement) {
return Err(Error::Other("element XML changed or introduced paragraph-owned section properties are unsupported, use the checked section owner".into()));
}
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let (source, span) = candidate.story_source_and_owner(&owner)?;
let scope = story_namespace_scope_at(&source.xml, span.full.start)?;
let part = source.part_name.clone();
let mut bytes = source.xml.into_owned();
bytes.splice(
span.full,
close_content_fragment_namespaces(xml.as_bytes(), &scope)?,
);
set_story_source_xml(&mut candidate, &part, bytes)?;
// Only the closed candidate changed: replacing the qualified cell
// cannot move its enclosing table or preceding owner boundary.
// Rebind that candidate's story fingerprint before its topology check.
let mut enclosing = table.clone();
enclosing.story = candidate
.stories()?
.into_iter()
.find(|story| {
story.kind == table.story.kind
&& story.part_name == table.story.part_name
&& story.owner_index == table.story.owner_index
})
.ok_or_else(|| Error::Other("element XML enclosing table story disappeared".into()))?;
validate_table_topology(&candidate.story_table_model(&enclosing)?)?;
candidate.reconcile_comment_removal(self)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Replace an existing physical section owner through a closed candidate.
#[doc(hidden)]
pub fn replace_section_properties_xml(&mut self, index: usize, xml: &str) -> Result<()> {
self.validate_model_resource_limits()?;
let current = self
.section(index)
.ok_or_else(|| Error::Other("element XML section owner is absent".into()))?;
let replacement = CT_SectPr::from_xml_fragment_checked(xml.as_bytes())?;
for (references, kind) in [
(&replacement.header_refs, HeaderFooterKind::Header),
(&replacement.footer_refs, HeaderFooterKind::Footer),
] {
let mut types = Vec::new();
for reference in references {
if types.contains(&reference.hdr_ftr_type) {
return Err(Error::Other(
"element XML section has duplicate header/footer types".into(),
));
}
types.push(reference.hdr_ftr_type);
let part = self
.header_footer_part_name(&reference.rel_id, kind == HeaderFooterKind::Header)
.ok_or_else(|| {
Error::Other(
"element XML section relationship kind or mode is invalid".into(),
)
})?;
if self.package.content_types.content_type_for(&part) != Some(kind.content_type())
|| self
.load_header_footer(&reference.rel_id, kind == HeaderFooterKind::Header)
.is_none()
{
return Err(Error::Other(
"element XML section relationship target is invalid".into(),
));
}
}
}
let mut bytes = Vec::new();
current.properties().to_xml(&mut Writer::new(&mut bytes))?;
let scope = BTreeMap::from([
("w".to_owned(), WORD_NAMESPACE.to_owned()),
("r".to_owned(), rdocx_oxml::namespace::R_NS.to_owned()),
]);
if close_content_fragment_namespaces(&bytes, &scope)? == xml.as_bytes() {
return Ok(());
}
let mut candidate = self.clone_for_staging();
*candidate
.section_mut(index)
.ok_or_else(|| Error::Other("element XML section owner disappeared".into()))?
.inner = replacement;
if candidate
.validate_sections()
.findings
.iter()
.any(|finding| {
finding.severity == ValidationSeverity::Error
&& finding.message == format!("section {index} has nonpositive page size")
})
{
return Err(Error::Other(
"element XML section geometry has nonpositive page size".into(),
));
}
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
fn story_table_model(&self, location: &ContentLocation) -> Result<CT_Tbl> {
if location.item_kind != StoryItemKind::Table || location.is_end {
return Err(Error::Other(
"rich table needs an existing table location".to_owned(),
));
}
let (source, item) = self.story_item_source(location)?;
Self::story_table_from_source(&source.xml, &item)
}
fn story_table_from_source(xml: &[u8], item: &StoryItemSpan) -> Result<CT_Tbl> {
let scope = story_namespace_scope_at(xml, item.full.start)?;
let closed = close_content_fragment_namespaces(&xml[item.full.clone()], &scope)?;
let mut wrapped =
format!(r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body>"#).into_bytes();
wrapped.extend_from_slice(&closed);
wrapped.extend_from_slice(b"</w:body></w:document>");
let mut parsed = CT_Document::from_xml(&wrapped)?;
if parsed.body.content.len() != 1 {
return Err(Error::Other(
"rich table has ambiguous physical content".to_owned(),
));
}
match parsed.body.content.remove(0) {
BodyContent::Table(table) => Ok(table),
_ => Err(Error::Other(
"rich table source is not a qualified table".to_owned(),
)),
}
}
/// Read owned facts from one source-qualified table.
#[doc(hidden)]
pub fn read_story_table<T>(
&self,
location: &ContentLocation,
read: impl FnOnce(TableRef<'_>) -> T,
) -> Result<T> {
let table = self.story_table_model(location)?;
Ok(read(TableRef { inner: &table }))
}
/// Edit one source-qualified table and publish it atomically.
#[doc(hidden)]
pub fn edit_story_table<T>(
&mut self,
location: &ContentLocation,
edit: impl FnOnce(&mut Table<'_>) -> Result<T>,
) -> Result<T> {
let mut table = self.story_table_model(location)?;
let result = edit(&mut Table { inner: &mut table })?;
self.replace_story_content(location, ContentFragment::table(table)?)?;
Ok(result)
}
/// Snapshot all checked Word owners within one immutable source inventory.
///
/// The four families have the same order, payload and admission as story
/// items, physical table cells, range paragraphs and hyperlinks respectively.
/// Source qualification is shared only within this call, never across edits.
#[doc(hidden)]
#[allow(clippy::type_complexity)] // One cross-crate projection carries four existing owned families.
pub fn story_owner_snapshots(
&self,
) -> Result<(
Vec<StoryItemSnapshot>,
Vec<(ContentLocation, usize, usize, StoryId)>,
Vec<(ContentLocation, Vec<u8>)>,
Vec<(ContentLocation, LinkInfo)>,
)> {
let mut snapshots = Vec::new();
let mut cells = Vec::new();
let mut paragraphs = Vec::new();
let mut links = Vec::new();
let mut seen = HashSet::new();
let mut emitted_cells = HashSet::new();
for source in self.story_sources()? {
let physical_owners = scan_story_owners(&source.xml, source.root_kind)?;
let owner_scopes = story_namespace_scopes_at(
&source.xml,
physical_owners.iter().map(|owner| owner.full.start),
)?;
let mut owners = Vec::with_capacity(physical_owners.len());
let mut inventories = Vec::with_capacity(physical_owners.len());
let mut admitted = Vec::with_capacity(physical_owners.len());
for owner in &physical_owners {
let scope = owner_scopes.get(&owner.full.start).ok_or_else(|| {
Error::Other("story owner namespace scope was not inventoried".into())
})?;
inventories.push(scan_story_items_with_scope(&source.xml, owner, scope)?);
let story = StoryId {
kind: owner.kind,
part_name: source.part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
};
admitted.push(seen.insert((
owner.kind,
source.part_name.clone(),
owner.owner_index,
)));
owners.push((story, owner.clone()));
}
let scopes = story_namespace_scopes_at(
&source.xml,
inventories
.iter()
.flatten()
.flat_map(|item| [item.scan.start, item.full.start]),
)?;
cells.extend(Self::story_table_cells_from_inventory(
&source,
&physical_owners,
&inventories,
&scopes,
&mut emitted_cells,
)?);
let paragraph_spans = story_range_paragraph_spans_from_inventory(
&source.xml,
&owners,
&inventories,
&scopes,
)?;
let mut link_spans = Vec::with_capacity(owners.len());
for ((story, _), items) in owners.iter().zip(&inventories) {
link_spans.push(ordered_story_link_spans(
&source.xml,
story,
items.clone(),
&scopes,
)?);
}
let leaf_scopes = story_namespace_scopes_at(
&source.xml,
paragraph_spans
.iter()
.flatten()
.map(|(_, span)| span.start)
.chain(link_spans.iter().flatten().map(|(_, link)| link.full.start)),
)?;
for (owner_index, ((story, _), items)) in owners.iter().zip(&inventories).enumerate() {
if !admitted[owner_index] {
continue;
}
let mut direct_items = Vec::new();
if story.kind == StoryKind::Body {
for item in items.iter().filter(|item| item.direct_owner_child) {
if item.kind != StoryItemKind::PreservedNode
|| !content_fragment_root_is_section_properties(&source.xml, item)?
{
direct_items.push(item);
}
}
}
let mut direct_cursor = 0;
for (index, item) in items.iter().enumerate() {
while direct_items
.get(direct_cursor)
.is_some_and(|direct| direct.full.end <= item.full.start)
{
direct_cursor += 1;
}
let direct_body_index = direct_items.get(direct_cursor).and_then(|direct| {
(direct.full.start <= item.full.start && item.full.end <= direct.full.end)
.then_some(direct_cursor)
});
let scope = scopes.get(&item.scan.start).ok_or_else(|| {
Error::Other("story item namespace scope was not inventoried".into())
})?;
let text = story_item_text_with_scope(&source.xml, item, scope, items)?;
let xml = if item.complex_field {
complex_story_field_xml(&source.xml, item)?
} else {
source.xml[item.full.clone()].to_vec()
};
snapshots.push(StoryItemSnapshot {
location: ContentLocation::new(story.clone(), item.kind, vec![index]),
direct_body_index,
direct_child: item.direct_owner_child,
text,
xml,
});
}
}
for (index, mut spans) in paragraph_spans.into_iter().enumerate() {
if !admitted[index] {
continue;
}
spans.sort_by_key(|(_, span)| span.start);
for (location, span) in spans {
let scope = leaf_scopes.get(&span.start).ok_or_else(|| {
Error::Other("story paragraph namespace scope was not inventoried".into())
})?;
paragraphs.push((
location,
close_content_fragment_namespaces(&source.xml[span], scope)?,
));
}
}
for (index, spans) in link_spans.into_iter().enumerate() {
if !admitted[index] {
continue;
}
for (location, span) in spans {
let scope = leaf_scopes.get(&span.full.start).ok_or_else(|| {
Error::Other("story hyperlink namespace scope was not inventoried".into())
})?;
let info = self.story_link_info(location.story(), &source.xml, span, scope)?;
links.push((location, info));
}
}
}
Ok((snapshots, cells, paragraphs, links))
}
/// Snapshot direct physical cell coordinates and their checked source owners.
///
/// Each source is materialized once. Outer tables are parsed once and their
/// nested models participate in the same physical correspondence traversal.
#[doc(hidden)]
#[allow(clippy::type_complexity)]
#[cfg(test)]
fn story_table_cell_snapshots(&self) -> Result<Vec<(ContentLocation, usize, usize, StoryId)>> {
let mut result = Vec::new();
let mut emitted = HashSet::new();
for source in self.story_sources()? {
let owners = scan_story_owners(&source.xml, source.root_kind)?;
let scopes = story_namespace_scopes_at(
&source.xml,
owners.iter().map(|owner| owner.full.start),
)?;
let mut tables = Vec::new();
for owner in &owners {
let scope = scopes
.get(&owner.full.start)
.ok_or_else(|| Error::Other("table owner scope missing".into()))?;
let story = StoryId {
kind: owner.kind,
part_name: source.part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
};
for (index, item) in scan_story_items_with_scope(&source.xml, owner, scope)?
.into_iter()
.enumerate()
{
if item.kind == StoryItemKind::Table {
tables.push((
item.full,
ContentLocation::new(story.clone(), item.kind, vec![index]),
));
}
}
}
// Recover physical order with one lexical pass instead of sorting
// a growing table inventory. Qualification came from the owner scan.
let mut by_start = tables
.into_iter()
.map(|item| (item.0.start, item))
.collect::<HashMap<_, _>>();
let mut tables = Vec::with_capacity(by_start.len());
let mut reader = quick_xml::Reader::from_reader(source.xml.as_ref());
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
match reader.read_event_into(&mut buffer).map_err(|error| {
Error::Other(format!("table source order scan failed: {error}"))
})? {
Event::Start(_) | Event::Empty(_) => {
if let Some(item) = by_start.remove(&before) {
tables.push(item);
}
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
if !by_start.is_empty() {
return Err(Error::Other(
"table physical source order is incomplete".into(),
));
}
let scopes =
story_namespace_scopes_at(&source.xml, tables.iter().map(|(span, _)| span.start))?;
let cells = owners
.iter()
.filter(|owner| owner.kind == StoryKind::TableCell)
.collect::<Vec<_>>();
let mut next = 0;
let mut cell_cursor = 0;
while next < tables.len() {
let root = next;
let span = tables[root].0.clone();
next += 1;
while next < tables.len() && tables[next].0.end < span.end {
next += 1;
}
let scope = scopes
.get(&span.start)
.ok_or_else(|| Error::Other("table item scope missing".into()))?;
let closed = close_content_fragment_namespaces(&source.xml[span.clone()], scope)?;
let mut wrapped =
format!(r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body>"#).into_bytes();
wrapped.extend(closed);
wrapped.extend_from_slice(b"</w:body></w:document>");
let parsed = CT_Document::from_xml(&wrapped)?;
let [BodyContent::Table(table)] = parsed.body.content.as_slice() else {
return Err(Error::Other(
"rich table has ambiguous physical content".into(),
));
};
let (count, modeled) = physical_table_cells(table);
while cells
.get(cell_cursor)
.is_some_and(|owner| owner.full.start < span.start)
{
cell_cursor += 1;
}
let first = cell_cursor;
while cells
.get(cell_cursor)
.is_some_and(|owner| owner.full.start < span.end)
{
cell_cursor += 1;
}
let physical = &cells[first..cell_cursor];
if modeled.len() != physical.len() {
return Err(Error::Other(
"rich table cell source correspondence is incomplete".into(),
));
}
let mut table_routes = vec![None; count];
let mut containing = Vec::<usize>::new();
let mut cursor = root;
for ((table, coordinate), owner) in modeled.into_iter().zip(physical) {
while cursor < next && tables[cursor].0.start < owner.full.start {
while containing
.last()
.is_some_and(|&index| tables[index].0.end < tables[cursor].0.end)
{
containing.pop();
}
containing.push(cursor);
cursor += 1;
}
while containing
.last()
.is_some_and(|&index| tables[index].0.end <= owner.full.start)
{
containing.pop();
}
let index = *containing
.last()
.ok_or_else(|| Error::Other("cell has no physical table".into()))?;
if owner.full.end >= tables[index].0.end
|| table_routes[table].is_some_and(|before| before != index)
{
return Err(Error::Other(
"rich table cell has ambiguous physical owner".into(),
));
}
table_routes[table] = Some(index);
if let Some((row, cell)) = coordinate
&& emitted.insert((tables[index].1.clone(), row, cell))
{
result.push((
tables[index].1.clone(),
row,
cell,
StoryId {
kind: owner.kind,
part_name: source.part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
},
));
}
}
}
}
Ok(result)
}
#[allow(clippy::type_complexity)]
fn story_table_cells_from_inventory(
source: &StorySource<'_>,
owners: &[StoryOwnerSpan],
inventories: &[Vec<StoryItemSpan>],
scopes: &HashMap<usize, BTreeMap<String, String>>,
emitted: &mut HashSet<(ContentLocation, usize, usize)>,
) -> Result<Vec<(ContentLocation, usize, usize, StoryId)>> {
let mut result = Vec::new();
let mut tables = Vec::new();
for (owner, items) in owners.iter().zip(inventories) {
let story = StoryId {
kind: owner.kind,
part_name: source.part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
};
for (index, item) in items.iter().enumerate() {
if item.kind == StoryItemKind::Table {
tables.push((
item.full.clone(),
ContentLocation::new(story.clone(), item.kind, vec![index]),
));
}
}
}
// Recover physical order with one lexical pass instead of sorting
// a growing table inventory. Qualification came from the owner scan.
let mut by_start = tables
.into_iter()
.map(|item| (item.0.start, item))
.collect::<HashMap<_, _>>();
let mut tables = Vec::with_capacity(by_start.len());
let mut reader = quick_xml::Reader::from_reader(source.xml.as_ref());
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
match reader
.read_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("table source order scan failed: {error}")))?
{
Event::Start(_) | Event::Empty(_) => {
if let Some(item) = by_start.remove(&before) {
tables.push(item);
}
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
if !by_start.is_empty() {
return Err(Error::Other(
"table physical source order is incomplete".into(),
));
}
let cells = owners
.iter()
.filter(|owner| owner.kind == StoryKind::TableCell)
.collect::<Vec<_>>();
let mut next = 0;
let mut cell_cursor = 0;
while next < tables.len() {
let root = next;
let span = tables[root].0.clone();
next += 1;
while next < tables.len() && tables[next].0.end < span.end {
next += 1;
}
let scope = scopes
.get(&span.start)
.ok_or_else(|| Error::Other("table item scope missing".into()))?;
let closed = close_content_fragment_namespaces(&source.xml[span.clone()], scope)?;
let mut wrapped =
format!(r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body>"#).into_bytes();
wrapped.extend(closed);
wrapped.extend_from_slice(b"</w:body></w:document>");
let parsed = CT_Document::from_xml(&wrapped)?;
let [BodyContent::Table(table)] = parsed.body.content.as_slice() else {
return Err(Error::Other(
"rich table has ambiguous physical content".into(),
));
};
let (count, modeled) = physical_table_cells(table);
while cells
.get(cell_cursor)
.is_some_and(|owner| owner.full.start < span.start)
{
cell_cursor += 1;
}
let first = cell_cursor;
while cells
.get(cell_cursor)
.is_some_and(|owner| owner.full.start < span.end)
{
cell_cursor += 1;
}
let physical = &cells[first..cell_cursor];
if modeled.len() != physical.len() {
return Err(Error::Other(
"rich table cell source correspondence is incomplete".into(),
));
}
let mut table_routes = vec![None; count];
let mut containing = Vec::<usize>::new();
let mut cursor = root;
for ((table, coordinate), owner) in modeled.into_iter().zip(physical) {
while cursor < next && tables[cursor].0.start < owner.full.start {
while containing
.last()
.is_some_and(|&index| tables[index].0.end < tables[cursor].0.end)
{
containing.pop();
}
containing.push(cursor);
cursor += 1;
}
while containing
.last()
.is_some_and(|&index| tables[index].0.end <= owner.full.start)
{
containing.pop();
}
let index = *containing
.last()
.ok_or_else(|| Error::Other("cell has no physical table".into()))?;
if owner.full.end >= tables[index].0.end
|| table_routes[table].is_some_and(|before| before != index)
{
return Err(Error::Other(
"rich table cell has ambiguous physical owner".into(),
));
}
table_routes[table] = Some(index);
if let Some((row, cell)) = coordinate
&& emitted.insert((tables[index].1.clone(), row, cell))
{
result.push((
tables[index].1.clone(),
row,
cell,
StoryId {
kind: owner.kind,
part_name: source.part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
},
));
}
}
}
Ok(result)
}
/// Resolve one related table cell through its actual enclosing physical table.
#[doc(hidden)]
pub fn story_table_cell(
&self,
location: &ContentLocation,
row: usize,
cell: usize,
) -> Result<StoryId> {
if location.item_kind != StoryItemKind::Table || location.is_end {
return Err(Error::Other(
"rich table needs an existing table location".to_owned(),
));
}
let (source, item) = self.story_item_source(location)?;
let table = Self::story_table_from_source(&source.xml, &item)?;
TableRef { inner: &table }
.cell(row, cell)
.ok_or_else(|| Error::Other("rich table cell index is out of range".to_owned()))?;
let owners = scan_story_owners(&source.xml, source.root_kind)?
.into_iter()
.filter(|owner| {
owner.kind == StoryKind::TableCell
&& item.full.start < owner.full.start
&& owner.full.end < item.full.end
})
.collect::<Vec<_>>();
let (_, modeled) = physical_table_cells(&table);
if modeled.len() < owners.len() {
return Err(Error::Other(
"rich table cell source correspondence is incomplete".into(),
));
}
if modeled.len() > owners.len() {
return Err(Error::Other(
"rich table has unqualified modeled cells".into(),
));
}
let ordinal = modeled
.iter()
.position(|(table, coordinate)| *table == 0 && *coordinate == Some((row, cell)))
.ok_or_else(|| Error::Other("rich table cell has no unique physical owner".into()))?;
let owner = &owners[ordinal];
let story = StoryId {
kind: owner.kind,
part_name: source.part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
};
Ok(story)
}
#[cfg(test)]
fn fx237_legacy_story_table_cell(
&self,
location: &ContentLocation,
row: usize,
cell: usize,
) -> Result<StoryId> {
let mut table = self.story_table_model(location)?;
let selected = TableRef { inner: &table }
.cell(row, cell)
.map(|cell| cell.inner as *const CT_Tc)
.ok_or_else(|| Error::Other("rich table cell index is out of range".to_owned()))?;
let (source, item) = self.story_item_source(location)?;
let owners = scan_story_owners(&source.xml, source.root_kind)?
.into_iter()
.filter(|owner| {
owner.kind == StoryKind::TableCell
&& item.full.start < owner.full.start
&& owner.full.end < item.full.end
})
.collect::<Vec<_>>();
let mut found = None;
for (index, owner) in owners.iter().enumerate() {
let mut ordinal = index;
let modeled = nth_cell_in_table(&mut table, &mut ordinal).ok_or_else(|| {
Error::Other("rich table cell source correspondence is incomplete".to_owned())
})?;
if std::ptr::eq(selected, modeled) {
found = Some(owner);
}
}
let mut beyond = owners.len();
if nth_cell_in_table(&mut table, &mut beyond).is_some() {
return Err(Error::Other(
"rich table has unqualified modeled cells".to_owned(),
));
}
let owner = found.ok_or_else(|| {
Error::Other("rich table cell has no unique physical owner".to_owned())
})?;
let story = StoryId {
kind: owner.kind,
part_name: source.part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
};
Ok(story)
}
/// Resolve one paragraph through its actual checked related table cell owner.
#[doc(hidden)]
pub fn story_table_cell_paragraph_location(
&self,
location: &ContentLocation,
row: usize,
cell: usize,
paragraph: usize,
) -> Result<ContentLocation> {
let story = self.story_table_cell(location, row, cell)?;
let (source, owner) = self.story_source_and_owner(&story)?;
let spans = story_range_paragraph_spans(&source.xml, &[(story.clone(), owner.clone())])?
.into_iter()
.flatten();
let (location, _) = spans.into_iter().nth(paragraph).ok_or_else(|| {
Error::Other("rich table cell paragraph index is out of range".to_owned())
})?;
self.story_range_paragraph_snapshot(&location)?;
Ok(location)
}
/// Read owned facts from one source-qualified rich paragraph.
#[doc(hidden)]
pub fn read_story_paragraph<T>(
&self,
location: &ContentLocation,
read: impl FnOnce(ParagraphRef<'_>) -> T,
) -> Result<T> {
let snapshot = self.story_range_paragraph_snapshot(location)?;
let paragraph = CT_P::from_xml_fragment(snapshot.xml())?;
Ok(read(ParagraphRef { inner: ¶graph }))
}
/// Edit one source-qualified rich paragraph and publish it atomically.
#[doc(hidden)]
pub fn edit_story_paragraph<T>(
&mut self,
location: &ContentLocation,
edit: impl FnOnce(&mut Paragraph<'_>) -> Result<T>,
) -> Result<T> {
let snapshot = self.story_range_paragraph_snapshot(location)?;
let mut paragraph = CT_P::from_xml_fragment(snapshot.xml())?;
let result = edit(&mut Paragraph {
inner: &mut paragraph,
})?;
let mut fragment = ContentFragment::paragraph(paragraph)?;
// This fragment is rebuilt from the checked paragraph of this same owner.
// Keep relationship admission scoped to that owner, including newly
// allocated relationships already validated in its package scope.
fragment.source_part_name = Some(location.story.part_name.clone());
fragment.source_story_kind = Some(location.story.kind);
fragment.source_owner_index = Some(location.story.owner_index);
self.replace_story_content(location, fragment)?;
Ok(result)
}
pub fn set_story_text(&mut self, location: &ContentLocation, value: &str) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let (source, owner) = candidate.story_source_and_owner(&location.story)?;
if location.index_path.len() != 1 {
return Err(StoryError::InvalidPath {
path: location.index_path.clone(),
}
.into());
}
let items = scan_story_items(&source.xml, &owner)?;
let index = location.index_path[0];
let item = items.get(index).ok_or(StoryError::OutOfBounds {
index,
len: items.len(),
})?;
if item.kind != location.item_kind {
return Err(StoryError::KindMismatch {
expected: location.item_kind,
actual: item.kind,
}
.into());
}
if item.kind == StoryItemKind::PreservedNode {
return Err(StoryError::NotTextBearing { kind: item.kind }.into());
}
let original_len = source.xml.len();
let item_start = item.full.start;
let item_end = item.full.end;
let updated = replace_story_item_text(&source.xml, item, value)?;
let new_item_end = if updated.len() >= original_len {
item_end + (updated.len() - original_len)
} else {
item_end
.checked_sub(original_len - updated.len())
.ok_or_else(|| {
Error::Other("story text replacement underflowed its item range".to_owned())
})?
};
let updated = remove_newly_empty_hyperlinks(
&source.xml,
item_start..item_end,
&updated,
item_start..new_item_end,
)?;
if source.part_name == candidate.doc_part_name {
candidate.document = CT_Document::from_xml(&updated)?;
candidate.flush_to_package()?;
} else {
candidate.package.set_part(&source.part_name, updated);
}
let reopened = candidate.reopen_prepared_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Point one story hyperlink at an external URL.
///
/// `link_index` indexes [`Self::story_links`] for `story`. An anchor
/// hyperlink becomes external and loses its anchor. The link's
/// relationship is retargeted in place when nothing else references it.
/// Otherwise the link gets a new relationship, so another link that shared
/// the old one keeps its target. A relationship left unreferenced is removed.
///
/// Only links that [`Self::story_links`] lists can be addressed. An empty
/// `w:hyperlink`, a link inside `w:fldSimple`, a link in a text box inside
/// `mc:AlternateContent`, and a HYPERLINK field are out of reach.
pub fn set_hyperlink_url(
&mut self,
story: &StoryId,
link_index: usize,
url: &str,
) -> Result<()> {
self.edit_story_hyperlink(story, link_index, Some(url))
}
/// Remove one story hyperlink and keep its content in place.
///
/// `link_index` indexes [`Self::story_links`] for `story`. The runs keep
/// their formatting, except that the built-in Hyperlink and
/// FollowedHyperlink character styles are cleared, as Word's Remove
/// Hyperlink does. A relationship left unreferenced is removed. The links
/// out of reach are those [`Self::set_hyperlink_url`] names.
pub fn remove_hyperlink(&mut self, story: &StoryId, link_index: usize) -> Result<()> {
self.edit_story_hyperlink(story, link_index, None)
}
fn edit_story_hyperlink(
&mut self,
story: &StoryId,
link_index: usize,
url: Option<&str>,
) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let (source, owner) = candidate.story_source_and_owner(story)?;
let part_name = source.part_name.clone();
let xml = source.xml.into_owned();
let mut links = story_link_spans(&xml, &owner, story)?;
let len = links.len();
if link_index >= len {
return Err(StoryError::OutOfBounds {
index: link_index,
len,
}
.into());
}
let (_, link) = links.swap_remove(link_index);
let scope = story_namespace_scope_at(&xml, link.full.start)?;
let updated = match url {
Some(url) => {
let (start, _) = story_hyperlink_tags(&xml, &link.full)?;
let with_relationship = |relationship_id: &str| -> Result<Vec<u8>> {
let tag = external_story_hyperlink_start(
&xml[start.clone()],
&scope,
relationship_id,
)?;
let mut updated = xml.clone();
updated.splice(start.clone(), tag);
Ok(updated)
};
let shared = match link.rel_id.as_deref() {
Some(old) => xml_relationship_ids_in_order(&with_relationship("")?)?
.iter()
.any(|id| id == old),
None => false,
};
let in_place = link.rel_id.as_deref().filter(|_| !shared).and_then(|old| {
candidate
.package
.get_part_rels_mut(&part_name)?
.items
.iter_mut()
.find(|relationship| {
relationship.id == old
&& relationship.rel_type == rel_types::HYPERLINK
&& relationship.target_mode.as_deref() == Some("External")
})
});
match in_place {
Some(relationship) => {
relationship.target = url.to_owned();
let relationship_id = relationship.id.clone();
if link.anchor.is_some() {
with_relationship(&relationship_id)?
} else {
xml.clone()
}
}
None => {
let relationship_id = candidate.add_external_relationship_checked(
&part_name,
rel_types::HYPERLINK,
url,
)?;
if part_name == candidate.doc_part_name {
candidate
.identifiers
.register_nested_story_relationship(story, relationship_id.clone());
}
with_relationship(&relationship_id)?
}
}
}
None => {
let styles = candidate.hyperlink_character_style_ids();
unwrap_story_hyperlink(&xml, &link.full, &scope, &styles)?
}
};
if let Some(old) = link.rel_id.as_deref()
&& !xml_relationship_ids_in_order(&updated)?
.iter()
.any(|id| id == old)
&& let Some(relationships) = candidate.package.get_part_rels_mut(&part_name)
{
let before = relationships.items.len();
relationships.items.retain(|relationship| {
relationship.id != old || relationship.rel_type != rel_types::HYPERLINK
});
if relationships.items.len() != before {
if relationships.items.is_empty() {
candidate.package.remove_part_rels(&part_name);
}
candidate
.identifiers
.retire_authored_story_relationships(&part_name, [old.to_owned()]);
}
}
set_story_source_xml(&mut candidate, &part_name, updated)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// The character style IDs that mark hyperlink text: the built-in
/// Hyperlink and FollowedHyperlink styles, by ID and by name.
fn hyperlink_character_style_ids(&self) -> HashSet<String> {
const NAMES: [&str; 2] = ["Hyperlink", "FollowedHyperlink"];
let mut ids = NAMES.map(str::to_owned).into_iter().collect::<HashSet<_>>();
ids.extend(
self.styles
.styles
.iter()
.filter(|style| {
style.style_type == StyleType::Character
&& style.name.as_deref().is_some_and(|name| {
NAMES.iter().any(|known| known.eq_ignore_ascii_case(name))
})
})
.map(|style| style.style_id.clone()),
);
ids
}
/// Insert one owned fragment at a checked direct-child boundary.
pub fn insert_content(
&mut self,
destination: &ContentLocation,
fragment: ContentFragment,
) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let (source, owner) = candidate.story_source_and_owner(&destination.story)?;
let part_name = source.part_name.clone();
let source_xml = source.xml.into_owned();
let (boundary, _, _) = validated_content_boundary(&source_xml, &owner, destination)?;
reject_section_owning_content_fragment(&fragment)?;
let fragment_xml =
content_fragment_for_insertion(&candidate, &destination.story, &fragment)?;
let mut updated = source_xml;
insert_story_fragment(&mut updated, &owner, boundary, fragment_xml)?;
set_story_source_xml(&mut candidate, &part_name, updated)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Insert one bounded HTML fragment at a checked direct-child boundary.
///
/// Images are resolved only from data URIs or the explicit resource slice.
/// The document remains unchanged if parsing, projection, package
/// reconciliation, insertion, or reopen validation fails.
pub fn insert_html_fragment(
&mut self,
destination: &ContentLocation,
html: &str,
images: &[crate::HtmlImageResource<'_>],
) -> Result<crate::HtmlFragmentInsertResult> {
if !matches!(
destination.story.kind,
StoryKind::Body | StoryKind::TableCell | StoryKind::Header | StoryKind::Footer
) {
return Err(Error::Other(
"HTML fragments support body, table-cell, header, and footer stories".to_owned(),
));
}
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let (source, owner) = candidate.story_source_and_owner(&destination.story)?;
let part_name = source.part_name.clone();
let mut source_xml = source.xml.into_owned();
let (boundary, direct_index, _) =
validated_content_boundary(&source_xml, &owner, destination)?;
let modeled_cell_route = (destination.story.kind == StoryKind::TableCell
&& part_name == candidate.doc_part_name)
.then(|| modeled_main_cell_route(&source_xml, &owner))
.transpose()?;
let projected =
crate::html::project_html_fragment(&mut candidate, &destination.story, html, images)?;
let inserted_count = projected.content.len();
if destination.story.kind == StoryKind::Body && part_name == candidate.doc_part_name {
candidate
.document
.body
.content
.splice(direct_index..direct_index, projected.content);
} else if destination.story.kind == StoryKind::TableCell
&& part_name == candidate.doc_part_name
{
let (content_index, mut cell_index) = modeled_cell_route.ok_or_else(|| {
Error::Other("modeled table-cell route disappeared before insertion".to_owned())
})?;
let content = candidate
.document
.body
.content
.get_mut(content_index)
.ok_or_else(|| StoryError::OwnerNotFound {
story: destination.story.clone(),
})?;
let cell = match content {
BodyContent::Table(table) => nth_cell_in_table(table, &mut cell_index),
BodyContent::ContentControl(control) => {
nth_cell_in_control(control, &mut cell_index)
}
BodyContent::Paragraph(_) | BodyContent::RawXml(_) => None,
}
.ok_or_else(|| StoryError::OwnerNotFound {
story: destination.story.clone(),
})?;
insert_html_content_into_cell(cell, direct_index, projected.content)?;
} else {
let mut fragment_xml = Vec::new();
for content in projected.content {
fragment_xml.extend_from_slice(&serialize_content_fragment(content)?);
}
insert_story_fragment(&mut source_xml, &owner, boundary, fragment_xml)?;
set_story_source_xml(&mut candidate, &part_name, source_xml)?;
}
let reopened = candidate.prepare_and_reopen_staged()?;
let result_story = reopened
.stories()?
.into_iter()
.find(|story| {
story.kind == destination.story.kind
&& story.part_name == destination.story.part_name
&& story.owner_index == destination.story.owner_index
})
.ok_or_else(|| StoryError::OwnerNotFound {
story: destination.story.clone(),
})?;
let range_end = direct_index
.checked_add(inserted_count)
.ok_or_else(|| Error::Other("HTML fragment range overflowed".to_owned()))?;
self.commit_staged_mutation(reopened);
Ok(crate::HtmlFragmentInsertResult {
story: result_story,
direct_range: direct_index..range_end,
diagnostics: projected.diagnostics,
})
}
/// Import an owned cross-document fragment at a checked story boundary.
///
/// The complete supported dependency closure is allocated on a staged
/// candidate. Unreconcilable dependencies return an error before the
/// destination changes.
pub fn import_fragment(
&mut self,
destination: &ContentLocation,
fragment: &DocumentFragment,
policy: FragmentConflictPolicy,
) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let (source, owner) = candidate.fragment_story_source_and_owner(&destination.story)?;
let part_name = source.part_name.clone();
let mut source_xml = source.xml.into_owned();
let (mut owner, mut boundary, _, _) =
fragment_content_boundary(&source_xml, &owner, destination)?;
if fragment.include_final_section_properties
&& (destination.story.kind != StoryKind::Body
|| (!destination.is_end && destination.index_path.len() != 1))
{
return Err(Error::Other(
"section-inclusive fragments require the main body destination".to_owned(),
));
}
let imported_xml = fragment.import_content_staged(&mut candidate, &part_name, policy)?;
// Companion closure may append notes or comment threads to the same
// physical part that owns the destination. Refresh that part before
// inserting, so publishing the selected owner cannot discard them.
candidate.prepare_staged_package()?;
let companion_owner = candidate.comments_part_name.as_deref() == Some(&part_name)
|| candidate.note_part_name(StoryKind::Footnote)?.as_deref() == Some(&part_name)
|| candidate.note_part_name(StoryKind::Endnote)?.as_deref() == Some(&part_name);
if companion_owner {
let (updated_source, updated_owner) =
candidate.fragment_story_source_and_owner(&destination.story)?;
source_xml = updated_source.xml.into_owned();
let (updated_owner, updated_boundary, _, _) =
fragment_content_boundary(&source_xml, &updated_owner, destination)?;
owner = updated_owner;
boundary = updated_boundary;
}
insert_story_fragment(&mut source_xml, &owner, boundary, imported_xml)?;
set_story_source_xml(&mut candidate, &part_name, source_xml)?;
let reopened = candidate.reopen_prepared_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Remove one checked direct child and return it as an owned fragment.
pub fn remove_content_at(&mut self, location: &ContentLocation) -> Result<ContentFragment> {
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let (source, owner) = candidate.story_source_and_owner(&location.story)?;
let part_name = source.part_name.clone();
let source_xml = source.xml.into_owned();
let item = validated_direct_content_item(&source_xml, &owner, location)?;
let namespace_scope = story_namespace_scope_at(&source_xml, item.full.start)?;
let closed =
close_content_fragment_namespaces(&source_xml[item.full.clone()], &namespace_scope)?;
Self::refuse_commented_fragment(&closed)?;
if owner.kind == StoryKind::TableCell
&& item.kind == StoryItemKind::Paragraph
&& direct_story_content_items(&source_xml, &owner)?
.iter()
.filter(|candidate| candidate.kind == StoryItemKind::Paragraph)
.count()
== 1
{
return Err(Error::Other(
"cannot remove the final required table-cell paragraph".to_owned(),
));
}
let fragment = ContentFragment {
kind: item.kind,
xml: source_xml[item.full.clone()].to_vec(),
source_part_name: Some(part_name.clone()),
source_story_kind: Some(location.story.kind),
source_owner_index: Some(location.story.owner_index),
namespace_scope,
};
reject_section_owning_content_fragment(&fragment)?;
validate_fragment_relationships(&candidate, &location.story, &fragment)?;
let mut updated = source_xml;
updated.drain(item.full);
set_story_source_xml(&mut candidate, &part_name, updated)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(fragment)
}
/// Clone one checked direct child into a checked insertion boundary.
///
/// Document-wide identities of the copy are made unique, and its
/// paragraphs and table rows drop `w14:paraId` and `w14:textId`.
pub fn clone_content(
&mut self,
source_location: &ContentLocation,
destination: &ContentLocation,
) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let (source, source_owner) = candidate.story_source_and_owner(&source_location.story)?;
let source_xml = source.xml.into_owned();
let source_item =
validated_direct_content_item(&source_xml, &source_owner, source_location)?;
let mut fragment = ContentFragment {
kind: source_item.kind,
xml: source_xml[source_item.full.clone()].to_vec(),
source_part_name: Some(source_location.story.part_name.clone()),
source_story_kind: Some(source_location.story.kind),
source_owner_index: Some(source_location.story.owner_index),
namespace_scope: story_namespace_scope_at(&source_xml, source_item.full.start)?,
};
reject_section_owning_content_fragment(&fragment)?;
let (destination_source, destination_owner) =
candidate.story_source_and_owner(&destination.story)?;
let destination_part_name = destination_source.part_name.clone();
let destination_xml = destination_source.xml.into_owned();
let (boundary, _, _) =
validated_content_boundary(&destination_xml, &destination_owner, destination)?;
fragment.xml = freshen_content_fragment_identities(&mut candidate, &fragment)?;
let fragment_xml =
content_fragment_for_insertion(&candidate, &destination.story, &fragment)?;
let mut updated = destination_xml;
insert_story_fragment(&mut updated, &destination_owner, boundary, fragment_xml)?;
set_story_source_xml(&mut candidate, &destination_part_name, updated)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Move one checked direct child within its current story owner.
pub fn move_content(
&mut self,
source_location: &ContentLocation,
destination: &ContentLocation,
) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let (source, owner) = candidate.story_source_and_owner(&source_location.story)?;
let part_name = source.part_name.clone();
let source_xml = source.xml.into_owned();
let source_item = validated_direct_content_item(&source_xml, &owner, source_location)?;
let (destination_source, destination_owner) =
candidate.story_source_and_owner(&destination.story)?;
let destination_xml = destination_source.xml.into_owned();
let (boundary, destination_index, _) =
validated_content_boundary(&destination_xml, &destination_owner, destination)?;
if source_location.story != destination.story {
return Err(Error::Other(
"content can move only within one unchanged story owner".to_owned(),
));
}
let direct_items = direct_story_content_items(&source_xml, &owner)?;
let source_index = direct_items
.iter()
.position(|item| item.full == source_item.full)
.ok_or_else(|| Error::Other("direct content source was not found".to_owned()))?;
let fragment = ContentFragment {
kind: source_item.kind,
xml: source_xml[source_item.full.clone()].to_vec(),
source_part_name: Some(part_name.clone()),
source_story_kind: Some(source_location.story.kind),
source_owner_index: Some(source_location.story.owner_index),
namespace_scope: story_namespace_scope_at(&source_xml, source_item.full.start)?,
};
reject_section_owning_content_fragment(&fragment)?;
validate_fragment_relationships(&candidate, &destination.story, &fragment)?;
if destination_index == source_index || destination_index == source_index + 1 {
return Ok(());
}
let removed_len = source_item.full.len();
let mut updated = source_xml;
updated.drain(source_item.full);
let adjusted_boundary = if source_index < destination_index {
boundary.checked_sub(removed_len).ok_or_else(|| {
Error::Other("content move destination underflowed after removal".to_owned())
})?
} else {
boundary
};
updated.splice(adjusted_boundary..adjusted_boundary, fragment.xml);
set_story_source_xml(&mut candidate, &part_name, updated)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
// ---- Paragraph access ----
/// Iterate over direct body items in source order.
///
/// Unlike [`Self::paragraphs`] and [`Self::tables`], this retains the
/// interleaving of paragraphs, tables, content controls, and preserved
/// unmodelled XML.
pub fn body_items(&self) -> impl Iterator<Item = BodyItemRef<'_>> {
self.document.body.content.iter().map(|item| match item {
BodyContent::Paragraph(paragraph) => {
BodyItemRef::Paragraph(ParagraphRef { inner: paragraph })
}
BodyContent::Table(table) => BodyItemRef::Table(TableRef { inner: table }),
BodyContent::ContentControl(control) => {
BodyItemRef::ContentControl(ContentControlRef { inner: control })
}
BodyContent::RawXml(raw) => BodyItemRef::UnsupportedXml(raw),
})
}
/// Iterate over paragraphs, tables, and unsupported content in body order.
///
/// This compatibility view reports content controls as modeled unsupported
/// facts without fabricating raw XML bytes.
pub fn body_content(&self) -> impl Iterator<Item = BodyContentRef<'_>> {
self.document.body.content.iter().map(|item| match item {
BodyContent::Paragraph(paragraph) => {
BodyContentRef::Paragraph(ParagraphRef { inner: paragraph })
}
BodyContent::Table(table) => BodyContentRef::Table(TableRef { inner: table }),
BodyContent::ContentControl(control) => {
BodyContentRef::UnsupportedXml(UnsupportedXmlRef::modeled(
"w:sdt",
WORD_NAMESPACE,
"sdt",
control.has_child_content(),
))
}
BodyContent::RawXml(raw) => BodyContentRef::UnsupportedXml(UnsupportedXmlRef::raw(
raw,
&self.body_namespace_bindings,
)),
})
}
/// Whether the document declares a page background that may affect its
/// visible appearance.
pub fn has_document_background(&self) -> bool {
self.document.background_xml.is_some()
}
/// Publish page color and the relationship-resolved display setting together.
pub fn set_page_color(&mut self, color: &str) -> Result<()> {
if !is_rgb_hex(color) {
return Err(Error::Other(
"page color must be six hexadecimal digits".to_owned(),
));
}
let mut candidate = self.settings_mutation_candidate()?;
if let Some(background) = candidate.document.background_xml.as_ref() {
let source = candidate
.package
.get_part(&candidate.doc_part_name)
.ok_or_else(|| Error::Other("main document part is missing".to_owned()))?;
let scope = document_namespace_scopes(source)?
.root_declarations
.into_iter()
.collect();
candidate.document.background_xml =
Some(close_content_fragment_namespaces(background, &scope)?);
}
candidate.document.set_background_color(color)?;
candidate
.settings
.get_or_insert_with(CT_Settings::new)
.set_display_background_shape(true)?;
candidate.invalidate_layout();
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Whether any document section carries layout formatting.
///
/// This includes the final body section and sections attached to paragraph
/// properties. Callers that cannot preserve page layout can reject such
/// documents without treating section XML as ordinary body content.
pub fn has_section_layout_formatting(&self) -> bool {
self.section_properties_in_order().any(section_has_layout)
}
/// Whether any document section retains XML that the semantic section
/// reader does not model.
pub fn has_unmodeled_section_properties(&self) -> bool {
self.section_properties_in_order()
.any(|properties| !properties.extra_xml.is_empty() || properties.change.is_some())
}
/// Return whether each facade paragraph contains displayed content.
///
/// Uses an unpublished source snapshot and preserves the live document.
pub fn paragraph_visibility(&self) -> Result<Vec<bool>> {
let mut candidate = self.clone_for_staging();
candidate.flush_document_to_package()?;
let source = candidate
.package
.get_part(&candidate.doc_part_name)
.ok_or(Error::NoDocumentPart)?;
paragraph_visibility_from_main_source(
&candidate,
source,
ParagraphVisibilityScope::FacadeRecursive,
)
}
/// Get immutable references to all paragraphs.
pub fn paragraphs(&self) -> Vec<ParagraphRef<'_>> {
self.document
.body
.paragraphs()
.map(|p| ParagraphRef { inner: p })
.collect()
}
/// Get an immutable reference to a paragraph by its index in
/// [`Self::paragraphs`]. That index skips body tables and enters block
/// content controls, so it is not the direct body index.
pub fn paragraph(&self, index: usize) -> Option<ParagraphRef<'_>> {
self.document
.body
.paragraphs()
.nth(index)
.map(|p| ParagraphRef { inner: p })
}
/// All footnotes as (id, plain text), in file order.
///
/// Separator entries are excluded. They live in the same stream and are
/// retained by the model so a round trip preserves them, but they are not
/// notes and never were part of this listing.
pub fn footnotes(&self) -> Vec<(i32, String)> {
self.footnotes
.footnotes
.iter()
.filter(|f| f.note_type == rdocx_oxml::footnotes::NoteType::Normal)
.map(|f| {
let text = f
.paragraphs
.iter()
.map(|p| p.text())
.collect::<Vec<_>>()
.join("\n");
(f.id, text)
})
.collect()
}
/// Add a footnote with the given text; returns its id. Pair with
/// `Paragraph::add_footnote_ref` to reference it from the body.
pub fn add_footnote(&mut self, text: &str) -> i32 {
let mut candidate = self.clone_for_staging();
candidate
.reserve_footnotes_bundle()
.expect("an in-memory document can allocate a footnotes part");
candidate.invalidate_layout();
use rdocx_oxml::footnotes::CT_Footnote;
use rdocx_oxml::text::CT_P;
let id = candidate
.footnotes
.footnotes
.iter()
.map(|f| f.id)
.max()
.unwrap_or(1)
+ 1;
let mut p = CT_P::new();
p.add_run(text);
candidate.footnotes.footnotes.push(CT_Footnote {
id,
note_type: rdocx_oxml::footnotes::NoteType::Normal,
paragraphs: vec![p],
});
candidate.footnotes_dirty = true;
candidate
.flush_dirty_related_story_models()
.expect("an in-memory document can publish a typed footnote");
candidate
.refresh_related_story_caches()
.expect("an in-memory document can refresh a typed footnote");
self.commit_staged_mutation(candidate);
id
}
/// Create a normal footnote and append its reference to a direct body paragraph.
/// The note and reference are published together only after the staged package reopens.
pub fn create_footnote(&mut self, reference: &ContentLocation, text: &str) -> Result<i32> {
if reference.story.kind != StoryKind::Body || reference.index_path.len() != 1 {
return Err(Error::Other(
"footnote reference must identify a direct body paragraph".to_owned(),
));
}
let run_index = self.read_story_paragraph(reference, |paragraph| paragraph.run_count())?;
self.create_footnote_at(
&StoryRunPosition {
location: reference.clone(),
run_index,
},
text,
)
}
/// Create a note and reference together at a checked body or table-cell run boundary.
pub fn create_footnote_at(&mut self, position: &StoryRunPosition, text: &str) -> Result<i32> {
if !matches!(
position.location.story.kind(),
StoryKind::Body | StoryKind::TableCell
) || position.location.story.part_name() != self.doc_part_name
{
return Err(Error::Other(
"note references require body or body-table content".to_owned(),
));
}
let mut candidate = self.clone_for_staging();
candidate
.read_story_paragraph(&position.location, |paragraph| paragraph.run_count())?
.checked_sub(position.run_index)
.ok_or_else(|| {
Error::Other("note reference run boundary is out of range".to_owned())
})?;
let occupied = candidate
.footnotes
.footnotes
.iter()
.map(|note| note.id)
.collect::<HashSet<_>>();
let mut id = 2_i32;
while occupied.contains(&id) {
id = id
.checked_add(1)
.ok_or_else(|| Error::Other("footnote ID range is exhausted".to_owned()))?;
}
candidate.reserve_footnotes_bundle()?;
let mut paragraph = CT_P::new();
let mut note_marker = CT_R::new("");
note_marker.content.clear();
note_marker.extra_xml.push(b"<w:footnoteRef/>".to_vec());
note_marker.extra_xml_positions.push(0);
paragraph.runs.push(note_marker);
paragraph.add_run(text);
candidate
.footnotes
.footnotes
.push(rdocx_oxml::footnotes::CT_Footnote {
id,
note_type: rdocx_oxml::footnotes::NoteType::Normal,
paragraphs: vec![paragraph],
});
candidate.footnotes_dirty = true;
candidate.flush_dirty_related_story_models()?;
candidate.refresh_related_story_caches()?;
candidate.invalidate_layout();
let mut run = CT_R::new("");
run.content = vec![RunContent::FootnoteRef {
id,
custom_mark: None,
}];
candidate.insert_story_run_staged(position, run)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(id)
}
/// Create a normal endnote and append its reference to a direct body paragraph.
/// The note and reference are published together only after the staged package reopens.
pub fn create_endnote(&mut self, reference: &ContentLocation, text: &str) -> Result<i32> {
if reference.story.kind != StoryKind::Body || reference.index_path.len() != 1 {
return Err(Error::Other(
"endnote reference must identify a direct body paragraph".to_owned(),
));
}
let run_index = self.read_story_paragraph(reference, |paragraph| paragraph.run_count())?;
self.create_endnote_at(
&StoryRunPosition {
location: reference.clone(),
run_index,
},
text,
)
}
/// Create a note and reference together at a checked body or table-cell run boundary.
pub fn create_endnote_at(&mut self, position: &StoryRunPosition, text: &str) -> Result<i32> {
if !matches!(
position.location.story.kind(),
StoryKind::Body | StoryKind::TableCell
) || position.location.story.part_name() != self.doc_part_name
{
return Err(Error::Other(
"note references require body or body-table content".to_owned(),
));
}
let mut candidate = self.clone_for_staging();
candidate
.read_story_paragraph(&position.location, |paragraph| paragraph.run_count())?
.checked_sub(position.run_index)
.ok_or_else(|| {
Error::Other("note reference run boundary is out of range".to_owned())
})?;
candidate.flush_to_package()?;
let existing_part = candidate.note_part_name(StoryKind::Endnote)?;
let part_name = candidate.reserve_document_part_bundle(
existing_part.as_deref(),
"/word/endnotes.xml",
rel_types::ENDNOTES,
"application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
)?;
let source = match candidate.package.get_part(&part_name) {
Some(xml) => xml.to_vec(),
None => rdocx_oxml::footnotes::CT_Footnotes::new().to_xml_endnotes()?,
};
let existing = rdocx_oxml::footnotes::CT_Footnotes::from_xml(&source)?;
let occupied = existing
.footnotes
.iter()
.map(|note| note.id)
.collect::<HashSet<_>>();
let mut id = 2_i32;
while occupied.contains(&id) {
id = id
.checked_add(1)
.ok_or_else(|| Error::Other("endnote ID range is exhausted".to_owned()))?;
}
let mut paragraph = CT_P::new();
let mut note_marker = CT_R::new("");
note_marker.content.clear();
note_marker.extra_xml.push(b"<w:endnoteRef/>".to_vec());
note_marker.extra_xml_positions.push(0);
paragraph.runs.push(note_marker);
paragraph.add_run(text);
let note = rdocx_oxml::footnotes::CT_Footnote {
id,
note_type: rdocx_oxml::footnotes::NoteType::Normal,
paragraphs: vec![paragraph],
};
let fragment = serialized_note_fragment(¬e, StoryKind::Endnote)?;
let updated =
append_story_fragments_to_root(&source, b"endnotes", b"endnote", &[fragment])?;
set_story_source_xml(&mut candidate, &part_name, updated)?;
candidate.invalidate_layout();
let mut run = CT_R::new("");
run.content = vec![RunContent::EndnoteRef {
id,
custom_mark: None,
}];
candidate.insert_story_run_staged(position, run)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(id)
}
/// Create a footnote whose visible reference is an authored mark.
pub fn create_footnote_with_mark(
&mut self,
reference: &ContentLocation,
text: &str,
mark: &str,
) -> Result<i32> {
self.create_note_with_mark(NoteFamily::Footnote, reference, text, mark)
}
/// Create an endnote whose visible reference is an authored mark.
pub fn create_endnote_with_mark(
&mut self,
reference: &ContentLocation,
text: &str,
mark: &str,
) -> Result<i32> {
self.create_note_with_mark(NoteFamily::Endnote, reference, text, mark)
}
fn create_note_with_mark(
&mut self,
family: NoteFamily,
reference: &ContentLocation,
text: &str,
mark: &str,
) -> Result<i32> {
if mark.is_empty() {
return Err(Error::Other(
"custom note mark must not be empty".to_owned(),
));
}
let mut candidate = self.clone_for_staging();
let (kind, id) = match family {
NoteFamily::Footnote => (
StoryKind::Footnote,
candidate.create_footnote(reference, text)?,
),
NoteFamily::Endnote => (
StoryKind::Endnote,
candidate.create_endnote(reference, text)?,
),
};
let paragraph_index = reference.index_path.first().copied().ok_or_else(|| {
Error::Other("note reference must identify a direct body paragraph".to_owned())
})?;
let refreshed = candidate
.paragraph_story_location(paragraph_index)?
.ok_or_else(|| Error::Other("new note reference paragraph is missing".to_owned()))?;
let paragraph = candidate.story_paragraph_mut(&refreshed)?;
let marker = paragraph
.runs
.last_mut()
.and_then(|run| run.content.first_mut())
.ok_or_else(|| Error::Other("new note reference is missing".to_owned()))?;
match marker {
RunContent::FootnoteRef { custom_mark, .. }
| RunContent::EndnoteRef { custom_mark, .. } => {
*custom_mark = Some(mark.to_owned());
}
_ => return Err(Error::Other("new note reference is malformed".to_owned())),
}
candidate.invalidate_layout();
candidate.flush_to_package()?;
let story = candidate
.note_story(kind, id)?
.ok_or_else(|| Error::Other("new note story is missing".to_owned()))?;
let part_name = story.part_name.clone();
let mut xml = candidate
.package
.get_part(&part_name)
.ok_or_else(|| Error::Other("new note part is missing".to_owned()))?
.to_vec();
let owner = scan_story_owners(&xml, kind)?
.into_iter()
.find(|owner| owner.kind == kind && owner.owner_index == story.owner_index)
.ok_or_else(|| Error::Other("new note owner is missing".to_owned()))?;
let mut paragraph = CT_P::new();
let marker_run = CT_R::new(mark);
paragraph.runs.push(marker_run);
paragraph.add_run(text);
let note = rdocx_oxml::footnotes::CT_Footnote {
id,
note_type: rdocx_oxml::footnotes::NoteType::Normal,
paragraphs: vec![paragraph],
};
let fragment = serialized_note_fragment(¬e, kind)?;
xml.splice(owner.full, fragment);
set_story_source_xml(&mut candidate, &part_name, xml)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(id)
}
/// Create or replace an authored note separator or continuation record.
/// The supplied paragraphs are written into the special note owner without
/// changing other note owners or unmodelled XML in the part.
pub fn set_note_special_record(
&mut self,
family: NoteFamily,
record: NoteSpecialRecord,
paragraphs: Vec<CT_P>,
) -> Result<i32> {
if paragraphs.is_empty() {
return Err(Error::Other(
"special note record needs a paragraph".to_owned(),
));
}
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let (kind, part_name, root, item) = match family {
NoteFamily::Footnote => {
candidate.reserve_footnotes_bundle()?;
(
StoryKind::Footnote,
candidate
.footnotes_part_name
.clone()
.ok_or_else(|| Error::Other("footnotes part is missing".to_owned()))?,
b"footnotes".as_slice(),
b"footnote".as_slice(),
)
}
NoteFamily::Endnote => {
let existing = candidate.note_part_name(StoryKind::Endnote)?;
let part = candidate.reserve_document_part_bundle(
existing.as_deref(),
"/word/endnotes.xml",
rel_types::ENDNOTES,
"application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
)?;
(
StoryKind::Endnote,
part,
b"endnotes".as_slice(),
b"endnote".as_slice(),
)
}
};
let source = match candidate.package.get_part(&part_name) {
Some(xml) => xml.to_vec(),
None if family == NoteFamily::Footnote => {
rdocx_oxml::footnotes::CT_Footnotes::new().to_xml_footnotes()?
}
None => rdocx_oxml::footnotes::CT_Footnotes::new().to_xml_endnotes()?,
};
let parsed = rdocx_oxml::footnotes::CT_Footnotes::from_xml(&source)?;
let matches = parsed
.footnotes
.iter()
.enumerate()
.filter(|(_, note)| note.note_type == record.note_type())
.collect::<Vec<_>>();
if matches.len() > 1 {
return Err(Error::Other("duplicate special note records".to_owned()));
}
let used = parsed
.footnotes
.iter()
.map(|note| note.id)
.collect::<HashSet<_>>();
let id = if let Some((_, note)) = matches.first() {
note.id
} else {
let preferred = match record {
NoteSpecialRecord::Separator => -1,
NoteSpecialRecord::ContinuationSeparator => 0,
NoteSpecialRecord::ContinuationNotice => -2,
};
if !used.contains(&preferred) {
preferred
} else {
let mut next = -2_i32;
while used.contains(&next) {
next = next.checked_sub(1).ok_or_else(|| {
Error::Other("special note ID range is exhausted".to_owned())
})?;
}
next
}
};
let note = rdocx_oxml::footnotes::CT_Footnote {
id,
note_type: record.note_type(),
paragraphs,
};
let fragment = serialized_note_fragment(¬e, kind)?;
let updated = if let Some((index, _)) = matches.first() {
let spans = scan_story_owners_with_special(&source, kind, true)?
.into_iter()
.filter(|owner| owner.kind == kind)
.collect::<Vec<_>>();
let span = spans
.get(*index)
.ok_or_else(|| Error::Other("special note owner cannot be located".to_owned()))?;
let mut xml = source;
xml.splice(span.full.clone(), fragment);
xml
} else {
append_story_fragments_to_root(&source, root, item, &[fragment])?
};
set_story_source_xml(&mut candidate, &part_name, updated)?;
let mut properties = candidate
.note_properties(family)
.cloned()
.unwrap_or_default();
if !properties.special_references.contains(&id) {
properties.special_references.push(id);
}
candidate.stage_settings_mutation(|settings| match family {
NoteFamily::Footnote => settings.set_footnote_properties(properties),
NoteFamily::Endnote => settings.set_endnote_properties(properties),
})?;
candidate.invalidate_layout();
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(id)
}
/// Find the current story identity for a normal footnote ID.
pub fn footnote_story(&self, id: i32) -> Result<Option<StoryId>> {
self.note_story(StoryKind::Footnote, id)
}
/// Find the current story identity for a normal endnote ID.
pub fn endnote_story(&self, id: i32) -> Result<Option<StoryId>> {
self.note_story(StoryKind::Endnote, id)
}
/// Omit review state only within transferred body content and its selected notes.
pub(crate) fn omit_glossary_selected_body_comments(
&mut self,
include_final_section_properties: bool,
) -> Result<Vec<u8>> {
let part = self.doc_part_name.clone();
let mut xml = self
.package
.get_part(&part)
.ok_or_else(|| Error::Other("fragment main source disappeared".into()))?
.to_vec();
let owner = scan_story_owners(&xml, StoryKind::Body)?
.into_iter()
.find(|owner| owner.kind == StoryKind::Body)
.ok_or_else(|| Error::Other("document fragment has no main body".into()))?;
let start = direct_story_content_items(&xml, &owner)?
.first()
.map(|item| item.full.start)
.ok_or_else(|| Error::Other("document fragment main body is empty".into()))?;
let end = story_owner_content_end(&xml, &owner)?;
let selected = package_authoritative_body_fragment(&xml, false, &BTreeMap::new())?;
self.omit_glossary_fragment_note_comments(&part, &selected)?;
let omitted = Self::omit_comment_markers(&selected)?;
if include_final_section_properties {
for item in scan_story_items(&xml, &owner)? {
if item.direct_owner_child
&& item.kind == StoryItemKind::PreservedNode
&& content_fragment_root_is_section_properties(&xml, &item)?
{
let scope = story_namespace_scope_at(&xml, item.full.start)?;
let section =
close_content_fragment_namespaces(&xml[item.full.clone()], &scope)?;
self.omit_glossary_fragment_note_comments(&part, §ion)?;
xml.splice(item.full, Self::omit_comment_markers(§ion)?);
break;
}
}
}
xml.splice(start..end, omitted);
set_story_source_xml(self, &part, xml.clone())?;
Ok(xml)
}
/// Omit only selected note review markers before glossary dependency capture.
pub(crate) fn omit_glossary_fragment_note_comments(
&mut self,
source_part: &str,
selected: &[u8],
) -> Result<()> {
let mut pending = crate::field::fragment_note_references(selected)?;
let mut seen = HashSet::new();
while let Some((kind, id)) = pending.pop() {
if !seen.insert((kind, id)) {
continue;
}
let relationship_type = match kind {
StoryKind::Footnote => rel_types::FOOTNOTES,
StoryKind::Endnote => rel_types::ENDNOTES,
_ => return Err(Error::Other("invalid glossary note dependency kind".into())),
};
if part_name_identity(source_part) != part_name_identity(&self.doc_part_name)
&& self
.package
.get_part_rels(source_part)
.iter()
.flat_map(|rels| &rels.items)
.any(|relationship| relationship.rel_type == relationship_type)
{
return Err(Error::Other(format!(
"cannot project glossary-local {kind:?} {id} through main numeric note ownership"
)));
}
let story = self.note_story(kind, id)?.ok_or_else(|| {
Error::Other(format!(
"glossary fragment {kind:?} {id} has no proved main note owner"
))
})?;
let (source, owner) = self.story_source_and_owner(&story)?;
let part = source.part_name;
let mut xml = source.xml.into_owned();
let scope = story_namespace_scope_at(&xml, owner.full.start)?;
let note = close_content_fragment_namespaces(&xml[owner.full.clone()], &scope)?;
pending.extend(crate::field::fragment_note_references(¬e)?);
let omitted = Self::omit_comment_markers(¬e)?;
xml.splice(owner.full, omitted);
set_story_source_xml(self, &part, xml)?;
}
Ok(())
}
pub(crate) fn fragment_note_dependency(
&self,
kind: StoryKind,
id: i32,
) -> Result<(String, Vec<u8>)> {
let story = self.note_story(kind, id)?.ok_or_else(|| {
Error::Other(format!(
"document fragment {kind:?} reference {id} has no note owner"
))
})?;
let (source, owner) = self.story_source_and_owner(&story)?;
let xml = source.xml.as_ref();
let scope = story_namespace_scope_at(xml, owner.full.start)?;
Ok((
source.part_name,
close_content_fragment_namespaces(&xml[owner.full], &scope)?,
))
}
pub(crate) fn fragment_comment_dependency(&self, id: i32) -> Result<(String, Vec<u8>)> {
let part = self.comments_part_name.as_ref().ok_or_else(|| {
Error::Other("REF f annotation has no relationship-owned comments part".into())
})?;
let comments = self
.comments
.as_ref()
.ok_or_else(|| Error::Other("REF f annotation has no source definitions".into()))?;
let matching = comments
.comments
.iter()
.enumerate()
.filter(|(_, comment)| comment.id == id)
.collect::<Vec<_>>();
if matching.len() != 1 {
return Err(Error::Other(
"REF f annotation source ID is missing or ambiguous".into(),
));
}
let owners = self
.stories()?
.into_iter()
.filter(|story| story.kind == StoryKind::Comment && story.part_name == *part)
.collect::<Vec<_>>();
if owners.len() != comments.comments.len() {
return Err(Error::Other(
"REF f annotation owner inventory differs from its definitions".into(),
));
}
let (source, owner) = self.story_source_and_owner(&owners[matching[0].0])?;
let scope = story_namespace_scope_at(source.xml.as_ref(), owner.full.start)?;
Ok((
source.part_name,
close_content_fragment_namespaces(&source.xml.as_ref()[owner.full], &scope)?,
))
}
pub(crate) fn publish_fragment_comment_staged(
&mut self,
part: &str,
xml: Vec<u8>,
replace_id: Option<i32>,
) -> Result<()> {
if self.comments_part_name.as_deref() != Some(part) {
return Err(Error::Other(
"REF f annotation copy has a different relationship owner".into(),
));
}
let source = self
.package
.get_part(part)
.ok_or_else(|| Error::Other("REF f destination comments part is missing".into()))?;
let updated = if let Some(id) = replace_id {
let matching = self
.comments
.as_ref()
.ok_or_else(|| {
Error::Other("REF f cached annotation definitions are absent".into())
})?
.comments
.iter()
.enumerate()
.filter(|(_, comment)| comment.id == id)
.map(|(index, _)| index)
.collect::<Vec<_>>();
if matching.len() != 1 {
return Err(Error::Other(
"REF f cached annotation owner is missing or ambiguous".into(),
));
}
let owners = self
.stories()?
.into_iter()
.filter(|story| story.kind == StoryKind::Comment && story.part_name == part)
.collect::<Vec<_>>();
if owners.len() != self.comments.as_ref().unwrap().comments.len() {
return Err(Error::Other(
"REF f cached annotation physical owners differ from their definitions".into(),
));
}
let (owner_source, owner) = self.story_source_and_owner(&owners[matching[0]])?;
if owner_source.part_name != part {
return Err(Error::Other(
"REF f cached annotation has a different relationship owner".into(),
));
}
let mut updated = source.to_vec();
updated.splice(owner.full, xml);
updated
} else {
append_story_fragments_to_root(source, b"comments", b"comment", &[xml])?
};
self.comments = Some(rdocx_oxml::comments::CT_Comments::from_xml(&updated)?);
self.package.set_part(part, updated);
self.comments_dirty = false;
Ok(())
}
pub(crate) fn ensure_fragment_note_part_staged(&mut self, kind: StoryKind) -> Result<String> {
let (preferred, relationship_type, content_type) = match kind {
StoryKind::Footnote => (
"/word/footnotes.xml",
rel_types::FOOTNOTES,
"application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
),
StoryKind::Endnote => (
"/word/endnotes.xml",
rel_types::ENDNOTES,
"application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
),
_ => return Err(Error::Other("invalid fragment note story kind".to_owned())),
};
let existing = self.note_part_name(kind)?;
let part = self.reserve_document_part_bundle(
existing.as_deref(),
preferred,
relationship_type,
content_type,
)?;
if kind == StoryKind::Footnote {
self.footnotes_part_name = Some(part.clone());
}
if self.package.get_part(&part).is_none() {
let notes = rdocx_oxml::footnotes::CT_Footnotes::new();
let xml = if kind == StoryKind::Footnote {
notes.to_xml_footnotes()?
} else {
notes.to_xml_endnotes()?
};
self.package.set_part(&part, xml);
}
Ok(part)
}
pub(crate) fn publish_fragment_note_staged(
&mut self,
kind: StoryKind,
part_name: &str,
note_xml: Vec<u8>,
replace_id: Option<i32>,
) -> Result<()> {
let source = self.package.get_part(part_name).ok_or_else(|| {
Error::Other(format!(
"document fragment destination note part {part_name} is missing"
))
})?;
let (root, item) = match kind {
StoryKind::Footnote => (b"footnotes".as_slice(), b"footnote".as_slice()),
StoryKind::Endnote => (b"endnotes".as_slice(), b"endnote".as_slice()),
_ => return Err(Error::Other("invalid fragment note story kind".to_owned())),
};
let updated = if let Some(id) = replace_id {
let story = self.note_story(kind, id)?.ok_or_else(|| {
Error::Other("REF f cached note replacement owner is absent".into())
})?;
let (owner_source, owner) = self.story_source_and_owner(&story)?;
if owner_source.part_name != part_name {
return Err(Error::Other(
"REF f cached note replacement has a different relationship owner".into(),
));
}
let mut updated = source.to_vec();
updated.splice(owner.full, note_xml);
rdocx_oxml::footnotes::CT_Footnotes::from_xml(&updated)?;
updated
} else {
append_story_fragments_to_root(source, root, item, &[note_xml])?
};
self.package.set_part(part_name, updated);
Ok(())
}
fn note_part_name(&self, kind: StoryKind) -> Result<Option<String>> {
if kind == StoryKind::Footnote {
return Ok(self.footnotes_part_name.clone());
}
if kind != StoryKind::Endnote {
return Err(Error::Other("invalid note story kind".to_owned()));
}
let mut targets = self
.package
.get_part_rels(&self.doc_part_name)
.map(|relationships| relationships.items.as_slice())
.unwrap_or_default()
.iter()
.filter(|relationship| {
relationship.rel_type == rel_types::ENDNOTES
&& relationship_is_internal(relationship)
})
.map(|relationship| {
OpcPackage::resolve_rel_target(&self.doc_part_name, &relationship.target)
});
let first = targets.next();
if targets.next().is_some() {
return Err(Error::Other(
"endnotes part has multiple internal relationships".to_owned(),
));
}
Ok(first)
}
fn note_story(&self, kind: StoryKind, id: i32) -> Result<Option<StoryId>> {
let Some(part_name) = self.note_part_name(kind)? else {
if kind == StoryKind::Footnote
&& self.footnotes.footnotes.iter().any(|note| {
note.id == id && note.note_type == rdocx_oxml::footnotes::NoteType::Normal
})
{
return Err(Error::Other(
"normal footnote has no relationship-resolved part".to_owned(),
));
}
return Ok(None);
};
let endnotes;
let notes = if kind == StoryKind::Footnote {
&self.footnotes
} else {
let xml = self
.package
.get_part(&part_name)
.ok_or_else(|| Error::Other(format!("endnotes part {part_name} is missing")))?;
endnotes = rdocx_oxml::footnotes::CT_Footnotes::from_xml(xml)?;
&endnotes
};
let normal = notes
.footnotes
.iter()
.filter(|note| note.note_type == rdocx_oxml::footnotes::NoteType::Normal)
.collect::<Vec<_>>();
let mut matches = normal.iter().enumerate().filter(|(_, note)| note.id == id);
let Some((index, _)) = matches.next() else {
return Ok(None);
};
if matches.next().is_some() {
return Err(Error::Other(format!("{kind:?} ID {id} is ambiguous")));
}
let stories = self
.stories()?
.into_iter()
.filter(|story| story.kind == kind && story.part_name == part_name)
.collect::<Vec<_>>();
if stories.len() != normal.len() {
return Err(Error::Other(
"note story count differs from the normal note stream".to_owned(),
));
}
Ok(stories.into_iter().nth(index))
}
/// Move a normal footnote before another normal footnote without changing IDs.
pub fn move_footnote_before(&mut self, id: i32, before_id: i32) -> Result<()> {
self.move_note_before(StoryKind::Footnote, id, before_id)
}
/// Move a normal endnote before another normal endnote without changing IDs.
pub fn move_endnote_before(&mut self, id: i32, before_id: i32) -> Result<()> {
self.move_note_before(StoryKind::Endnote, id, before_id)
}
fn move_note_before(&mut self, kind: StoryKind, id: i32, before_id: i32) -> Result<()> {
let label = if kind == StoryKind::Footnote {
"footnote"
} else {
"endnote"
};
let source_story = self
.note_story(kind, id)?
.ok_or_else(|| Error::Other(format!("{label} ID {id} does not exist")))?;
let target_story = self
.note_story(kind, before_id)?
.ok_or_else(|| Error::Other(format!("{label} ID {before_id} does not exist")))?;
if id == before_id {
return Ok(());
}
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let part_name = source_story.part_name.clone();
let mut xml = candidate
.package
.get_part(&part_name)
.ok_or_else(|| Error::Other(format!("{label}s part {part_name} is missing")))?
.to_vec();
let owners = scan_story_owners(&xml, kind)?;
let source = owners
.iter()
.find(|owner| owner.kind == kind && owner.owner_index == source_story.owner_index)
.ok_or_else(|| StoryError::OwnerNotFound {
story: source_story.clone(),
})?;
let target = owners
.iter()
.find(|owner| owner.kind == kind && owner.owner_index == target_story.owner_index)
.ok_or_else(|| StoryError::OwnerNotFound {
story: target_story.clone(),
})?;
let fragment = xml[source.full.clone()].to_vec();
let source_range = source.full.clone();
let target_start = target.full.start;
xml.drain(source_range.clone());
let insertion = if source_range.start < target_start {
target_start - source_range.len()
} else {
target_start
};
xml.splice(insertion..insertion, fragment);
set_story_source_xml(&mut candidate, &part_name, xml)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Remove a normal footnote and every matching reference in the main body.
pub fn remove_footnote(&mut self, id: i32) -> Result<()> {
self.remove_note(StoryKind::Footnote, id)
}
/// Remove a normal endnote and every matching reference in the main body.
pub fn remove_endnote(&mut self, id: i32) -> Result<()> {
self.remove_note(StoryKind::Endnote, id)
}
fn remove_note(&mut self, kind: StoryKind, id: i32) -> Result<()> {
let label = if kind == StoryKind::Footnote {
"footnote"
} else {
"endnote"
};
let story = self
.note_story(kind, id)?
.ok_or_else(|| Error::Other(format!("{label} ID {id} does not exist")))?;
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let part_name = story.part_name.clone();
let mut notes_xml = candidate
.package
.get_part(&part_name)
.ok_or_else(|| Error::Other(format!("{label}s part {part_name} is missing")))?
.to_vec();
let owner = scan_story_owners(¬es_xml, kind)?
.into_iter()
.find(|owner| owner.kind == kind && owner.owner_index == story.owner_index)
.ok_or_else(|| StoryError::OwnerNotFound {
story: story.clone(),
})?;
notes_xml.drain(owner.full);
let document_xml = candidate
.package
.get_part(&candidate.doc_part_name)
.ok_or_else(|| Error::Other("main document part is missing".to_owned()))?;
let without_references = remove_note_references(document_xml, id, kind)?;
let document_part = candidate.doc_part_name.clone();
set_story_source_xml(&mut candidate, &document_part, without_references)?;
set_story_source_xml(&mut candidate, &part_name, notes_xml)?;
candidate.reconcile_comment_removal(self)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Add a paragraph with the given text and return a mutable reference.
pub fn add_paragraph(&mut self, text: &str) -> Paragraph<'_> {
self.invalidate_layout();
let mut p = CT_P::new();
if !text.is_empty() {
p.add_run(text);
}
self.document.body.content.push(BodyContent::Paragraph(p));
match self.document.body.content.last_mut().unwrap() {
BodyContent::Paragraph(p) => Paragraph { inner: p },
_ => unreachable!(),
}
}
/// Get the number of paragraphs.
pub fn paragraph_count(&self) -> usize {
self.document.body.paragraphs().count()
}
/// Return every valid modeled main-document revision in document order.
///
/// This borrowed projection covers the main body, its tables, cells, and
/// content controls. [`Document::story_revisions`] lists every story.
pub fn revisions(&self) -> Vec<RevisionRef<'_>> {
self.document
.revisions()
.into_iter()
.map(|inner| RevisionRef { inner })
.collect()
}
/// Return every modeled revision that accepting or rejecting all revisions
/// resolves, each with the story that holds it.
///
/// The list covers the main document, headers, footers, comments, normal
/// footnotes, endnotes, and the text boxes inside them. It has one entry per
/// revision element, so its length is the count [`Document::accept_all`]
/// and [`Document::reject_all`] report. The main document comes first, then
/// headers and footers in section order, then comments, footnotes, and
/// endnotes. Revisions keep document order within each part.
///
/// Each story is one returned by [`Document::stories`]. A revision in a
/// table cell belongs to the story that holds the table. A text box that
/// `stories` does not report, such as one under `mc:AlternateContent` or
/// one whose namespace binding the typed serialization drops, belongs to
/// the story that holds its drawing. A revision outside every story owner,
/// such as one inside a footnote separator, is an error, and so is a
/// document that resolution cannot stage.
pub fn story_revisions(&self) -> Result<Vec<StoryRevision>> {
// Resolution scans the parts staging leaves in the package. The main
// part there keeps its original bytes, or its namespace declarations
// replayed, which the typed serialization that `stories` scans lacks.
let mut staged = self.clone_for_staging();
staged.prepare_staged_package()?;
let mut parts = vec![(StoryKind::Body, staged.doc_part_name.clone())];
parts.extend(crate::comparison::revision_story_parts(&staged)?);
let mut seen = HashSet::new();
let mut revisions = Vec::new();
for (root_kind, part_name) in parts {
if !seen.insert(part_name.clone()) {
continue;
}
let scanned = staged
.package
.get_part(&part_name)
.ok_or_else(|| Error::Other(format!("missing revision story part {part_name}")))?;
let reported = if part_name == self.doc_part_name {
Some(Cow::Owned(self.document.to_xml()?))
} else {
self.package.get_part(&part_name).map(Cow::Borrowed)
};
let owners = paired_story_owners(&part_name, root_kind, scanned, reported.as_deref())?;
revisions.extend(crate::revision::story_part_revisions(
&part_name, scanned, &owners,
)?);
}
if staged
.package
.contains_part(crate::comparison::COMMENT_COMPARISON_PART)
{
let body = staged
.stories()?
.into_iter()
.find(|story| story.kind() == StoryKind::Body)
.ok_or_else(|| Error::Other("comment comparison has no body story".to_owned()))?;
if let Some(revision) = crate::revision::comment_comparison_revision(&staged, body)? {
revisions.push(revision);
}
}
Ok(revisions)
}
/// Return valid document-protection metadata recorded in the settings part.
///
/// This reports author intent and password-verification metadata. It does
/// not enforce an access-control boundary or verify a password.
pub fn document_protection(&self) -> Option<&DocumentProtection> {
self.settings.as_ref()?.document_protection()
}
/// Get the plain text of body paragraphs and table cells in document order.
///
/// A body paragraph normally ends with a newline. A table row is one line in which
/// every cell paragraph ends with a tab, paragraphs of nested tables
/// included. Content controls at every level contribute the paragraphs,
/// rows and cells they wrap at the position they occupy.
/// Each paragraph contributes its accepted-view text, the same text as
/// [`ParagraphRef::text`]: tracked insertions are included and tracked
/// deletions are left out. A body paragraph whose mark is deleted or moved
/// away joins the next body paragraph without a newline when
/// `CT_Body::accepted_paragraph_joins_next` says so, as accepting it does,
/// so one with no accepted text leaves no line at all. A table row that
/// accepting removes, as
/// `CT_Row::accepted_view_removes` reports it, contributes no line, so a
/// table whose rows are all deleted contributes nothing.
pub fn text(&self) -> String {
let mut result = String::new();
for (index, content) in self.document.body.content.iter().enumerate() {
match content {
BodyContent::Paragraph(paragraph)
if self.document.body.accepted_paragraph_joins_next(index) =>
{
result.push_str(&ParagraphRef { inner: paragraph }.text());
}
BodyContent::Paragraph(paragraph) => push_paragraph_line(paragraph, &mut result),
BodyContent::Table(table) => push_table_text(table, &mut result),
BodyContent::ContentControl(control) => push_control_text(control, &mut result),
BodyContent::RawXml(_) => {}
}
}
result
}
/// Get a mutable reference to a paragraph by the same index as
/// [`Self::paragraph`].
pub fn paragraph_mut(&mut self, index: usize) -> Option<Paragraph<'_>> {
self.invalidate_layout();
let mut remaining = index;
nth_paragraph_in_body(&mut self.document.body.content, &mut remaining)
.map(|inner| Paragraph { inner })
}
/// Split a direct body paragraph run at a Unicode scalar offset of its
/// literal text and return the resulting run boundary.
///
/// `body_index` is the direct body child index that `RunPosition`,
/// [`Self::find_content_index`], [`Self::add_comment`] and
/// [`Self::insert_paragraph`] use, where a table or a block content
/// control counts as one child. An index that names a table, a block
/// content control or preserved XML is an error that names its kind. A
/// paragraph inside a block content control is split through
/// `paragraph_mut(i).split_run`.
///
/// Zero returns the boundary before the selected run and the literal text
/// length returns the boundary after it without changing the document.
/// Interior offsets create a continuation with cloned run properties while
/// preserving ordered non-text content, hyperlinks, and range markers.
/// Every failure leaves the document unchanged.
pub fn split_run(
&mut self,
body_index: usize,
run_index: usize,
character_offset: usize,
) -> Result<usize> {
let not_a_paragraph = |kind: &str| {
Error::Other(format!(
"body index {body_index} is {kind}, not a paragraph"
))
};
let original = match self.document.body.content.get(body_index) {
Some(BodyContent::Paragraph(paragraph)) => paragraph,
Some(BodyContent::Table(_)) => return Err(not_a_paragraph("a table")),
Some(BodyContent::ContentControl(_)) => {
return Err(not_a_paragraph("a block content control"));
}
Some(BodyContent::RawXml(_)) => return Err(not_a_paragraph("preserved XML")),
None => {
return Err(Error::Other(format!(
"body index {body_index} is out of range"
)));
}
};
let mut candidate = original.clone();
let boundary = Paragraph {
inner: &mut candidate,
}
.split_run(run_index, character_offset)?;
if candidate == *original {
return Ok(boundary);
}
let Some(BodyContent::Paragraph(paragraph)) =
self.document.body.content.get_mut(body_index)
else {
unreachable!("body index {body_index} was checked to be a paragraph");
};
*paragraph = candidate;
self.invalidate_layout();
Ok(boundary)
}
// ---- Table access ----
/// Get immutable references to all tables.
pub fn tables(&self) -> Vec<TableRef<'_>> {
self.document
.body
.tables()
.map(|t| TableRef { inner: t })
.collect()
}
/// Get an immutable table by index among tables only.
pub fn table(&self, index: usize) -> Option<TableRef<'_>> {
self.document
.body
.tables()
.nth(index)
.map(|inner| TableRef { inner })
}
/// Get a mutable table by index among tables only.
pub fn table_mut(&mut self, index: usize) -> Option<Table<'_>> {
self.invalidate_layout();
let mut remaining = index;
nth_table_in_body(&mut self.document.body.content, &mut remaining)
.map(|inner| Table { inner })
}
/// Append a formatted empty row through one checked package transaction.
pub fn add_empty_table_row(&mut self, table_index: usize) -> Result<usize> {
let mut candidate = self.clone_for_staging();
let index = {
let mut table = candidate.table_mut(table_index).ok_or_else(|| {
Error::Other(format!("table index {table_index} is out of range"))
})?;
let index = table.row_count();
table.add_row()?;
index
};
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(index)
}
/// Insert a logical grid column before or after an existing grid column.
pub fn insert_table_column(
&mut self,
table_index: usize,
index: usize,
before: bool,
) -> Result<()> {
let mut candidate = self.clone_for_staging();
let mut table = candidate
.table_mut(table_index)
.ok_or_else(|| Error::Other(format!("table index {table_index} is out of range")))?;
let columns = table
.inner
.grid
.as_ref()
.map_or(0, |grid| grid.columns.len());
if index >= columns {
return Err(Error::Other(
"column insertion index is out of range".into(),
));
}
table.insert_column(index, before, None)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
fn reconcile_cut_table_bookmarks(&mut self, original: &Self, part_name: &str) -> Result<()> {
let source_xml = |document: &Self| -> Result<Vec<u8>> {
document
.story_sources()?
.into_iter()
.find(|source| source.part_name == part_name)
.map(|source| source.xml.into_owned())
.ok_or_else(|| Error::Other("table bookmark source part disappeared".to_owned()))
};
let paired = |markers: &[BookmarkMarkerSpan]| {
let starts = markers
.iter()
.filter(|marker| marker.start)
.map(|marker| marker.id.clone())
.collect::<HashSet<_>>();
markers
.iter()
.filter(|marker| !marker.start && starts.contains(&marker.id))
.map(|marker| marker.id.clone())
.collect::<HashSet<_>>()
};
let paired_before = paired(&bookmark_marker_spans(&source_xml(original)?)?);
let mut xml = source_xml(self)?;
let markers = bookmark_marker_spans(&xml)?;
let paired_after = paired(&markers);
let mut orphans = markers
.into_iter()
.filter(|marker| {
paired_before.contains(&marker.id) && !paired_after.contains(&marker.id)
})
.collect::<Vec<_>>();
orphans.sort_by_key(|marker| marker.span.start);
if !orphans.is_empty() {
for marker in orphans.into_iter().rev() {
xml.splice(marker.span, std::iter::empty());
}
set_story_source_xml(self, part_name, xml)?;
}
Ok(())
}
/// Delete one logical grid column without publishing partial topology.
pub fn delete_table_column(&mut self, table_index: usize, index: usize) -> Result<()> {
let mut candidate = self.clone_for_staging();
let table = candidate
.table_mut(table_index)
.ok_or_else(|| Error::Other(format!("table index {table_index} is out of range")))?
.inner;
crate::table::remove_grid_column(table, index)?;
let part_name = candidate.doc_part_name.clone();
candidate.reconcile_cut_table_bookmarks(self, &part_name)?;
candidate.reconcile_comment_removal(self)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Split the complete merge containing a logical column, or preserve an unmerged cell.
pub fn split_table_cell(
&mut self,
table_index: usize,
row: usize,
logical_column: usize,
) -> Result<bool> {
let mut candidate = self.clone_for_staging();
let mut table = candidate
.table_mut(table_index)
.ok_or_else(|| Error::Other(format!("table index {table_index} is out of range")))?;
let columns = table
.inner
.grid
.as_ref()
.map_or(0, |grid| grid.columns.len());
let source_row = table
.inner
.rows
.get(row)
.ok_or_else(|| Error::Other("table row index is out of range".to_owned()))?;
let cell = crate::table::row_cell_ranges(source_row, columns)?
.iter()
.position(|(start, end)| (*start..*end).contains(&logical_column))
.ok_or_else(|| Error::Other("logical column has no physical cell".to_owned()))?;
let before = table.inner.clone();
table.split_cell(row, cell)?;
if table.inner == &before {
return Ok(false);
}
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(true)
}
/// Insert a copy of one table row before row `insert_at` in the same table.
///
/// `table_index` counts every table in document order, including nested
/// tables. `insert_at` may equal the row count to append. The copy keeps
/// row properties, cell formatting, nested content, relationships, and
/// preserved producer XML. Document-wide identities are made unique, the
/// row and its paragraphs drop `w14:paraId` and `w14:textId`, and comment
/// anchors are omitted. The document is unchanged on error.
pub fn clone_table_row(
&mut self,
table_index: usize,
source: usize,
insert_at: usize,
) -> Result<usize> {
let mut candidate = self.clone_for_staging();
let source_row = {
let table = candidate
.table_mut(table_index)
.ok_or_else(|| Error::Other(format!("table index {table_index} is out of range")))?
.inner;
let row_count = table.rows.len();
if source >= row_count {
return Err(Error::Other(format!(
"row index {source} is out of range for a table with {row_count} rows"
)));
}
if insert_at > row_count {
return Err(Error::Other(format!(
"row insertion index {insert_at} is out of range for a table with {row_count} rows"
)));
}
table.rows[source].clone()
};
let copied_row = candidate.fresh_table_row_copy(source_row)?;
let table = candidate
.table_mut(table_index)
.expect("the checked table remains available")
.inner;
for (position, _) in &mut table.extra_xml {
if *position >= insert_at {
*position += 1;
}
}
for (position, _, _) in &mut table.content_controls {
if *position >= insert_at {
*position += 1;
}
}
table.rows.insert(insert_at, copied_row);
validate_table_topology(table)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(insert_at)
}
/// Remove one row from a table and report whether it was removed.
///
/// A table retains at least one direct row. If the removed row starts a
/// vertical merge that continues below, the next row becomes its start.
/// Preserved table children stay at their original logical boundaries.
/// The document is unchanged on error.
pub fn remove_table_row(&mut self, table_index: usize, row_index: usize) -> Result<bool> {
let mut candidate = self.clone_for_staging();
let table = candidate
.table_mut(table_index)
.ok_or_else(|| Error::Other(format!("table index {table_index} is out of range")))?
.inner;
let row_count = table.rows.len();
if row_index >= row_count {
return Err(Error::Other(format!(
"row index {row_index} is out of range for a table with {row_count} rows"
)));
}
if row_count == 1 {
return Err(Error::Other(
"a table keeps at least one row, so its only row cannot be removed".to_owned(),
));
}
restart_merges_continued_below(table, row_index)?;
let raw_before_removed = table
.extra_xml
.iter()
.filter(|(position, _)| *position == row_index)
.count();
for (position, _) in &mut table.extra_xml {
if *position > row_index {
*position -= 1;
}
}
for (position, raw_before, _) in &mut table.content_controls {
if *position > row_index {
if *position == row_index + 1 {
*raw_before += raw_before_removed;
}
*position -= 1;
}
}
table.rows.remove(row_index);
validate_table_topology(table)?;
candidate.reconcile_comment_removal(self)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(true)
}
/// Delete a logical column in one checked related table and publish once.
#[doc(hidden)]
pub fn delete_story_table_column(
&mut self,
location: &ContentLocation,
column: usize,
) -> Result<()> {
let mut candidate = self.clone_for_staging();
let mut table = candidate.story_table_model(location)?;
crate::table::remove_grid_column(&mut table, column)?;
candidate.replace_story_content(location, ContentFragment::table(table)?)?;
candidate.reconcile_cut_table_bookmarks(self, location.story().part_name())?;
candidate.reconcile_comment_removal(self)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Copy one row within a checked related table, freshening its identities once.
#[doc(hidden)]
pub fn clone_story_table_row(
&mut self,
location: &ContentLocation,
source: usize,
insert_at: usize,
) -> Result<usize> {
let mut candidate = self.clone_for_staging();
let mut table = candidate.story_table_model(location)?;
if source >= table.rows.len() || insert_at > table.rows.len() {
return Err(Error::Other(
"rich table row copy index is out of range".to_owned(),
));
}
let copied = candidate.fresh_table_row_copy(table.rows[source].clone())?;
for (position, _) in &mut table.extra_xml {
if *position >= insert_at {
*position += 1;
}
}
for (position, _, _) in &mut table.content_controls {
if *position >= insert_at {
*position += 1;
}
}
table.rows.insert(insert_at, copied);
validate_table_topology(&table)?;
candidate.replace_story_content(location, ContentFragment::table(table)?)?;
self.commit_staged_mutation(candidate);
Ok(insert_at)
}
/// Remove one row from a checked related table while preserving owned boundaries.
#[doc(hidden)]
pub fn remove_story_table_row(
&mut self,
location: &ContentLocation,
row_index: usize,
) -> Result<bool> {
let mut candidate = self.clone_for_staging();
let mut table = candidate.story_table_model(location)?;
if row_index >= table.rows.len() {
return Err(Error::Other(
"rich table row index is out of range".to_owned(),
));
}
if table.rows.len() == 1 {
return Err(Error::Other(
"a table keeps at least one row, so its only row cannot be removed".to_owned(),
));
}
restart_merges_continued_below(&mut table, row_index)?;
let raw_before = table
.extra_xml
.iter()
.filter(|(position, _)| *position == row_index)
.count();
for (position, _) in &mut table.extra_xml {
if *position > row_index {
*position -= 1;
}
}
for (position, raw, _) in &mut table.content_controls {
if *position > row_index {
if *position == row_index + 1 {
*raw += raw_before;
}
*position -= 1;
}
}
table.rows.remove(row_index);
validate_table_topology(&table)?;
candidate.replace_story_content(location, ContentFragment::table(table)?)?;
self.commit_staged_mutation(candidate);
Ok(true)
}
/// Set one body's table cell text through a checked package transaction.
#[doc(hidden)]
pub fn try_set_cell_text(
&mut self,
table: usize,
row: usize,
cell: usize,
text: &str,
) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate
.table_mut(table)
.ok_or_else(|| Error::Other(format!("table index {table} is out of range")))?
.cell(row, cell)
.ok_or_else(|| Error::Other(format!("cell {row}:{cell} is out of range")))?
.try_set_text(text)?;
candidate.reconcile_comment_removal(self)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Freshen the document-wide identities carried by a copied table row.
fn fresh_table_row_copy(&mut self, row: CT_Row) -> Result<CT_Row> {
let mut xml = Vec::new();
CT_Tbl {
properties: None,
grid: None,
rows: vec![row],
extra_xml: Vec::new(),
content_controls: Vec::new(),
}
.to_xml(&mut Writer::new(&mut xml))?;
let mut namespace_scope = self
.body_namespace_bindings
.iter()
.map(|(name, value)| (namespace_prefix(name), value.clone()))
.collect::<BTreeMap<_, _>>();
namespace_scope.insert("w".to_owned(), WORD_NAMESPACE.to_owned());
let fragment = ContentFragment {
kind: StoryItemKind::Table,
xml,
source_part_name: None,
source_story_kind: None,
source_owner_index: None,
namespace_scope,
};
let freshened = freshen_content_fragment_identities(self, &fragment)?;
let closed = close_content_fragment_namespaces(&freshened, &fragment.namespace_scope)?;
let mut wrapped =
format!(r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body>"#).into_bytes();
wrapped.extend(closed);
wrapped.extend_from_slice(b"</w:body></w:document>");
let mut content = CT_Document::from_xml(&wrapped)?.body.content.into_iter();
match (content.next(), content.next()) {
(Some(BodyContent::Table(mut table)), None) if table.rows.len() == 1 => {
Ok(table.rows.remove(0))
}
_ => Err(Error::Other(
"the copied table row did not parse back as one row".to_owned(),
)),
}
}
/// Add a table with the specified number of rows and columns.
/// Returns a mutable reference for further configuration.
pub fn add_table(&mut self, rows: usize, cols: usize) -> Table<'_> {
self.invalidate_layout();
use rdocx_oxml::table::{CT_Row, CT_TblGrid, CT_TblGridCol, CT_TblPr, CT_TblWidth, CT_Tc};
use rdocx_oxml::units::Twips;
// Default column width: divide 9360tw (6.5" printable at 1" margins) evenly.
// A zero-column table has no grid to divide; clamp so this cannot divide by zero.
let col_width = Twips(9360 / cols.max(1) as i32);
let grid = CT_TblGrid {
columns: (0..cols)
.map(|_| CT_TblGridCol { width: col_width })
.collect(),
..Default::default()
};
let mut tbl = CT_Tbl::new();
tbl.properties = Some(CT_TblPr {
width: Some(CT_TblWidth::dxa(col_width.0 * cols as i32)),
..Default::default()
});
tbl.grid = Some(grid);
for _ in 0..rows {
let mut row = CT_Row::new();
for _ in 0..cols {
row.cells.push(CT_Tc::new());
}
tbl.rows.push(row);
}
self.document.body.content.push(BodyContent::Table(tbl));
match self.document.body.content.last_mut().unwrap() {
BodyContent::Table(t) => Table { inner: t },
_ => unreachable!(),
}
}
/// Get the number of tables.
pub fn table_count(&self) -> usize {
self.document.body.tables().count()
}
// ---- Content insertion ----
/// Get the number of body content elements (paragraphs + tables).
pub fn content_count(&self) -> usize {
self.document.body.content_count()
}
/// Insert a paragraph at the given body index.
///
/// Returns a mutable `Paragraph` for further configuration.
/// # Panics
///
/// Panics if `index > content_count()`. (Unlike [`Self::insert_document`]
/// and [`Self::insert_toc`], which clamp an out-of-range index to the end.)
pub fn insert_paragraph(&mut self, index: usize, text: &str) -> Paragraph<'_> {
self.invalidate_layout();
let mut p = CT_P::new();
if !text.is_empty() {
p.add_run(text);
}
self.document.body.insert_paragraph(index, p);
match &mut self.document.body.content[index] {
BodyContent::Paragraph(p) => Paragraph { inner: p },
_ => unreachable!(),
}
}
/// Insert a table at the given body index.
///
/// Returns a mutable `Table` for further configuration.
/// A `cols` of 0 produces a table with no columns rather than panicking.
///
/// # Panics
///
/// Panics if `index > content_count()`. (Unlike [`Self::insert_document`]
/// and [`Self::insert_toc`], which clamp an out-of-range index to the end.)
pub fn insert_table(&mut self, index: usize, rows: usize, cols: usize) -> Table<'_> {
self.invalidate_layout();
use rdocx_oxml::table::{CT_Row, CT_TblGrid, CT_TblGridCol, CT_TblPr, CT_TblWidth, CT_Tc};
use rdocx_oxml::units::Twips;
let col_width = Twips(9360 / cols.max(1) as i32);
let grid = CT_TblGrid {
columns: (0..cols)
.map(|_| CT_TblGridCol { width: col_width })
.collect(),
..Default::default()
};
let mut tbl = CT_Tbl::new();
tbl.properties = Some(CT_TblPr {
width: Some(CT_TblWidth::dxa(col_width.0 * cols as i32)),
..Default::default()
});
tbl.grid = Some(grid);
for _ in 0..rows {
let mut row = CT_Row::new();
for _ in 0..cols {
row.cells.push(CT_Tc::new());
}
tbl.rows.push(row);
}
self.document.body.insert_table(index, tbl);
match &mut self.document.body.content[index] {
BodyContent::Table(t) => Table { inner: t },
_ => unreachable!(),
}
}
/// Find the body content index of the first paragraph containing the given text.
pub fn find_content_index(&self, text: &str) -> Option<usize> {
self.find_content_indices(text).into_iter().next()
}
/// Find every direct body location whose paragraph content contains `text`.
///
/// Direct paragraphs precede enclosing content controls so a heading wins
/// over a table-of-contents control that happens to repeat its text.
pub fn find_content_indices(&self, text: &str) -> Vec<usize> {
self.document.body.find_paragraph_indices(text)
}
/// Return the paragraph index of the direct body child at `content_index`.
///
/// The result addresses that paragraph through [`Self::paragraph`] and
/// [`Self::paragraph_mut`], whose index also counts paragraphs inside block
/// content controls. Returns `None` when the index is out of bounds or the
/// direct child is not a paragraph.
pub fn paragraph_index_of_content(&self, content_index: usize) -> Option<usize> {
let BodyContent::Paragraph(target) = self.document.body.content.get(content_index)? else {
return None;
};
self.document
.body
.paragraphs()
.position(|paragraph| std::ptr::eq(paragraph, target))
}
/// Return the direct body index of the paragraph addressed by `paragraph_index`.
///
/// A paragraph nested in a block content control is not a direct body child
/// and therefore returns `None`.
pub fn content_index_of_paragraph(&self, paragraph_index: usize) -> Option<usize> {
let target = self.paragraph(paragraph_index)?.inner;
self.document
.body
.content
.iter()
.position(|content| matches!(content, BodyContent::Paragraph(paragraph) if std::ptr::eq(paragraph, target)))
}
/// Return the checked body story location of the paragraph addressed by
/// `paragraph_index`, the index of [`Self::paragraph_mut`].
///
/// A direct body paragraph has its one-segment story item path. A
/// paragraph inside a block content control, which has no story item of
/// its own, has a two-segment path: the control's story item index, then
/// the paragraph's position among the control's paragraphs. Comment
/// operations and scoped literal replacement accept this form. Returns
/// `None` when the index is out of range or the paragraph belongs to a
/// Resolve a modeled body cell paragraph to its checked physical story owner.
#[doc(hidden)]
pub fn cell_paragraph_story_location(
&self,
table: usize,
row: usize,
cell: usize,
paragraph: usize,
) -> Result<ContentLocation> {
let mut candidate = self.clone_for_staging();
let selected = candidate
.table(table)
.and_then(|table| table.cell(row, cell).map(|cell| cell.inner as *const CT_Tc))
.ok_or_else(|| Error::Other("cell index is out of range".to_owned()))?;
let xml = candidate.document.to_xml()?;
for owner in scan_story_owners(&xml, StoryKind::Body)?
.into_iter()
.filter(|owner| owner.kind == StoryKind::TableCell)
{
let (content, mut ordinal) = modeled_main_cell_route(&xml, &owner)?;
let modeled = match candidate.document.body.content.get_mut(content) {
Some(BodyContent::Table(table)) => nth_cell_in_table(table, &mut ordinal),
Some(BodyContent::ContentControl(control)) => {
nth_cell_in_control(control, &mut ordinal)
}
_ => None,
};
if let Some(modeled) = modeled.filter(|cell| std::ptr::eq(selected, *cell)) {
let count = modeled.paragraphs().len();
let story = StoryId {
kind: owner.kind,
part_name: candidate.doc_part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
};
let mut paragraphs = story_range_paragraph_spans(&xml, &[(story, owner)])?
.into_iter()
.next()
.expect("one checked cell inventory");
// Controls contribute their direct paragraphs separately. Restore
// physical source order before applying the facade paragraph axis.
paragraphs.sort_by_key(|(_, span)| span.start);
if paragraphs.len() != count {
return Err(Error::Other(
"cell paragraph modeled/source correspondence is unsupported".into(),
));
}
let (location, _) = paragraphs
.into_iter()
.nth(paragraph)
.ok_or_else(|| Error::Other("cell paragraph index is out of range".into()))?;
candidate.story_range_paragraph_snapshot(&location)?;
return Ok(location);
}
}
Err(Error::Other(
"cell has no checked physical story owner".to_owned(),
))
}
/// nested control or table beyond this two-segment path.
pub fn paragraph_story_location(
&self,
paragraph_index: usize,
) -> Result<Option<ContentLocation>> {
let Some(paragraph) = self.paragraph(paragraph_index) else {
return Ok(None);
};
let selected = paragraph.inner;
// A facade handle counts recursively. The checked two-segment path
// counts only paragraphs directly owned by the outer block control.
let target = self
.document
.body
.content
.iter()
.enumerate()
.find_map(|(slot, child)| match child {
BodyContent::Paragraph(paragraph) if std::ptr::eq(paragraph, selected) => {
Some((slot, None))
}
BodyContent::ContentControl(control) => control
.content
.iter()
.filter_map(|child| match child {
SdtContent::Paragraph(paragraph) => Some(paragraph),
_ => None,
})
.position(|paragraph| std::ptr::eq(paragraph, selected))
.map(|ordinal| (slot, Some(ordinal))),
_ => None,
});
let Some((slot, ordinal)) = target else {
return Ok(None);
};
let story = self
.stories()?
.into_iter()
.find(|story| story.kind == StoryKind::Body)
.ok_or_else(|| Error::Other("document body story is missing".to_owned()))?;
let (source, owner) = self.story_source_and_owner(&story)?;
// The body section properties come last, so the n-th direct item is
// the direct body child at slot n.
let items = scan_story_items(source.xml.as_ref(), &owner)?;
let (item_index, item) = items
.iter()
.enumerate()
.filter(|(_, item)| item.direct_owner_child)
.nth(slot)
.ok_or_else(|| Error::Other("body paragraph has no story item".to_owned()))?;
let expected_kind = if ordinal.is_some() {
StoryItemKind::ContentControl
} else {
StoryItemKind::Paragraph
};
if item.kind != expected_kind {
return Ok(None);
}
Ok(Some(ContentLocation::new(
story,
StoryItemKind::Paragraph,
std::iter::once(item_index).chain(ordinal).collect(),
)))
}
/// Return the direct body index of the table addressed by `table_index`.
pub fn content_index_of_table(&self, table_index: usize) -> Option<usize> {
let target = self.table(table_index)?.inner;
self.document.body.content.iter().position(
|content| matches!(content, BodyContent::Table(table) if std::ptr::eq(table, target)),
)
}
/// Remove the content at the given body index.
///
/// Returns false when the index is absent or safe removal is refused.
/// Use [`Self::try_remove_content`] to distinguish a refusal from an absent index.
pub fn remove_content(&mut self, index: usize) -> bool {
self.try_remove_content(index).unwrap_or(false)
}
/// Remove a direct body child and its wholly owned comment threads.
/// Partial comment cuts and unprovable companion ownership fail atomically.
pub fn try_remove_content(&mut self, index: usize) -> Result<bool> {
if index >= self.document.body.content.len() {
return Ok(false);
}
let mut candidate = self.clone_for_staging();
candidate.document.body.remove(index);
candidate.reconcile_comment_removal(self)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(true)
}
// ---- Image support ----
/// Add an inline image to the document.
///
/// Embeds the image data (PNG, JPEG, etc.) into the package and adds a
/// paragraph containing the image. Returns a mutable reference to the
/// paragraph for further configuration.
///
/// `width` and `height` specify the display size.
pub fn add_picture(
&mut self,
image_data: &[u8],
image_filename: &str,
width: Length,
height: Length,
) -> Paragraph<'_> {
self.try_add_picture(image_data, image_filename, width, height)
.expect("an in-memory document cannot exhaust image identifiers");
let Some(BodyContent::Paragraph(paragraph)) = self.document.body.content.last_mut() else {
unreachable!("picture staging appends one paragraph")
};
Paragraph { inner: paragraph }
}
fn try_add_picture(
&mut self,
image_data: &[u8],
image_filename: &str,
width: Length,
height: Length,
) -> Result<()> {
let mut candidate = self.clone_for_staging();
let owner = candidate.doc_part_name.clone();
let rel_id =
candidate.add_image_relationship_checked(&owner, image_data, image_filename)?;
let drawing_id = candidate.identifiers.reserve_drawing_id()?;
let mut inline = CT_Inline::new(&rel_id, width.to_emu(), height.to_emu());
inline.doc_pr_id = drawing_id;
let drawing = CT_Drawing::inline(inline);
let run = CT_R {
alt_drawings: Vec::new(),
properties: None,
content: vec![RunContent::Drawing(drawing)],
extra_xml: Vec::new(),
extra_xml_positions: Vec::new(),
};
let mut p = CT_P::new();
p.runs.push(run);
candidate
.document
.body
.content
.push(BodyContent::Paragraph(p));
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(())
}
fn chart_legend_owner(
&self,
paragraph: usize,
) -> Result<(ContentLocation, Vec<u8>, WordChartReference, String)> {
if self.content_index_of_paragraph(paragraph).is_none() {
return Err(Error::Other(
"chart legend requires a direct body paragraph".into(),
));
}
let location = self
.paragraph_story_location(paragraph)?
.ok_or_else(|| Error::Other("chart paragraph is absent".into()))?;
let xml = self.paragraph_element_xml(&location)?;
let references = word_chart_references(&xml)?
.into_iter()
.filter(|reference| reference.owned)
.collect::<Vec<_>>();
let [reference] = references.as_slice() else {
return Err(Error::Other(
"chart paragraph requires exactly one owned chart".into(),
));
};
let id = &reference.relationship_id;
if self
.package
.get_part_rels(&self.doc_part_name)
.map_or(0, |rels| rels.items.iter().filter(|r| r.id == *id).count())
!= 1
{
return Err(Error::Other(
"chart relationship is missing or ambiguous".into(),
));
}
let relationship = self
.package
.get_part_rels(&self.doc_part_name)
.and_then(|r| r.get_by_id(id))
.filter(|r| r.rel_type == rel_types::CHART && relationship_is_internal(r))
.ok_or_else(|| {
Error::Other("chart relationship is missing, external or invalid".into())
})?;
let part = OpcPackage::resolve_rel_target(&self.doc_part_name, &relationship.target);
if self.package.content_types.content_type_for(&part) != Some(content_types::CHART)
|| self.package.get_part(&part).is_none()
{
return Err(Error::Other(
"chart target is missing or has invalid content type".into(),
));
}
if let Some(rels) = self.package.get_part_rels(&part) {
for rel in &rels.items {
if relationship_is_internal(rel)
&& self
.package
.get_part(&OpcPackage::resolve_rel_target(&part, &rel.target))
.is_none()
{
return Err(Error::Other("chart related part is missing".into()));
}
}
}
Ok((location, xml, reference.clone(), part))
}
/// Read effective legend settings from exactly one owned direct-body chart.
pub fn chart_legend(&self, paragraph: usize) -> Result<oxml_chart::LegendSettings> {
let (_, _, _, part) = self.chart_legend_owner(paragraph)?;
oxml_chart::chart_legend_settings(
self.package
.get_part(&part)
.ok_or_else(|| Error::Other("chart part disappeared".into()))?,
)
.map_err(|e| Error::Other(format!("invalid chart legend: {e}")))
}
/// Atomically edit one chart legend, isolating any shared ChartML part.
pub fn set_chart_legend(
&mut self,
paragraph: usize,
visible: Option<bool>,
position: Option<oxml_chart::LegendPosition>,
overlay: Option<bool>,
) -> Result<()> {
let (location, xml, reference, part) = self.chart_legend_owner(paragraph)?;
let id = &reference.relationship_id;
let source = self
.package
.get_part(&part)
.ok_or_else(|| Error::Other("chart part disappeared".into()))?;
let updated = oxml_chart::set_chart_legend(source, visible, position, overlay)
.map_err(|e| Error::Other(format!("invalid chart legend: {e}")))?;
if updated == source {
return Ok(());
}
let references = word_chart_references(&self.document.to_xml()?)?;
let shared = references
.iter()
.filter(|r| &r.relationship_id == id)
.count()
> 1
|| self
.package
.part_rels
.iter()
.flat_map(|(owner, rels)| rels.items.iter().map(move |r| (owner, r)))
.filter(|(owner, r)| {
relationship_is_internal(r)
&& OpcPackage::resolve_rel_target(owner, &r.target)
.eq_ignore_ascii_case(&part)
})
.count()
> 1;
let mut candidate = self.clone_for_staging();
candidate
.identifiers
.observe_package_graph(&candidate.package)?;
let target = if shared {
candidate
.identifiers
.reserve_part_name("/word/charts", "chart", "xml")?
} else {
part.clone()
};
if shared {
if let Some(mut rels) = candidate.package.get_part_rels(&part).cloned() {
for rel in &mut rels.items {
if relationship_is_internal(rel) {
let dest = OpcPackage::resolve_rel_target(&part, &rel.target);
if candidate.package.get_part(&dest).is_none() {
return Err(Error::Other("chart related part is missing".into()));
}
rel.target = relative_target(&target, &dest);
}
}
candidate.package.set_part_rels(&target, rels);
}
let owner = candidate.doc_part_name.clone();
let isolated = candidate.add_relative_internal_relationship_checked(
&owner,
rel_types::CHART,
&target,
)?;
let mut rewritten = xml;
rewritten.splice(reference.value_span, isolated.bytes());
let rewritten =
std::str::from_utf8(&rewritten).map_err(|e| Error::Other(e.to_string()))?;
candidate.package.set_part(&target, updated);
candidate
.package
.content_types
.add_override(&target, content_types::CHART);
candidate.replace_paragraph_xml(&location, rewritten)?;
} else {
candidate.package.set_part(&target, updated);
}
candidate.invalidate_layout();
let candidate = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Add an editable inline chart to the document.
///
/// The ChartML cache and embedded workbook are authored from the same
/// validated data. Invalid dimensions or data leave the document unchanged.
pub fn add_chart(
&mut self,
kind: ChartKind,
width: Length,
height: Length,
data: &ChartData,
) -> Result<Paragraph<'_>> {
self.add_chart_package(ChartPackageSource::Authored { kind, data }, width, height)?;
let Some(BodyContent::Paragraph(paragraph)) = self.document.body.content.last_mut() else {
unreachable!("chart package assembly appends one paragraph");
};
Ok(Paragraph { inner: paragraph })
}
/// Insert an editable chart immediately after a direct body paragraph.
/// Validation and package assembly complete on a candidate before publication.
pub fn add_chart_after(
&mut self,
after: usize,
kind: ChartKind,
width: Length,
height: Length,
data: &ChartData,
) -> Result<Paragraph<'_>> {
let index = self.content_index_of_paragraph(after).ok_or_else(|| {
Error::Other("chart insertion requires a direct body paragraph".to_owned())
})? + 1;
let mut candidate = self.clone_for_staging();
candidate.add_chart(kind, width, height, data)?;
let paragraph =
candidate.document.body.content.pop().ok_or_else(|| {
Error::Other("chart assembly did not create a paragraph".to_owned())
})?;
candidate.document.body.content.insert(index, paragraph);
candidate.document.to_xml()?;
self.commit_staged_mutation(candidate);
let BodyContent::Paragraph(paragraph) = &mut self.document.body.content[index] else {
unreachable!("chart assembly creates a paragraph");
};
Ok(Paragraph { inner: paragraph })
}
/// Add an inline image at its native size using 72 DPI when none is declared.
///
/// Returns an error without changing the document when the image dimensions
/// cannot be determined.
pub fn add_picture_auto(
&mut self,
image_data: &[u8],
image_filename: &str,
) -> Result<Paragraph<'_>> {
let native_size = oxml_media::probe(image_data)
.and_then(|info| info.native_size(72.0))
.ok_or_else(|| Error::UnavailableImageDimensions {
filename: image_filename.to_owned(),
})?;
self.try_add_picture(
image_data,
image_filename,
Length::emu(native_size.width_emu),
Length::emu(native_size.height_emu),
)?;
let Some(BodyContent::Paragraph(paragraph)) = self.document.body.content.last_mut() else {
unreachable!("picture staging appends one paragraph")
};
Ok(Paragraph { inner: paragraph })
}
/// Add a full-page background image behind text.
///
/// The image is placed at position (0,0) relative to the page with
/// dimensions matching the page size from section properties.
/// It is inserted at the beginning of the document body so it renders
/// behind all other content.
pub fn add_background_image(
&mut self,
image_data: &[u8],
image_filename: &str,
) -> Paragraph<'_> {
let mut candidate = self.clone_for_staging();
let rel_id = candidate.embed_image(image_data, image_filename);
// Get page dimensions from section properties (default US Letter)
let sect = candidate
.document
.body
.sect_pr
.as_ref()
.cloned()
.unwrap_or_else(CT_SectPr::default_letter);
let page_width_emu = sect
.page_width
.unwrap_or(rdocx_oxml::units::Twips(12240))
.to_emu()
.0;
let page_height_emu = sect
.page_height
.unwrap_or(rdocx_oxml::units::Twips(15840))
.to_emu()
.0;
let mut anchor = CT_Anchor::background(&rel_id, page_width_emu, page_height_emu);
anchor.doc_pr_id = candidate
.identifiers
.reserve_drawing_id()
.expect("an in-memory document cannot exhaust every drawing identifier");
let drawing = CT_Drawing::anchor(anchor);
let run = CT_R {
alt_drawings: Vec::new(),
properties: None,
content: vec![RunContent::Drawing(drawing)],
extra_xml: Vec::new(),
extra_xml_positions: Vec::new(),
};
let mut p = CT_P::new();
p.runs.push(run);
candidate.document.body.insert_paragraph(0, p);
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
match &mut self.document.body.content[0] {
BodyContent::Paragraph(p) => Paragraph { inner: p },
_ => unreachable!(),
}
}
/// Add an anchored (floating) image to the document.
///
/// If `behind_text` is true, the image renders behind text content.
/// The image is inserted at the beginning of the document body.
pub fn add_anchored_image(
&mut self,
image_data: &[u8],
image_filename: &str,
width: Length,
height: Length,
behind_text: bool,
) -> Paragraph<'_> {
let mut candidate = self.clone_for_staging();
let rel_id = candidate.embed_image(image_data, image_filename);
let mut anchor = CT_Anchor::background(&rel_id, width.to_emu(), height.to_emu());
anchor.doc_pr_id = candidate
.identifiers
.reserve_drawing_id()
.expect("an in-memory document cannot exhaust every drawing identifier");
anchor.behind_doc = behind_text;
let drawing = CT_Drawing::anchor(anchor);
let run = CT_R {
alt_drawings: Vec::new(),
properties: None,
content: vec![RunContent::Drawing(drawing)],
extra_xml: Vec::new(),
extra_xml_positions: Vec::new(),
};
let mut p = CT_P::new();
p.runs.push(run);
candidate.document.body.insert_paragraph(0, p);
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
match &mut self.document.body.content[0] {
BodyContent::Paragraph(p) => Paragraph { inner: p },
_ => unreachable!(),
}
}
/// Store image bytes as a new media part and declare its content type.
///
/// Returns the relationship target to use when referencing it, e.g.
/// `media/image3.png`. No relationship is created here: an image referenced
/// from a header or footer must be related to *that* part, not the
/// document, so the caller decides where it is attached.
fn store_image_part(&mut self, image_data: &[u8], filename: &str) -> String {
let (part_name, format) = self
.reserve_image_part(image_data, filename)
.expect("an in-memory package cannot exhaust every media part suffix");
self.install_reserved_image_part(&part_name, image_data, format)
}
fn reserve_image_part(
&mut self,
image_data: &[u8],
filename: &str,
) -> Result<(String, oxml_media::ImageFormat)> {
let format = oxml_media::resolve(image_data, filename);
let part_name =
self.identifiers
.reserve_part_name("/word/media", "image", format.extension())?;
Ok((part_name, format))
}
fn install_reserved_image_part(
&mut self,
part_name: &str,
image_data: &[u8],
format: oxml_media::ImageFormat,
) -> String {
let extension = format.extension();
self.package.set_part(part_name, image_data.to_vec());
let content_type = format.content_type();
match self.package.content_types.content_type_for(part_name) {
Some(existing) if existing == content_type => {}
Some(_) => self
.package
.content_types
.add_override(part_name, content_type),
None => {
self.package
.content_types
.add_default(extension, content_type);
self.identifiers.register_content_type_default(extension);
}
}
if self.package.content_types.contains_override(part_name) {
self.identifiers.register_content_type_override(part_name);
}
part_name
.strip_prefix("/word/")
.unwrap_or(part_name)
.to_owned()
}
/// Embed an image into the OPC package and return the relationship ID.
///
/// Public so callers can pre-embed an image and then pass the returned
/// `rel_id` to [`crate::Cell::add_picture`] for inline cell images.
pub fn embed_image(&mut self, image_data: &[u8], filename: &str) -> String {
let mut candidate = self.clone_for_staging();
let owner = candidate.doc_part_name.clone();
let relationship_id = candidate
.add_image_relationship_checked(&owner, image_data, filename)
.expect("an in-memory package cannot exhaust every relationship identifier");
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
relationship_id
}
/// Whether the given numbering definition renders as bullets (true)
/// or numbers (false). None if the id is unknown.
pub fn numbering_is_bullet(&self, num_id: u32) -> Option<bool> {
let numbering = self.numbering.as_ref()?;
let abstract_num = numbering.get_abstract_num_for(num_id)?;
let fmt = abstract_num.levels.first()?.num_fmt.as_ref()?;
match fmt {
rdocx_oxml::numbering::ST_NumberFormat::Bullet => Some(true),
rdocx_oxml::numbering::ST_NumberFormat::Other(_) => None,
_ => Some(false),
}
}
/// Resolve the public reader projection for one numbering level.
///
/// `has_unmodeled_properties` reports extra XML or attributes attached to
/// this numbering instance, definition, or level, excluding retained XML
/// namespace declarations. Modeled producer metadata and raw preservation
/// overlays for level properties are not reported.
pub fn numbering_level(&self, num_id: u32, level: u32) -> Option<NumberingLevel<'_>> {
if num_id == 0 {
return None;
}
let numbering = self.numbering.as_ref()?;
let instance = numbering.nums.iter().find(|item| item.num_id == num_id)?;
let definition = numbering
.abstract_nums
.iter()
.find(|item| item.abstract_num_id == instance.abstract_num_id)?;
let level = definition.levels.iter().find(|item| item.ilvl == level)?;
let format = level.num_fmt.as_ref();
Some(NumberingLevel {
level: level.ilvl,
format: format
.map(NumberingFormat::from_st)
.unwrap_or(NumberingFormat::Decimal),
format_name: format.map(ST_NumberFormat::to_str).unwrap_or("decimal"),
start: level.start.unwrap_or(1),
suffix: level
.suffix
.map(ListLevelSuffix::from_st)
.unwrap_or(ListLevelSuffix::Tab),
level_text: level.lvl_text.as_deref(),
alignment: level.lvl_jc.map(list_level_alignment),
paragraph_style: level.p_style.as_deref(),
restart: level.restart.map(ListLevelRestart::from_st),
legal_numbering: level.legal,
indent_left: level
.ppr
.as_ref()
.and_then(|properties| properties.ind_left),
indent_hanging: level
.ppr
.as_ref()
.and_then(|properties| properties.ind_hanging),
indent_first_line: level
.ppr
.as_ref()
.and_then(|properties| properties.ind_first_line),
marker_properties: level.rpr.as_ref(),
template_code: level.template_code.as_deref(),
tentative: level.tentative,
has_unmodeled_properties: !instance.extra_xml.is_empty()
|| !definition.extra_xml.is_empty()
|| !level.extra_xml.is_empty()
|| instance
.extra_attributes
.iter()
.chain(&definition.extra_attributes)
.chain(&level.extra_attributes)
.any(|(name, _)| name != "xmlns" && !name.starts_with("xmlns:")),
has_paragraph_presentation: level.ppr.as_ref().is_some_and(|properties| {
Self::has_list_paragraph_presentation(level.ilvl, properties)
}),
has_marker_presentation: level
.rpr
.as_ref()
.is_some_and(|properties| properties != &CT_RPr::default()),
})
}
fn has_list_paragraph_presentation(level: u32, properties: &CT_PPr) -> bool {
let Some(standard_indent) = (u64::from(level) + 1)
.checked_mul(720)
.and_then(|value| i32::try_from(value).ok())
else {
return true;
};
let standard = CT_PPr {
ind_left: Some(rdocx_oxml::units::Twips(standard_indent)),
ind_hanging: Some(rdocx_oxml::units::Twips(360)),
..CT_PPr::default()
};
properties != &standard
}
/// Append an external hyperlink to the last paragraph (creating one if
/// the document is empty): adds the External relationship and wraps the
/// new run in a hyperlink span.
pub fn append_hyperlink(&mut self, text: &str, url: &str) {
let mut candidate = self.clone_for_staging();
candidate.invalidate_layout();
let owner = candidate.doc_part_name.clone();
let rel_id = candidate
.add_external_relationship_checked(&owner, rel_types::HYPERLINK, url)
.expect("an in-memory document can install a hyperlink relationship");
if !matches!(
candidate.document.body.content.last(),
Some(BodyContent::Paragraph(_))
) {
candidate
.document
.body
.content
.push(BodyContent::Paragraph(CT_P::new()));
}
let Some(BodyContent::Paragraph(p)) = candidate.document.body.content.last_mut() else {
unreachable!();
};
crate::Paragraph { inner: p }.add_hyperlink(text, &rel_id);
self.commit_staged_mutation(candidate);
}
/// Add an external hyperlink relationship and return its relationship ID.
///
/// Use this with [`crate::Paragraph::add_hyperlink`] when the target
/// paragraph is not the last body paragraph, such as a paragraph inside a
/// table cell.
pub fn add_hyperlink_relationship(&mut self, url: &str) -> String {
let mut candidate = self.clone_for_staging();
candidate.invalidate_layout();
let owner = candidate.doc_part_name.clone();
let id = candidate
.add_external_relationship_checked(&owner, rel_types::HYPERLINK, url)
.expect("an in-memory document can install a hyperlink relationship");
self.commit_staged_mutation(candidate);
id
}
/// Get a builder for the last paragraph in the body, if any. Lets
/// callers interleave plain runs with `append_hyperlink` calls.
pub fn last_paragraph_mut(&mut self) -> Option<Paragraph<'_>> {
self.invalidate_layout();
match self.document.body.content.last_mut() {
Some(BodyContent::Paragraph(p)) => Some(Paragraph { inner: p }),
_ => None,
}
}
/// Fetch the raw bytes of an embedded image by its relationship ID.
pub fn image_data(&self, rel_id: &str) -> Option<Vec<u8>> {
let rels = self.package.get_part_rels(&self.doc_part_name)?;
let rel = rels.items.iter().find(|relationship| {
relationship.id == rel_id
&& relationship.rel_type == rel_types::IMAGE
&& relationship_is_internal(relationship)
})?;
let target = OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
self.package.get_part(&target).map(|b| b.to_vec())
}
/// Replace an embedded body picture by its relationship ID.
///
/// The relationship and every drawing that shows the picture, with its
/// extent, alt text and position, stay as they are. Shared target parts use
/// copy-on-write, and the target name and content type follow the new bytes.
pub fn replace_image(&mut self, rel_id: &str, image_data: &[u8]) -> Result<()> {
let owner = self.doc_part_name.clone();
self.replace_image_relationship(&owner, rel_id, image_data)
}
/// Resize every main-document picture that shows one image relationship.
///
/// Each inline or anchored `pic:pic` drawing whose blip names `rel_id`
/// gets the new `wp:extent` and `pic:spPr` `a:ext`, so pictures that share
/// the relationship are all resized. Its `wp:effectExtent` scales with the
/// extent on each axis, and anchor positions stay as they are. Returns the
/// number of drawings resized.
///
/// A VML `w:pict` picture that uses the relationship is not resized. The
/// relationship of an SVG blip extension is not matched, so pass the
/// relationship of its `a:blip` instead. A picture inside a group is not
/// resized, so a relationship that only grouped pictures use is an error.
pub fn set_picture_size(
&mut self,
rel_id: &str,
width: Length,
height: Length,
) -> Result<usize> {
const MAX_POSITIVE_COORDINATE: i64 = 27_273_042_316_900;
let (cx, cy) = (width.to_emu(), height.to_emu());
if !(1..=MAX_POSITIVE_COORDINATE).contains(&cx)
|| !(1..=MAX_POSITIVE_COORDINATE).contains(&cy)
{
return Err(Error::Other(format!(
"picture size {cx} x {cy} EMU must be positive and within the DrawingML coordinate range"
)));
}
let owner = self.doc_part_name.clone();
let is_image = self
.package
.get_part_rels(&owner)
.and_then(|relationships| relationships.get_by_id(rel_id))
.is_some_and(|relationship| relationship.rel_type == rel_types::IMAGE);
if !is_image {
return Err(Error::Other(format!(
"relationship owner {owner} has no image relationship {rel_id}"
)));
}
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let (updated, resized) =
resize_story_pictures(&candidate.document.to_xml()?, rel_id, cx, cy)?;
if resized == 0 {
return Err(Error::Other(format!(
"no picture drawing shows image relationship {rel_id}"
)));
}
set_story_source_xml(&mut candidate, &owner, updated)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(resized)
}
/// Resolve a hyperlink relationship ID to its external URL.
pub fn hyperlink_url(&self, rel_id: &str) -> Option<String> {
use oxml_opc::relationship::rel_types;
let rels = self.package.get_part_rels(&self.doc_part_name)?;
rels.items
.iter()
.find(|relationship| {
relationship.id == rel_id
&& relationship.rel_type == rel_types::HYPERLINK
&& relationship.target_mode.as_deref() == Some("External")
})
.map(|r| r.target.clone())
}
// ---- Header/Footer ----
/// Set the default header text.
///
/// Creates a header part with the given text and references it from
/// the section properties.
pub fn set_header(&mut self, text: &str) {
self.try_set_header(text)
.expect("an in-memory document can install a header");
}
/// Replace the header atomically, refusing unsafe comment removal.
pub fn try_set_header(&mut self, text: &str) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.invalidate_layout();
candidate.set_header_footer_part(text, true, HdrFtrType::Default)?;
candidate.reconcile_comment_removal(self)?;
candidate.clone_for_staging().prepare_and_reopen_staged()?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Set the default footer text.
pub fn set_footer(&mut self, text: &str) {
self.try_set_footer(text)
.expect("an in-memory document can install a footer");
}
/// Replace the footer atomically, refusing unsafe comment removal.
pub fn try_set_footer(&mut self, text: &str) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.invalidate_layout();
candidate.set_header_footer_part(text, false, HdrFtrType::Default)?;
candidate.reconcile_comment_removal(self)?;
candidate.clone_for_staging().prepare_and_reopen_staged()?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Set the first-page header text.
pub fn set_first_page_header(&mut self, text: &str) {
self.try_set_first_page_header(text)
.expect("an in-memory document can install a first page header");
}
/// Replace the first page header atomically, refusing unsafe comment removal.
pub fn try_set_first_page_header(&mut self, text: &str) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.invalidate_layout();
candidate.set_different_first_page(true);
candidate.set_header_footer_part(text, true, HdrFtrType::First)?;
candidate.reconcile_comment_removal(self)?;
candidate.clone_for_staging().prepare_and_reopen_staged()?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Set the first-page footer text.
pub fn set_first_page_footer(&mut self, text: &str) {
self.try_set_first_page_footer(text)
.expect("an in-memory document can install a first page footer");
}
/// Replace the first page footer atomically, refusing unsafe comment removal.
pub fn try_set_first_page_footer(&mut self, text: &str) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.invalidate_layout();
candidate.set_different_first_page(true);
candidate.set_header_footer_part(text, false, HdrFtrType::First)?;
candidate.reconcile_comment_removal(self)?;
candidate.clone_for_staging().prepare_and_reopen_staged()?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Get the default header text, if set.
pub fn header_text(&self) -> Option<String> {
self.get_header_footer_text(true, HdrFtrType::Default)
}
/// Get the default footer text, if set.
pub fn footer_text(&self) -> Option<String> {
self.get_header_footer_text(false, HdrFtrType::Default)
}
/// Whether the document references any header or footer part.
///
/// This is broader than [`Self::header_text`] and [`Self::footer_text`]:
/// a referenced part can contain drawings, fields, tables, or other visible
/// content without contributing literal text.
pub fn has_header_footer_content(&self) -> bool {
!self.header_footer_rel_ids().is_empty()
}
/// Set the default header to an inline image.
///
/// Creates a header part with an image paragraph. The image is embedded
/// in the header part's relationships.
pub fn set_header_image(
&mut self,
image_data: &[u8],
image_filename: &str,
width: Length,
height: Length,
) {
let mut candidate = self.clone_for_staging();
candidate.invalidate_layout();
candidate
.set_header_footer_image_part(
image_data,
image_filename,
width,
height,
true,
HdrFtrType::Default,
)
.expect("an in-memory document can install a header image");
candidate
.reconcile_comment_removal(self)
.expect("whole-story replacement cannot remove partial or malformed comment ownership");
candidate
.clone_for_staging()
.prepare_and_reopen_staged()
.expect("whole-story replacement package preflight failed");
self.commit_staged_mutation(candidate);
}
/// Set a Word-compatible text watermark in every active header variant.
pub fn set_text_watermark(&mut self, text: &str) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.apply_watermark(|_, _, _| {
Ok((
VmlWatermark::Text {
text: text.to_owned(),
width_pt: 468.0,
height_pt: 117.0,
rotation_degrees: 315.0,
color: "D9D9D9".to_owned(),
font_family: Some("Calibri".to_owned()),
opacity: 0.5,
},
None,
))
})?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Set the API-owned text watermark for one explicit section header variant.
pub fn set_text_watermark_for(
&mut self,
section_index: usize,
hdr_type: HdrFtrType,
text: &str,
options: TextWatermarkOptions,
) -> Result<()> {
validate_text_watermark_options(&options)?;
let mut candidate = self.clone_for_staging();
let story =
candidate.create_section_story(section_index, HeaderFooterKind::Header, hdr_type)?;
let part_name = story.part_name.clone();
let xml = candidate
.package
.get_part(&part_name)
.ok_or_else(|| Error::Other(format!("header part {part_name} is missing")))?
.to_vec();
let updated = replace_authored_watermark(
&xml,
&VmlWatermark::Text {
text: text.to_owned(),
width_pt: options.width.to_pt(),
height_pt: options.height.to_pt(),
rotation_degrees: options.rotation_degrees,
color: options.color,
font_family: options.font_family,
opacity: options.opacity,
},
)?;
candidate.package.set_part(&part_name, updated);
candidate.invalidate_layout();
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Set real DrawingML watermark text in one explicit header variant.
/// Only unchanged API-owned DrawingML is replaced. Opaque edits refuse atomically.
pub fn set_drawingml_text_watermark_for(
&mut self,
section_index: usize,
hdr_type: HdrFtrType,
text: &str,
options: TextWatermarkOptions,
font_size: Length,
) -> Result<()> {
validate_text_watermark_options(&options)?;
if options.opacity != 0.5 {
return Err(Error::Other(
"DrawingML text watermark opacity other than 0.5 is unsupported by the qualified native text-fill representation".into(),
));
}
let font_emu = font_size.to_emu();
if font_emu <= 0 || font_emu % 6350 != 0 || font_emu / 6350 > 3276 {
return Err(Error::Other(
"watermark font size requires positive half-points up to 1638 points".into(),
));
}
let mut candidate = self.clone_for_staging();
let story =
match candidate.section_story(section_index, HeaderFooterKind::Header, hdr_type)? {
Some(story) => story.story,
None => candidate.create_section_story(
section_index,
HeaderFooterKind::Header,
hdr_type,
)?,
};
candidate.flush_to_package()?;
let original = candidate
.package
.get_part(&story.part_name)
.ok_or_else(|| Error::Other("watermark header source is missing".into()))?
.to_vec();
let owned = drawingml_watermark_span(&original)?;
let drawing_id = match &owned {
Some((_, id)) => *id,
None => candidate.identifiers.reserve_drawing_id()?,
};
let box_options = TextBoxOptions {
width: options.width,
height: options.height,
anchor: PictureAnchor {
horizontal_relative_from: DrawingHorizontalRelativeFrom::Page,
horizontal_offset: Length::emu(0),
horizontal_alignment: Some(DrawingHorizontalAlignment::Center),
vertical_relative_from: DrawingVerticalRelativeFrom::Page,
vertical_offset: Length::emu(0),
vertical_alignment: Some(DrawingVerticalAlignment::Center),
wrap: DrawingWrap::None,
distance_top: Length::emu(0),
distance_bottom: Length::emu(0),
distance_left: Length::emu(0),
distance_right: Length::emu(0),
relative_height: 0,
behind_text: true,
},
rotation_degrees: options.rotation_degrees,
text_direction: TextBoxDirection::Horizontal,
fill_color: None,
};
let mut alternate = String::from_utf8(authored_text_box_alternate_content(
drawing_id,
text,
&box_options,
)?)
.map_err(|error| Error::Other(error.to_string()))?;
// Plain text warp keeps the watermark as real DrawingML shape text.
// It also lets importers preserve the shape rotation instead of
// projecting this owner as an unrotated Writer text frame.
alternate = alternate.replace(
"<wps:bodyPr vert=\"horz\"/>",
"<wps:bodyPr vert=\"horz\"><a:prstTxWarp prst=\"textPlain\"><a:avLst/></a:prstTxWarp></wps:bodyPr>",
);
let family = options.font_family.as_deref().unwrap_or("Calibri");
let family = quick_xml::escape::escape(family);
let color = &options.color;
let alpha = (options.opacity * 100_000.0).round() as u32;
let size = font_emu / 6350;
let properties = format!(
"<w:rPr><w:rFonts w:ascii=\"{family}\" w:hAnsi=\"{family}\" w:eastAsia=\"{family}\" w:cs=\"{family}\"/><w:color w:val=\"{color}\"/><w:sz w:val=\"{size}\"/><w:szCs w:val=\"{size}\"/><w14:textFill xmlns:w14=\"http://schemas.microsoft.com/office/word/2010/wordml\"><w14:solidFill><w14:srgbClr w14:val=\"{color}\"><w14:alpha w14:val=\"{alpha}\"/></w14:srgbClr></w14:solidFill></w14:textFill></w:rPr>"
);
if alternate.matches("<w:r>").count() != 2 {
return Err(Error::Other(
"watermark requires both real text representations".into(),
));
}
alternate = alternate.replace("<w:r>", &format!("<w:r>{properties}"));
alternate = alternate.replace(
&format!("name=\"Text Box {drawing_id}\""),
"name=\"RDOCX DrawingML Watermark\"",
);
// Word preserves solid text-fill opacity, while the supported WPS
// importer preserves the equivalent constant-gradient opacity. Keep
// both real text owners under their actual declared capability choices.
let choice_start = alternate
.find("<mc:Choice Requires=\"wps\">")
.ok_or_else(|| Error::Other("watermark capability choice is missing".into()))?;
let choice_end = alternate[choice_start..]
.find("</mc:Choice>")
.map(|offset| choice_start + offset + "</mc:Choice>".len())
.ok_or_else(|| Error::Other("watermark capability choice is incomplete".into()))?;
let original_choice = &alternate[choice_start..choice_end];
let word_choice = original_choice.replacen("Requires=\"wps\"", "Requires=\"wps w16\" xmlns:w16=\"http://schemas.microsoft.com/office/word/2018/wordml\"", 1);
alternate.replace_range(choice_start..choice_end, &word_choice);
let mut fallback = String::from_utf8(
VmlWatermark::Text {
text: text.to_owned(),
width_pt: options.width.to_pt(),
height_pt: options.height.to_pt(),
rotation_degrees: options.rotation_degrees,
color: options.color.clone(),
font_family: options.font_family.clone(),
opacity: options.opacity,
}
.to_pict_xml(),
)
.map_err(|_| Error::Other("watermark textpath source is not UTF-8".into()))?;
// Word's authored type 136 uses this formula-driven straight text path.
// Keep the legacy VML authoring writer unchanged and adapt only this fallback.
let shape_type_start = fallback
.find("<v:shapetype ")
.ok_or_else(|| Error::Other("watermark textpath shape type is missing".into()))?;
let shape_type_end = fallback[shape_type_start..]
.find("</v:shapetype>")
.map(|offset| shape_type_start + offset + "</v:shapetype>".len())
.ok_or_else(|| Error::Other("watermark textpath shape type is incomplete".into()))?;
let shape_type = concat!(
"<v:shapetype id=\"rdocx-watermark-type-text\" coordsize=\"21600,21600\" o:spt=\"136\" adj=\"10800\" path=\"m@7,l@8,m@5,21600l@6,21600e\">",
"<v:formulas><v:f eqn=\"sum #0 0 10800\"/><v:f eqn=\"prod #0 2 1\"/><v:f eqn=\"sum 21600 0 @1\"/><v:f eqn=\"sum 0 0 @2\"/><v:f eqn=\"sum 21600 0 @3\"/><v:f eqn=\"if @0 @3 0\"/><v:f eqn=\"if @0 21600 @1\"/><v:f eqn=\"if @0 0 @2\"/><v:f eqn=\"if @0 @4 21600\"/><v:f eqn=\"mid @5 @6\"/><v:f eqn=\"mid @8 @5\"/><v:f eqn=\"mid @7 @8\"/><v:f eqn=\"mid @6 @7\"/><v:f eqn=\"sum @6 0 @5\"/></v:formulas>",
"<v:path textpathok=\"t\" o:connecttype=\"custom\" o:connectlocs=\"@9,0;@10,10800;@11,21600;@12,10800\" o:connectangles=\"270,180,90,0\"/><v:textpath on=\"t\" fitshape=\"t\"/><v:handles><v:h position=\"#0,bottomRight\" xrange=\"6629,14971\"/></v:handles><o:lock v:ext=\"edit\" text=\"t\" shapetype=\"t\"/></v:shapetype>"
);
fallback.replace_range(shape_type_start..shape_type_end, shape_type);
fallback = fallback
.replace(
"font-size:1pt",
&format!("font-size:{}pt", font_size.to_pt()),
)
.replace("relative:margin", "relative:page")
.replace(
&format!("fillcolor=\"{color}\""),
&format!("fillcolor=\"#{color}\""),
)
.replace(
"id=\"rdocx-watermark\"",
&format!("id=\"rdocx-watermark-{drawing_id}\""),
)
.replace(
"rdocx-watermark-type-text",
&format!("rdocx-watermark-type-text-{drawing_id}"),
);
let fallback_start = alternate
.find("<mc:Fallback>")
.ok_or_else(|| Error::Other("watermark fallback source is missing".into()))?;
let fallback_end = alternate[fallback_start..]
.find("</mc:Fallback>")
.map(|offset| fallback_start + offset + "</mc:Fallback>".len())
.ok_or_else(|| Error::Other("watermark fallback source is incomplete".into()))?;
alternate.replace_range(
fallback_start..fallback_end,
&format!("<mc:Fallback>{fallback}</mc:Fallback>"),
);
let checksum = structural_fingerprint(alternate.as_bytes())?;
alternate = alternate.replace(
"name=\"RDOCX DrawingML Watermark\"",
&format!(
"name=\"RDOCX DrawingML Watermark\" descr=\"rdocx-watermark-text-v1:{checksum}\""
),
);
let alternate = alternate.into_bytes();
// Validate the actual style projection before publishing either representation.
let drawing =
rdocx_oxml::drawing::parse_alternate_content(&alternate, &[]).ok_or_else(|| {
Error::Other("watermark DrawingML representation did not reopen".into())
})?;
if let Some((span, _)) = owned {
let mut updated = original.clone();
updated.splice(span.clone(), alternate);
candidate
.package
.set_part(&story.part_name, updated.clone());
candidate.prune_replaced_watermark_media(
&story.part_name,
&original[span],
&updated,
)?;
} else {
let mut paragraph = CT_P::new();
paragraph.runs.push(CT_R {
properties: None,
content: Vec::new(),
extra_xml: vec![alternate],
extra_xml_positions: vec![0],
alt_drawings: vec![drawing],
});
candidate.append_authored_paragraph_to_story(&story, paragraph)?;
}
candidate.invalidate_layout();
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Set a page-centered DrawingML image in the selected actual header owner.
pub fn set_drawingml_image_watermark_for(
&mut self,
section_index: usize,
hdr_type: HdrFtrType,
image_data: &[u8],
image_filename: &str,
options: ImageWatermarkOptions,
) -> Result<()> {
if options.width.to_emu() <= 0
|| options.height.to_emu() <= 0
|| !options.rotation_degrees.is_finite()
|| !options.opacity.is_finite()
|| !(0.0..=1.0).contains(&options.opacity)
{
return Err(Error::Other(
"watermark image geometry or opacity is invalid".into(),
));
}
if oxml_media::probe(image_data).is_none() {
return Err(Error::Other("watermark image bytes are unsupported".into()));
}
let mut candidate = self.clone_for_staging();
let story =
match candidate.section_story(section_index, HeaderFooterKind::Header, hdr_type)? {
Some(story) => story.story,
None => candidate.create_section_story(
section_index,
HeaderFooterKind::Header,
hdr_type,
)?,
};
candidate.flush_to_package()?;
let original = candidate
.package
.get_part(&story.part_name)
.ok_or_else(|| Error::Other("watermark header source is missing".into()))?
.to_vec();
let owned = drawingml_watermark_span(&original)?;
let id = match &owned {
Some((_, id)) => *id,
None => candidate.identifiers.reserve_drawing_id()?,
};
// Retire only the already qualified API owner before allocating its
// replacement. Otherwise final relationship canonicalization can reuse
// the retired ID after the new owner's fingerprint has been written.
if let Some((span, _)) = &owned {
let mut without_owner = original.clone();
without_owner.drain(span.clone());
candidate
.package
.set_part(&story.part_name, without_owner.clone());
candidate.prune_replaced_watermark_media(
&story.part_name,
&original[span.clone()],
&without_owner,
)?;
}
let relationship = candidate.add_image_relationship_checked(
&story.part_name,
image_data,
image_filename,
)?;
let mut anchor = CT_Anchor::background(
&relationship,
options.width.to_emu(),
options.height.to_emu(),
);
anchor.doc_pr_id = id;
anchor.name = Some("RDOCX DrawingML Watermark".into());
anchor.description = None;
anchor.pos_h_align = Some(rdocx_oxml::drawing::AnchorAlignH::Center);
anchor.pos_v_align = Some(rdocx_oxml::drawing::AnchorAlignV::Center);
let mut writer = Writer::new(Vec::new());
CT_Drawing::anchor(anchor).to_xml(&mut writer)?;
let mut drawing =
String::from_utf8(writer.into_inner()).map_err(|e| Error::Other(e.to_string()))?;
drawing = drawing.replacen("<w:drawing>", "<w:drawing xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\" xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing\" xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\" xmlns:pic=\"http://schemas.openxmlformats.org/drawingml/2006/picture\" xmlns:r=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships\">", 1);
let blip = format!("<a:blip r:embed=\"{relationship}\"/>");
let alpha = (options.opacity * 100_000.0).round() as u32;
if !drawing.contains(&blip) || !drawing.contains("<a:xfrm>") {
return Err(Error::Other(
"watermark image writer shape is unsupported".into(),
));
}
drawing = drawing.replacen(
&blip,
&format!(
"<a:blip r:embed=\"{relationship}\"><a:alphaModFix amt=\"{alpha}\"/></a:blip>"
),
1,
);
let rotation = (options.rotation_degrees.rem_euclid(360.0) * 60_000.0).round() as i32;
drawing = drawing.replacen("<a:xfrm>", &format!("<a:xfrm rot=\"{rotation}\">"), 1);
let checksum = structural_fingerprint(drawing.as_bytes())?;
drawing = drawing.replacen(
"name=\"RDOCX DrawingML Watermark\"",
&format!(
"name=\"RDOCX DrawingML Watermark\" descr=\"rdocx-watermark-image-v1:{checksum}\""
),
1,
);
if let Some((span, _)) = owned {
let mut updated = original.clone();
updated.splice(span.clone(), drawing.into_bytes());
candidate
.package
.set_part(&story.part_name, updated.clone());
} else {
let mut paragraph = CT_P::new();
paragraph.runs.push(CT_R {
properties: None,
content: Vec::new(),
extra_xml: vec![drawing.into_bytes()],
extra_xml_positions: vec![0],
alt_drawings: Vec::new(),
});
candidate.append_authored_paragraph_to_story(&story, paragraph)?;
}
candidate.invalidate_layout();
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Remove only a source-qualified API watermark, keeping its surrounding content.
pub fn remove_drawingml_watermark_for(
&mut self,
section_index: usize,
hdr_type: HdrFtrType,
) -> Result<()> {
let Some(story) = self.section_story(section_index, HeaderFooterKind::Header, hdr_type)?
else {
return Ok(());
};
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let original = candidate
.package
.get_part(&story.story.part_name)
.ok_or_else(|| Error::Other("watermark header source is missing".into()))?
.to_vec();
let Some((span, _)) = drawingml_watermark_span(&original)? else {
return Ok(());
};
let mut updated = original.clone();
updated.drain(span.clone());
candidate
.package
.set_part(&story.story.part_name, updated.clone());
candidate.prune_replaced_watermark_media(
&story.story.part_name,
&original[span],
&updated,
)?;
candidate.invalidate_layout();
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
fn prune_replaced_watermark_media(
&mut self,
part: &str,
removed: &[u8],
updated: &[u8],
) -> Result<()> {
let removed_ids = xml_relationship_ids_in_order(removed)?;
let referenced = xml_relationship_ids_in_order(updated)?
.into_iter()
.collect::<HashSet<_>>();
for id in removed_ids {
if referenced.contains(&id) {
continue;
}
let image_part = self
.package
.get_part_rels(part)
.and_then(|rels| {
rels.items.iter().find(|rel| {
rel.id == id
&& rel.rel_type == rel_types::IMAGE
&& relationship_is_internal(rel)
})
})
.map(|rel| OpcPackage::resolve_rel_target(part, &rel.target));
if let Some(preserved) = self
.identifiers
.preserved_relationship_ids
.get_mut(&relationship_owner_identity(part))
{
preserved.remove(&id);
}
if let Some(image_part) = image_part {
self.identifiers
.preserved_part_names
.remove(&part_name_identity(&image_part));
}
}
self.prune_authored_story_relationships(part, &referenced);
Ok(())
}
/// Set an image watermark in every active header variant.
pub fn set_image_watermark(
&mut self,
image_data: &[u8],
image_filename: &str,
width: Length,
height: Length,
) -> Result<()> {
if width.to_emu() <= 0 || height.to_emu() <= 0 {
return Err(Error::Other(
"watermark image width and height must be positive".to_owned(),
));
}
let mut candidate = self.clone_for_staging();
let image_target = candidate.store_image_part(image_data, image_filename);
let image_part_name =
OpcPackage::resolve_rel_target(&candidate.doc_part_name, &image_target);
candidate.apply_watermark(|package, identifiers, part_name| {
let target = relative_target(part_name, &image_part_name);
let relationship_id = identifiers
.reserve_relationship_id_checked(part_name)
.map_err(|error| {
Error::Other(format!(
"watermark relationship allocation failed for {part_name}: {error}"
))
})?;
package.get_or_create_part_rels(part_name).add_with_id(
&relationship_id,
rel_types::IMAGE,
&target,
);
Ok((
VmlWatermark::Image {
relationship_id: relationship_id.clone(),
width_pt: width.to_pt(),
height_pt: height.to_pt(),
rotation_degrees: 0.0,
opacity: 0.5,
},
Some(relationship_id),
))
})?;
self.commit_staged_mutation(candidate);
Ok(())
}
fn apply_watermark(
&mut self,
mut watermark_for_part: impl FnMut(
&mut OpcPackage,
&mut DocumentIdentifiers,
&str,
) -> Result<(VmlWatermark, Option<String>)>,
) -> Result<()> {
self.ensure_watermark_header_inheritance()?;
let relationships = self.package.get_part_rels(&self.doc_part_name);
let header_ids = self
.header_footer_rel_ids()
.into_iter()
.filter_map(|(relationship_id, is_header)| {
(is_header
&& relationships.is_some_and(|relationships| {
relationships
.get_by_id(&relationship_id)
.is_some_and(|relationship| {
relationship.rel_type == rel_types::HEADER
&& relationship_is_internal(relationship)
})
}))
.then_some(relationship_id)
})
.collect::<Vec<_>>();
for relationship_id in header_ids {
let target = self
.package
.get_part_rels(&self.doc_part_name)
.and_then(|relationships| relationships.get_by_id(&relationship_id))
.filter(|relationship| relationship_is_internal(relationship))
.map(|relationship| relationship.target.clone())
.ok_or_else(|| {
Error::Other(format!(
"active header relationship {relationship_id} is missing"
))
})?;
let part_name = OpcPackage::resolve_rel_target(&self.doc_part_name, &target);
let xml = self
.package
.get_part(&part_name)
.map(<[u8]>::to_vec)
.ok_or_else(|| {
Error::Other(format!("active header part {part_name} is missing"))
})?;
let fully_authored = self.identifiers.authored_story_parts.contains(&part_name);
let (watermark, authored_relationship) =
watermark_for_part(&mut self.package, &mut self.identifiers, &part_name)?;
let updated = replace_authored_watermark(&xml, &watermark)?;
let referenced = xml_relationship_ids_in_order(&updated)?
.into_iter()
.collect::<HashSet<_>>();
self.prune_authored_story_relationships(&part_name, &referenced);
self.package.set_part(&part_name, updated);
if !fully_authored {
let authored = self
.identifiers
.authored_story_relationship_ids
.entry(relationship_owner_identity(&part_name))
.or_default();
authored.retain(|id| referenced.contains(id));
if let Some(id) = authored_relationship {
authored.insert(id);
}
if authored.is_empty() {
self.identifiers
.authored_story_relationship_ids
.remove(&relationship_owner_identity(&part_name));
}
}
}
self.invalidate_layout();
Ok(())
}
fn ensure_watermark_header_inheritance(&mut self) -> Result<()> {
self.section_properties_mut();
let even_enabled = self.even_headers_enabled();
let internal_header_ids = self
.package
.get_part_rels(&self.doc_part_name)
.map(|relationships| {
relationships
.items
.iter()
.filter(|relationship| {
relationship.rel_type == rel_types::HEADER
&& relationship_is_internal(relationship)
})
.map(|relationship| relationship.id.clone())
.collect::<HashSet<_>>()
})
.unwrap_or_default();
let insertions = {
let mut effective = [false; 3];
let mut insertions = Vec::new();
let mut inspect = |location: Option<usize>, section: &CT_SectPr| {
for reference in §ion.header_refs {
if internal_header_ids.contains(&reference.rel_id) {
effective[header_type_index(reference.hdr_ftr_type)] = true;
}
}
for hdr_type in [HdrFtrType::Default, HdrFtrType::First, HdrFtrType::Even] {
let active = match hdr_type {
HdrFtrType::Default => true,
HdrFtrType::First => section.title_pg.unwrap_or(false),
HdrFtrType::Even => even_enabled,
};
let index = header_type_index(hdr_type);
if active && !effective[index] {
insertions.push((location, hdr_type));
effective[index] = true;
}
}
};
for (index, content) in self.document.body.content.iter().enumerate() {
if let BodyContent::Paragraph(paragraph) = content
&& let Some(section) = paragraph
.properties
.as_ref()
.and_then(|properties| properties.sect_pr.as_ref())
{
inspect(Some(index), section);
}
}
if let Some(section) = self.document.body.sect_pr.as_ref() {
inspect(None, section);
}
insertions
};
for (location, hdr_type) in insertions {
let relationship_id = self.create_watermark_header_relationship(hdr_type)?;
let reference = HdrFtrRef {
hdr_ftr_type: hdr_type,
rel_id: relationship_id,
};
match location {
Some(index) => {
let BodyContent::Paragraph(paragraph) = &mut self.document.body.content[index]
else {
unreachable!("recorded section owner changed")
};
paragraph
.properties
.as_mut()
.and_then(|properties| properties.sect_pr.as_mut())
.expect("recorded section disappeared")
.header_refs
.push(reference);
}
None => self
.document
.body
.sect_pr
.as_mut()
.expect("final section disappeared")
.header_refs
.push(reference),
}
}
Ok(())
}
fn create_watermark_header_relationship(&mut self, hdr_type: HdrFtrType) -> Result<String> {
let part_name = self.identifiers.reserve_part_name(
"/word",
&format!(
"headerWatermark{}",
match hdr_type {
HdrFtrType::Default => "Default",
HdrFtrType::First => "First",
HdrFtrType::Even => "Even",
}
),
"xml",
)?;
let empty_header = CT_HdrFtr::new().to_xml_header()?;
self.package.set_part(&part_name, empty_header);
self.package.content_types.add_override(
&part_name,
"application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
);
self.identifiers.register_content_type_override(&part_name);
self.identifiers
.authored_story_parts
.insert(part_name.clone());
let target = relative_target(&self.doc_part_name, &part_name);
let owner = self.doc_part_name.clone();
let id = self
.identifiers
.reserve_relationship_id_checked(&owner)
.map_err(|error| {
Error::Other(format!(
"watermark header relationship allocation failed: {error}"
))
})?;
self.package
.get_or_create_part_rels(&owner)
.add_with_id(&id, rel_types::HEADER, &target);
Ok(id)
}
/// Set the default footer to an inline image.
pub fn set_footer_image(
&mut self,
image_data: &[u8],
image_filename: &str,
width: Length,
height: Length,
) {
let mut candidate = self.clone_for_staging();
candidate.invalidate_layout();
candidate
.set_header_footer_image_part(
image_data,
image_filename,
width,
height,
false,
HdrFtrType::Default,
)
.expect("an in-memory document can install a footer image");
candidate
.reconcile_comment_removal(self)
.expect("whole-story replacement cannot remove partial or malformed comment ownership");
candidate
.clone_for_staging()
.prepare_and_reopen_staged()
.expect("whole-story replacement package preflight failed");
self.commit_staged_mutation(candidate);
}
/// Set a header from raw XML bytes with associated images.
///
/// This is useful for copying complex headers from template documents
/// that contain grouped shapes, VML, or other elements not easily
/// recreated through the high-level API.
///
/// Each entry in `images` is `(rel_id, image_data, image_filename)`:
/// - `rel_id`: the relationship ID referenced in the header XML (e.g. "rId1")
/// - `image_data`: the raw image bytes
/// - `image_filename`: used to derive the part name and content type (e.g. "image5.png")
pub fn set_raw_header_with_images(
&mut self,
header_xml: Vec<u8>,
images: &[(&str, &[u8], &str)],
hdr_type: HdrFtrType,
) {
let mut candidate = self.clone_for_staging();
candidate.invalidate_layout();
candidate
.set_raw_hdr_ftr_with_images(header_xml, images, true, hdr_type)
.expect("raw header package preflight failed");
candidate
.reconcile_comment_removal(self)
.expect("whole-story replacement cannot remove partial or malformed comment ownership");
candidate
.clone_for_staging()
.prepare_and_reopen_staged()
.expect("whole-story replacement package preflight failed");
self.commit_staged_mutation(candidate);
}
/// Set a footer from raw XML bytes with associated images.
pub fn set_raw_footer_with_images(
&mut self,
footer_xml: Vec<u8>,
images: &[(&str, &[u8], &str)],
hdr_type: HdrFtrType,
) {
let mut candidate = self.clone_for_staging();
candidate.invalidate_layout();
candidate
.set_raw_hdr_ftr_with_images(footer_xml, images, false, hdr_type)
.expect("raw footer package preflight failed");
candidate
.reconcile_comment_removal(self)
.expect("whole-story replacement cannot remove partial or malformed comment ownership");
candidate
.clone_for_staging()
.prepare_and_reopen_staged()
.expect("whole-story replacement package preflight failed");
self.commit_staged_mutation(candidate);
}
/// Set the default header to an inline image with a colored background.
///
/// Creates a header part where the paragraph has shading fill set to
/// `bg_color` (hex string, e.g. "000000" for black) and contains the
/// inline image.
pub fn set_header_image_with_background(
&mut self,
image_data: &[u8],
image_filename: &str,
width: Length,
height: Length,
bg_color: &str,
) {
let mut candidate = self.clone_for_staging();
candidate.invalidate_layout();
candidate
.set_header_footer_image_bg_part(
image_data,
image_filename,
width,
height,
Some(bg_color),
true,
HdrFtrType::Default,
)
.expect("an in-memory document can install a header background image");
candidate
.reconcile_comment_removal(self)
.expect("whole-story replacement cannot remove partial or malformed comment ownership");
candidate
.clone_for_staging()
.prepare_and_reopen_staged()
.expect("whole-story replacement package preflight failed");
self.commit_staged_mutation(candidate);
}
/// Set the first-page header to an inline image.
pub fn set_first_page_header_image(
&mut self,
image_data: &[u8],
image_filename: &str,
width: Length,
height: Length,
) {
let mut candidate = self.clone_for_staging();
candidate.invalidate_layout();
candidate.set_different_first_page(true);
candidate
.set_header_footer_image_part(
image_data,
image_filename,
width,
height,
true,
HdrFtrType::First,
)
.expect("an in-memory document can install a first-page header image");
candidate
.reconcile_comment_removal(self)
.expect("whole-story replacement cannot remove partial or malformed comment ownership");
candidate
.clone_for_staging()
.prepare_and_reopen_staged()
.expect("whole-story replacement package preflight failed");
self.commit_staged_mutation(candidate);
}
/// Where a header/footer of this kind lives, and how to declare it.
///
/// All four public entry points differ only in what goes *inside* the part;
/// the surrounding bookkeeping — part name, content type, relationship,
/// section reference — is identical, and lives here.
///
/// Note the fixed `1` in the part name: rdocx manages one header and one
/// footer per [`HdrFtrType`] for the document's single section. Setting a
/// header of the same type again replaces the existing part.
fn hdr_ftr_slot_metadata(
is_header: bool,
hdr_type: HdrFtrType,
) -> (String, &'static str, &'static str) {
let type_suffix = match hdr_type {
HdrFtrType::Default => "",
HdrFtrType::First => "First",
HdrFtrType::Even => "Even",
};
if is_header {
(
format!("/word/header{type_suffix}1.xml"),
rel_types::HEADER,
"application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
)
} else {
(
format!("/word/footer{type_suffix}1.xml"),
rel_types::FOOTER,
"application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
)
}
}
/// Install a header/footer part: store its bytes, declare the content type,
/// relate it to the document, and point the section properties at it.
///
/// Any previous reference of the same [`HdrFtrType`] is replaced.
fn reserve_hdr_ftr_part_name(
&mut self,
is_header: bool,
hdr_type: HdrFtrType,
) -> Result<String> {
self.identifiers.observe_package_graph(&self.package)?;
let (preferred, expected_rel_type, _) = Self::hdr_ftr_slot_metadata(is_header, hdr_type);
let reference = self.document.body.sect_pr.as_ref().and_then(|section| {
let references = if is_header {
§ion.header_refs
} else {
§ion.footer_refs
};
references
.iter()
.find(|reference| reference.hdr_ftr_type == hdr_type)
});
if let Some(part_name) = reference
.and_then(|reference| {
self.package
.get_part_rels(&self.doc_part_name)
.and_then(|relationships| relationships.get_by_id(&reference.rel_id))
})
.filter(|relationship| {
relationship.rel_type == expected_rel_type && relationship_is_internal(relationship)
})
.map(|relationship| {
OpcPackage::resolve_rel_target(&self.doc_part_name, &relationship.target)
})
{
return Ok(part_name);
}
self.identifiers.reserve_preferred_part_name(&preferred)
}
fn install_hdr_ftr_part(
&mut self,
part_name: String,
xml: Vec<u8>,
is_header: bool,
hdr_type: HdrFtrType,
) -> Result<String> {
let (_, rel_type, content_type) = Self::hdr_ftr_slot_metadata(is_header, hdr_type);
// Setting the same header twice must not leave the first relationship
// behind pointing at the same part.
let rel_target = relative_target(&self.doc_part_name, &part_name);
let existing = self
.package
.get_part_rels(&self.doc_part_name)
.and_then(|rels| {
rels.items
.iter()
.find(|relationship| {
relationship.rel_type == rel_type
&& relationship_is_internal(relationship)
&& OpcPackage::resolve_rel_target(
&self.doc_part_name,
&relationship.target,
) == part_name
})
.map(|relationship| relationship.id.clone())
});
let pending_relationship = match existing {
Some(existing) => (existing, false),
None => (
self.identifiers
.reserve_relationship_id_checked(&self.doc_part_name)
.map_err(|error| {
Error::Other(format!(
"header or footer relationship allocation failed: {error}"
))
})?,
true,
),
};
self.package.set_part(&part_name, xml);
self.identifiers
.authored_story_parts
.insert(part_name.clone());
self.package
.content_types
.add_override(&part_name, content_type);
self.identifiers.register_content_type_override(&part_name);
let (rel_id, install_relationship) = pending_relationship;
if install_relationship {
self.package
.get_or_create_part_rels(&self.doc_part_name)
.add_with_id(&rel_id, rel_type, &rel_target);
}
let sect = self.section_properties_mut();
let refs = if is_header {
&mut sect.header_refs
} else {
&mut sect.footer_refs
};
refs.retain(|r| r.hdr_ftr_type != hdr_type);
refs.push(HdrFtrRef {
hdr_ftr_type: hdr_type,
rel_id,
});
Ok(part_name)
}
fn clear_authored_story_relationships(&mut self, part_name: &str) {
self.prune_authored_story_relationships(part_name, &HashSet::new());
}
fn prune_authored_story_relationships(
&mut self,
part_name: &str,
referenced: &HashSet<String>,
) {
let preserved = self
.identifiers
.preserved_relationship_ids
.get(&relationship_owner_identity(part_name))
.cloned()
.unwrap_or_default();
let Some(relationships) = self.package.get_part_rels_mut(part_name) else {
return;
};
let mut removed = Vec::new();
relationships.items.retain(|relationship| {
if preserved.contains(&relationship.id) || referenced.contains(&relationship.id) {
true
} else {
removed.push(relationship.clone());
false
}
});
let relationships_are_empty = relationships.items.is_empty();
if relationships_are_empty {
self.package.remove_part_rels(part_name);
}
self.identifiers.retire_authored_story_relationships(
part_name,
removed.iter().map(|relationship| relationship.id.clone()),
);
let mut image_parts = removed
.iter()
.filter(|relationship| {
relationship.rel_type == rel_types::IMAGE && relationship_is_internal(relationship)
})
.map(|relationship| OpcPackage::resolve_rel_target(part_name, &relationship.target))
.collect::<HashSet<_>>();
image_parts.retain(|candidate| {
!self.package.part_rels.iter().any(|(owner, relationships)| {
relationships.items.iter().any(|relationship| {
relationship_is_internal(relationship)
&& OpcPackage::resolve_rel_target(owner, &relationship.target) == *candidate
})
})
});
for image_part in image_parts {
if self.identifiers.part_is_preserved(&image_part) {
continue;
}
self.package.remove_part(&image_part);
self.package.remove_part_rels(&image_part);
self.package.content_types.remove_override(&image_part);
self.identifiers.retire_authored_part(&image_part);
let Some((_, extension)) = image_part.rsplit_once('.') else {
continue;
};
let default_is_used = self.package.parts.keys().any(|part_name| {
!self.package.content_types.contains_override(part_name)
&& part_name
.rsplit_once('.')
.is_some_and(|(_, candidate)| candidate == extension)
});
if !default_is_used
&& self
.identifiers
.authored_content_type_defaults
.remove(extension)
{
self.package.content_types.remove_default(extension);
self.identifiers.content_type_defaults.remove(extension);
}
}
}
/// Serialize a header/footer body, choosing the right root element.
fn serialize_hdr_ftr(hdr_ftr: &CT_HdrFtr, is_header: bool) -> Result<Vec<u8>> {
let xml = if is_header {
hdr_ftr.to_xml_header()
} else {
hdr_ftr.to_xml_footer()
};
Ok(xml?)
}
fn set_header_footer_part(
&mut self,
text: &str,
is_header: bool,
hdr_type: HdrFtrType,
) -> Result<()> {
let mut hdr_ftr = CT_HdrFtr::new();
let mut p = CT_P::new();
if !text.is_empty() {
p.add_run(text);
}
hdr_ftr.paragraphs.push(p);
let xml = Self::serialize_hdr_ftr(&hdr_ftr, is_header)?;
let part_name = self.reserve_hdr_ftr_part_name(is_header, hdr_type)?;
self.clear_authored_story_relationships(&part_name);
self.install_hdr_ftr_part(part_name, xml, is_header, hdr_type)?;
Ok(())
}
fn set_raw_hdr_ftr_with_images(
&mut self,
xml: Vec<u8>,
images: &[(&str, &[u8], &str)],
is_header: bool,
hdr_type: HdrFtrType,
) -> Result<()> {
let part_name = self.reserve_hdr_ftr_part_name(is_header, hdr_type)?;
let mut requested = HashSet::new();
for &(rel_id, _, _) in images {
if !requested.insert(rel_id) {
return Err(Error::Other(format!(
"duplicate supplied header or footer relationship id {rel_id}"
)));
}
}
self.clear_authored_story_relationships(&part_name);
let mut relationship_remap = HashMap::new();
for &(rel_id, _, _) in images {
let allocated = self
.identifiers
.reserve_requested_relationship_id_checked(&part_name, rel_id)
.map_err(|error| {
Error::Other(format!(
"header or footer image relationship allocation failed: {error}"
))
})?;
if allocated != rel_id {
relationship_remap.insert(rel_id.to_owned(), allocated);
}
}
let xml = remap_xml_relationship_ids(&xml, &relationship_remap)?;
let part_name = self.install_hdr_ftr_part(part_name, xml, is_header, hdr_type)?;
for &(rel_id, image_data, image_filename) in images {
let img_rel_target = self.store_image_part(image_data, image_filename);
let allocated = relationship_remap
.get(rel_id)
.map_or(rel_id, String::as_str);
self.package
.get_or_create_part_rels(&part_name)
.add_with_id(allocated, rel_types::IMAGE, &img_rel_target);
}
Ok(())
}
fn set_header_footer_image_part(
&mut self,
image_data: &[u8],
image_filename: &str,
width: Length,
height: Length,
is_header: bool,
hdr_type: HdrFtrType,
) -> Result<()> {
self.set_header_footer_image_bg_part(
image_data,
image_filename,
width,
height,
None,
is_header,
hdr_type,
)
}
fn set_header_footer_image_bg_part(
&mut self,
image_data: &[u8],
image_filename: &str,
width: Length,
height: Length,
bg_color: Option<&str>,
is_header: bool,
hdr_type: HdrFtrType,
) -> Result<()> {
use rdocx_oxml::properties::CT_Shd;
let part_name = self.reserve_hdr_ftr_part_name(is_header, hdr_type)?;
self.clear_authored_story_relationships(&part_name);
let img_rel_id = self
.add_image_relationship_checked(&part_name, image_data, image_filename)
.map_err(|error| {
Error::Other(format!(
"header or footer image relationship allocation failed: {error}"
))
})?;
let mut inline = CT_Inline::new(&img_rel_id, width.to_emu(), height.to_emu());
inline.doc_pr_id = self.identifiers.reserve_drawing_id()?;
let run = CT_R {
alt_drawings: Vec::new(),
properties: None,
content: vec![RunContent::Drawing(CT_Drawing::inline(inline))],
extra_xml: Vec::new(),
extra_xml_positions: Vec::new(),
};
let mut p = CT_P::new();
p.runs.push(run);
if let Some(color) = bg_color {
p.properties = Some(CT_PPr {
shading: Some(Box::new(CT_Shd {
val: "clear".to_string(),
color: Some("auto".to_string()),
fill: Some(color.to_string()),
..Default::default()
})),
..Default::default()
});
}
let mut hdr_ftr = CT_HdrFtr::new();
hdr_ftr.paragraphs.push(p);
let xml = Self::serialize_hdr_ftr(&hdr_ftr, is_header)?;
self.install_hdr_ftr_part(part_name, xml, is_header, hdr_type)?;
Ok(())
}
fn get_header_footer_text(&self, is_header: bool, hdr_type: HdrFtrType) -> Option<String> {
let sect = self.document.body.sect_pr.as_ref()?;
let refs = if is_header {
§.header_refs
} else {
§.footer_refs
};
let reference = self.first_usable_header_footer_reference(refs, hdr_type, is_header)?;
Some(
self.load_header_footer(&reference.rel_id, is_header)?
.text(),
)
}
// ---- Numbering/Lists ----
/// Ensure a numbering part exists.
///
/// The relationship and content-type override are added by
/// [`Self::flush_to_package`], which knows the resolved part name and will
/// not create a second numbering relationship if one already exists.
fn ensure_numbering(&mut self) -> &mut CT_Numbering {
self.numbering.get_or_insert_with(CT_Numbering::new)
}
fn reserve_numbering_bundle(&mut self) -> Result<()> {
self.identifiers.observe_package_graph(&self.package)?;
if let Some(numbering) = &self.numbering {
self.identifiers.abstract_numbering_ids.extend(
numbering
.abstract_nums
.iter()
.map(|definition| definition.abstract_num_id),
);
self.identifiers
.numbering_instance_ids
.extend(numbering.nums.iter().map(|instance| instance.num_id));
}
let part_name = match self.numbering_part_name.as_deref() {
Some(part_name) => part_name.to_owned(),
None => self
.identifiers
.reserve_preferred_part_name(DEFAULT_NUMBERING_PART)?,
};
let already_linked =
self.package
.get_part_rels(&self.doc_part_name)
.is_some_and(|relationships| {
relationships.items.iter().any(|relationship| {
relationship.rel_type == rel_types::NUMBERING
&& relationship_is_internal(relationship)
&& OpcPackage::resolve_rel_target(
&self.doc_part_name,
&relationship.target,
) == part_name
})
});
let pending_id = if already_linked {
None
} else {
Some(
self.identifiers
.reserve_bundle_relationship_id_checked(
&self.doc_part_name,
rel_types::NUMBERING,
)
.map_err(|error| {
Error::Other(format!("numbering relationship allocation failed: {error}"))
})?,
)
};
self.package
.content_types
.add_override(&part_name, NUMBERING_CONTENT_TYPE);
self.identifiers.register_content_type_override(&part_name);
if let Some(id) = pending_id {
let target = relative_target(&self.doc_part_name, &part_name);
self.package
.get_or_create_part_rels(&self.doc_part_name)
.add_with_id(&id, rel_types::NUMBERING, &target);
}
self.numbering_part_name = Some(part_name);
Ok(())
}
fn reserve_styles_bundle(&mut self) -> Result<()> {
let existing = self.styles_part_name.clone();
let part_name = self
.reserve_document_part_bundle(
existing.as_deref(),
DEFAULT_STYLES_PART,
rel_types::STYLES,
STYLES_CONTENT_TYPE,
)
.map_err(|error| {
Error::Other(format!("styles relationship allocation failed: {error}"))
})?;
self.styles_part_name = Some(part_name);
Ok(())
}
fn reserve_footnotes_bundle(&mut self) -> Result<()> {
let existing = self.footnotes_part_name.clone();
let part_name = self
.reserve_document_part_bundle(
existing.as_deref(),
"/word/footnotes.xml",
rel_types::FOOTNOTES,
"application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
)
.map_err(|error| {
Error::Other(format!("footnotes relationship allocation failed: {error}"))
})?;
self.footnotes_part_name = Some(part_name);
Ok(())
}
/// The direct ASCII document default font, without changing Normal.
pub fn default_font_name(&self) -> Option<&str> {
self.styles
.doc_defaults
.as_ref()?
.rpr
.as_ref()?
.font_ascii
.as_deref()
}
/// Stage a general four-slot default-font update, preserving unrelated defaults.
pub fn set_default_font_name_value(&mut self, name: Option<&str>) -> Result<()> {
if name.is_none()
&& self
.styles
.doc_defaults
.as_ref()
.and_then(|value| value.rpr.as_ref())
.is_none()
{
return Ok(());
}
if let Some(name) = name {
oxml_core::xml::reject_non_xml_characters("default_font_name", name)?;
}
let mut candidate = self.clone_for_staging();
candidate.reserve_styles_bundle()?;
let defaults = candidate
.styles
.doc_defaults
.get_or_insert_with(Default::default);
let properties = defaults.rpr.get_or_insert_with(Default::default);
properties.font_ascii = name.map(str::to_owned);
properties.font_hansi = name.map(str::to_owned);
properties.font_east_asia = name.map(str::to_owned);
properties.font_cs = name.map(str::to_owned);
properties.font_ascii_theme = None;
properties.font_hansi_theme = None;
properties.font_east_asia_theme = None;
properties.font_cs_theme = None;
if candidate.styles == self.styles {
return Ok(());
}
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(())
}
/// The normal-script direct default font size.
pub fn default_font_size(&self) -> Option<Length> {
self.styles
.doc_defaults
.as_ref()?
.rpr
.as_ref()?
.sz
.map(|size| Length::pt(size.to_pt()))
}
/// Set both normal and complex-script half-point defaults after preflight.
pub fn set_default_font_size_value(&mut self, size: Option<Length>) -> Result<()> {
let half_points = size
.map(|size| {
if size.to_emu() < 0 {
return Err(Error::Other(
"default_font_size cannot be negative".to_owned(),
));
}
u32::try_from(size.to_emu() / 6350)
.map(rdocx_oxml::units::HalfPoint)
.map_err(|_| {
Error::Other("default_font_size exceeds half-point range".to_owned())
})
})
.transpose()?;
if size.is_none()
&& self
.styles
.doc_defaults
.as_ref()
.and_then(|value| value.rpr.as_ref())
.is_none()
{
return Ok(());
}
let mut candidate = self.clone_for_staging();
candidate.reserve_styles_bundle()?;
let defaults = candidate
.styles
.doc_defaults
.get_or_insert_with(Default::default);
let properties = defaults.rpr.get_or_insert_with(Default::default);
properties.sz = half_points;
properties.sz_cs = half_points;
if candidate.styles == self.styles {
return Ok(());
}
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(())
}
/// Add a bullet list item at the given indentation level (0-based).
///
/// If no bullet list definition exists yet, one is created automatically.
/// Returns a mutable `Paragraph` for further configuration.
pub fn add_bullet_list_item(&mut self, text: &str, level: u32) -> Paragraph<'_> {
self.try_add_bullet_list_item(text, level)
.expect("the historical infallible list helper requires a valid document and level")
}
/// Checked counterpart. Invalid text, levels and package graphs leave the document unchanged.
pub fn try_add_bullet_list_item(&mut self, text: &str, level: u32) -> Result<Paragraph<'_>> {
if level > 8 {
return Err(Error::Other(
"list level must be between 0 and 8".to_owned(),
));
}
oxml_core::xml::reject_non_xml_characters("text", text)?;
self.validate_numbering_graph()?;
let mut candidate = self.clone_for_staging();
candidate.reserve_numbering_bundle()?;
candidate.invalidate_layout();
// Find or create a bullet list numId
let existing = candidate.numbering.as_ref().and_then(|numbering| {
numbering.nums.iter().find(|n| {
numbering
.get_abstract_num_for(n.num_id)
.map(|a| {
a.levels.first().and_then(|l| l.num_fmt.as_ref())
== Some(&rdocx_oxml::numbering::ST_NumberFormat::Bullet)
})
.unwrap_or(false)
})
});
let num_id = if let Some(existing) = existing {
existing.num_id
} else {
let (abstract_id, num_id) = candidate.identifiers.reserve_numbering_ids()?;
candidate.ensure_numbering().add_list_with_ids(
&[(rdocx_oxml::numbering::ST_NumberFormat::Bullet, Some(1))],
abstract_id,
num_id,
)
};
let mut p = CT_P::new();
if !text.is_empty() {
p.add_run(text);
}
let ppr = CT_PPr {
num_id: Some(num_id),
num_ilvl: Some(level),
..Default::default()
};
p.properties = Some(ppr);
candidate
.document
.body
.content
.push(BodyContent::Paragraph(p));
candidate.validate_numbering_graph()?;
self.commit_staged_mutation(candidate);
match self.document.body.content.last_mut().unwrap() {
BodyContent::Paragraph(p) => Ok(Paragraph { inner: p }),
_ => unreachable!(),
}
}
/// Add a numbered list item at the given indentation level (0-based).
///
/// If no numbered list definition exists yet, one is created automatically.
/// Returns a mutable `Paragraph` for further configuration.
pub fn add_numbered_list_item(&mut self, text: &str, level: u32) -> Paragraph<'_> {
self.try_add_numbered_list_item(text, level)
.expect("the historical infallible list helper requires a valid document and level")
}
/// Checked counterpart. Invalid text, levels and package graphs leave the document unchanged.
pub fn try_add_numbered_list_item(&mut self, text: &str, level: u32) -> Result<Paragraph<'_>> {
if level > 8 {
return Err(Error::Other(
"list level must be between 0 and 8".to_owned(),
));
}
oxml_core::xml::reject_non_xml_characters("text", text)?;
self.validate_numbering_graph()?;
let mut candidate = self.clone_for_staging();
candidate.reserve_numbering_bundle()?;
candidate.invalidate_layout();
// Find or create a numbered list numId
let existing = candidate.numbering.as_ref().and_then(|numbering| {
numbering.nums.iter().find(|n| {
numbering
.get_abstract_num_for(n.num_id)
.map(|a| {
a.levels.first().and_then(|l| l.num_fmt.as_ref())
== Some(&rdocx_oxml::numbering::ST_NumberFormat::Decimal)
})
.unwrap_or(false)
})
});
let num_id = if let Some(existing) = existing {
existing.num_id
} else {
let (abstract_id, num_id) = candidate.identifiers.reserve_numbering_ids()?;
candidate.ensure_numbering().add_list_with_ids(
&[(rdocx_oxml::numbering::ST_NumberFormat::Decimal, Some(1))],
abstract_id,
num_id,
)
};
let mut p = CT_P::new();
if !text.is_empty() {
p.add_run(text);
}
let ppr = CT_PPr {
num_id: Some(num_id),
num_ilvl: Some(level),
..Default::default()
};
p.properties = Some(ppr);
candidate
.document
.body
.content
.push(BodyContent::Paragraph(p));
candidate.validate_numbering_graph()?;
self.commit_staged_mutation(candidate);
match self.document.body.content.last_mut().unwrap() {
BodyContent::Paragraph(p) => Ok(Paragraph { inner: p }),
_ => unreachable!(),
}
}
/// Create a list definition with explicit per-level formats and return
/// its numId.
///
/// Unlike [`Self::add_bullet_list_item`] / [`Self::add_numbered_list_item`],
/// which share one bullet and one numbered definition per document, every
/// call creates a fresh definition — so separate lists restart their
/// numbering, and one definition can mix formats across levels (e.g. a
/// bullet list whose nested level is decimal). Attach paragraphs with
/// [`crate::Paragraph::set_numbering`].
///
/// `levels[i]` configures level `i`; deeper unspecified levels fall back
/// to the standard template rotation for the last specified format's
/// family. An empty slice produces the standard numbered template. Word
/// supports nine levels, so entries after index eight are ignored.
///
/// ```no_run
/// use rdocx::{Document, ListLevel};
///
/// let mut doc = Document::new();
/// let num_id = doc.add_list_definition(&[
/// ListLevel::bullet(),
/// ListLevel::decimal().start(3),
/// ]);
/// doc.add_paragraph("first bullet").set_numbering(num_id, 0);
/// doc.add_paragraph("third decimal").set_numbering(num_id, 1);
/// ```
pub fn add_list_definition(&mut self, levels: &[ListLevel]) -> u32 {
if levels
.iter()
.take(9)
.enumerate()
.any(|(level, value)| validate_list_level(level as u32, value).is_err())
{
return 0;
}
let mut candidate = self.clone_for_staging();
candidate
.reserve_numbering_bundle()
.expect("an in-memory document can allocate a numbering part");
candidate.invalidate_layout();
let formats: Vec<(ST_NumberFormat, Option<u32>)> = levels
.iter()
.take(9)
.map(|level| (level.format.to_st(), level.start))
.collect();
let (abstract_id, num_id) = candidate
.identifiers
.reserve_numbering_ids()
.expect("an in-memory document cannot exhaust numbering identifiers");
let num_id = candidate
.ensure_numbering()
.add_list_with_ids(&formats, abstract_id, num_id);
if let Some(definition) = candidate.numbering.as_mut().and_then(|numbering| {
numbering
.abstract_nums
.iter_mut()
.find(|definition| definition.abstract_num_id == abstract_id)
}) {
for (level, value) in levels.iter().take(9).enumerate() {
let existing = &definition.levels[level];
definition.levels[level] = merge_list_level(existing, value)
.expect("list level was validated before staged allocation");
}
}
if candidate.validate_numbering_graph().is_err() {
return 0;
}
self.commit_staged_mutation(candidate);
num_id
}
/// Inspect all abstract numbering definitions in package order.
pub fn numbering_definitions(&self) -> Vec<NumberingDefinition> {
self.numbering
.as_ref()
.map(|numbering| {
numbering
.abstract_nums
.iter()
.map(|definition| NumberingDefinition {
id: definition.abstract_num_id,
levels: definition
.levels
.iter()
.map(|level| NumberingDefinitionLevel {
level: level.ilvl,
properties: list_level_from_ct(level),
})
.collect(),
paragraph_style_links: definition
.levels
.iter()
.map(|level| level.p_style.clone())
.collect(),
has_unmodeled_properties: !definition.extra_xml.is_empty()
|| !definition.extra_attributes.is_empty()
|| definition
.nsid_raw
.as_ref()
.is_some_and(|(_, raw, prefixes)| {
typed_numbering_leaf_has_unmodeled(raw, prefixes)
})
|| definition.multi_level_type_raw.as_ref().is_some_and(
|(_, raw, prefixes)| {
typed_numbering_leaf_has_unmodeled(raw, prefixes)
},
)
|| definition
.tmpl_raw
.as_ref()
.is_some_and(|(_, raw, prefixes)| {
typed_numbering_leaf_has_unmodeled(raw, prefixes)
})
|| definition.levels.iter().any(|level| {
numbering_level_has_unmodeled(level)
|| matches!(
level.num_fmt.as_ref(),
Some(ST_NumberFormat::Other(_))
)
}),
})
.collect()
})
.unwrap_or_default()
}
/// Inspect one abstract numbering definition by identifier.
pub fn numbering_definition(&self, id: u32) -> Option<NumberingDefinition> {
self.numbering_definitions()
.into_iter()
.find(|definition| definition.id == id)
}
/// Inspect all numbering instances in package order.
pub fn numbering_instances(&self) -> Vec<NumberingInstance> {
self.numbering
.as_ref()
.map(|numbering| {
numbering
.nums
.iter()
.map(|instance| NumberingInstance {
id: instance.num_id,
definition_id: instance.abstract_num_id,
level_overrides: instance
.level_overrides
.iter()
.map(|value| NumberingLevelOverride {
level: value.ilvl,
start: value.start_override,
replacement: value.level.as_ref().map(list_level_from_ct),
paragraph_style_link: value
.level
.as_ref()
.and_then(|level| level.p_style.clone()),
has_unmodeled_properties: numbering_override_has_unmodeled(value),
})
.collect(),
has_unmodeled_properties: !instance.extra_xml.is_empty()
|| !instance.extra_attributes.is_empty()
|| instance.abstract_num_id_raw.as_ref().is_some_and(
|(_, raw, prefixes)| {
typed_numbering_leaf_has_unmodeled(raw, prefixes)
},
)
|| instance
.level_overrides
.iter()
.any(numbering_override_has_unmodeled),
})
.collect()
})
.unwrap_or_default()
}
/// Inspect one numbering instance by identifier.
pub fn numbering_instance(&self, id: u32) -> Option<NumberingInstance> {
self.numbering_instances()
.into_iter()
.find(|instance| instance.id == id)
}
/// Create an abstract numbering definition and return its identifier.
pub fn add_numbering_definition(&mut self, levels: &[ListLevel]) -> Result<u32> {
validate_numbering_level_slice(levels)?;
let authored = levels
.iter()
.enumerate()
.map(|(level, value)| ct_level_from_list(level as u32, value))
.collect::<Result<Vec<_>>>()?;
let mut candidate = self.clone_for_staging();
candidate.reserve_numbering_bundle()?;
let id = candidate.identifiers.reserve_abstract_numbering_id()?;
let mut definition = CT_AbstractNum::new(id);
definition.multi_level_type = Some("hybridMultilevel".to_owned());
definition.levels = authored;
candidate.ensure_numbering().push_abstract_num(definition);
candidate.validate_numbering_graph()?;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(id)
}
/// Replace the modeled levels of an existing abstract definition.
pub fn update_numbering_definition(
&mut self,
id: u32,
levels: &[NumberingDefinitionLevel],
) -> Result<()> {
validate_numbering_definition_levels(levels)?;
let mut candidate = self.clone_for_staging();
let numbering = candidate
.numbering
.as_mut()
.ok_or_else(|| Error::Other(format!("numbering definition {id} does not exist")))?;
let definition = numbering
.abstract_nums
.iter_mut()
.find(|definition| definition.abstract_num_id == id)
.ok_or_else(|| Error::Other(format!("numbering definition {id} does not exist")))?;
if definition.levels.len() != levels.len() {
return Err(Error::Other(format!(
"numbering definition {id} updates must retain its {} levels",
definition.levels.len()
)));
}
definition.levels = definition
.levels
.iter()
.map(|existing| {
let value = levels
.iter()
.find(|value| value.level == existing.ilvl)
.ok_or_else(|| {
Error::Other(format!(
"numbering definition {id} update is missing level {}",
existing.ilvl
))
})?;
merge_list_level(existing, &value.properties)
})
.collect::<Result<Vec<_>>>()?;
candidate.reserve_numbering_bundle()?;
candidate.validate_numbering_graph()?;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(())
}
/// Remove an abstract definition that no instance references.
pub fn remove_numbering_definition(&mut self, id: u32) -> Result<bool> {
let Some(numbering) = self.numbering.as_ref() else {
return Ok(false);
};
if !numbering
.abstract_nums
.iter()
.any(|definition| definition.abstract_num_id == id)
{
return Ok(false);
}
if let Some(instance) = numbering
.nums
.iter()
.find(|instance| instance.abstract_num_id == id)
{
return Err(Error::Other(format!(
"numbering definition {id} is referenced by instance {}",
instance.num_id
)));
}
let mut candidate = self.clone_for_staging();
candidate
.numbering
.as_mut()
.expect("checked numbering part")
.remove_abstract_num(id);
candidate.validate_numbering_graph()?;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(true)
}
/// Create a numbering instance for an existing definition.
pub fn add_numbering_instance(
&mut self,
definition_id: u32,
overrides: &[NumberingLevelOverride],
) -> Result<u32> {
let mut candidate = self.clone_for_staging();
candidate.reserve_numbering_bundle()?;
let numbering = candidate.ensure_numbering();
if !numbering
.abstract_nums
.iter()
.any(|definition| definition.abstract_num_id == definition_id)
{
return Err(Error::Other(format!(
"numbering definition {definition_id} does not exist"
)));
}
let level_overrides = overrides
.iter()
.map(numbering_override_from_public)
.collect::<Result<Vec<_>>>()?;
let id = candidate.identifiers.reserve_numbering_instance_id()?;
candidate.ensure_numbering().push_num(CT_Num {
num_id: id,
abstract_num_id: definition_id,
abstract_num_id_raw: None,
level_overrides,
extra_xml: Vec::new(),
extra_attributes: Vec::new(),
});
candidate.validate_numbering_graph()?;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(id)
}
/// Replace the definition and overrides used by an existing instance.
pub fn update_numbering_instance(
&mut self,
id: u32,
definition_id: u32,
overrides: &[NumberingLevelOverride],
) -> Result<()> {
let mut candidate = self.clone_for_staging();
let numbering = candidate
.numbering
.as_mut()
.ok_or_else(|| Error::Other(format!("numbering instance {id} does not exist")))?;
if !numbering
.abstract_nums
.iter()
.any(|definition| definition.abstract_num_id == definition_id)
{
return Err(Error::Other(format!(
"numbering definition {definition_id} does not exist"
)));
}
let instance = numbering
.nums
.iter_mut()
.find(|instance| instance.num_id == id)
.ok_or_else(|| Error::Other(format!("numbering instance {id} does not exist")))?;
let mut updated = Vec::with_capacity(overrides.len());
for value in overrides {
let existing = instance
.level_overrides
.iter()
.find(|existing| existing.ilvl == value.level);
updated.push(numbering_override_for_update(existing, value)?);
}
instance.abstract_num_id = definition_id;
instance.level_overrides = updated;
candidate.reserve_numbering_bundle()?;
candidate.validate_numbering_graph()?;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(())
}
/// Remove an unreferenced numbering instance.
pub fn remove_numbering_instance(&mut self, id: u32) -> Result<bool> {
let Some(numbering) = self.numbering.as_ref() else {
return Ok(false);
};
if !numbering.nums.iter().any(|instance| instance.num_id == id) {
return Ok(false);
}
if self.document_references_numbering_instance(id)? {
return Err(Error::Other(format!(
"numbering instance {id} is referenced by document content"
)));
}
let mut candidate = self.clone_for_staging();
candidate
.numbering
.as_mut()
.expect("checked numbering part")
.remove_num(id);
candidate.validate_numbering_graph()?;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(true)
}
/// Validate the complete typed numbering graph.
pub fn validate_numbering_graph(&self) -> Result<()> {
let numbering = self.numbering.as_ref();
let Some(numbering) = numbering else {
for style in &self.styles.styles {
if style.style_type == StyleType::Paragraph
&& style
.ppr
.as_ref()
.and_then(|properties| properties.num_id)
.is_some_and(|num_id| num_id != 0)
{
return Err(Error::Other(format!(
"style '{}' references a missing numbering part",
style.style_id
)));
}
}
let live_references = self.document_numbering_references()?;
if let Some((num_id, level)) = live_references.first() {
return Err(Error::Other(format!(
"document content references missing numbering instance {num_id} at level {level}"
)));
}
return Ok(());
};
let mut definition_ids = HashSet::new();
for definition in &numbering.abstract_nums {
if !definition_ids.insert(definition.abstract_num_id) {
return Err(Error::Other(format!(
"duplicate numbering definition id {}",
definition.abstract_num_id
)));
}
let mut levels = HashSet::new();
for level in &definition.levels {
if level.ilvl > 8 || !levels.insert(level.ilvl) {
return Err(Error::Other(format!(
"numbering definition {} has invalid or duplicate level {}",
definition.abstract_num_id, level.ilvl
)));
}
validate_ct_numbering_level(level)?;
if let Some(style_id) = level.p_style.as_deref()
&& self.styles.get_by_id(style_id).is_none()
{
return Err(Error::Other(format!(
"numbering definition {} references missing style '{style_id}'",
definition.abstract_num_id
)));
}
}
}
let mut instance_ids = HashSet::new();
for instance in &numbering.nums {
if instance.num_id == 0 || !instance_ids.insert(instance.num_id) {
return Err(Error::Other(format!(
"invalid or duplicate numbering instance id {}",
instance.num_id
)));
}
let definition = numbering
.abstract_nums
.iter()
.find(|definition| definition.abstract_num_id == instance.abstract_num_id)
.ok_or_else(|| {
Error::Other(format!(
"numbering instance {} references missing definition {}",
instance.num_id, instance.abstract_num_id
))
})?;
let mut override_levels = HashSet::new();
for level_override in &instance.level_overrides {
if level_override.ilvl > 8 || !override_levels.insert(level_override.ilvl) {
return Err(Error::Other(format!(
"numbering instance {} has invalid or duplicate override level {}",
instance.num_id, level_override.ilvl
)));
}
if !definition
.levels
.iter()
.any(|level| level.ilvl == level_override.ilvl)
{
return Err(Error::Other(format!(
"numbering instance {} override level {} is not owned by definition {}",
instance.num_id, level_override.ilvl, instance.abstract_num_id
)));
}
if let Some(level) = &level_override.level {
if level.ilvl != level_override.ilvl {
return Err(Error::Other(format!(
"numbering instance {} override level {} owns replacement level {}",
instance.num_id, level_override.ilvl, level.ilvl
)));
}
validate_ct_numbering_level(level)?;
if let Some(style_id) = level.p_style.as_deref()
&& self.styles.get_by_id(style_id).is_none()
{
return Err(Error::Other(format!(
"numbering instance {} override references missing style '{style_id}'",
instance.num_id
)));
}
}
}
}
let mut style_links = HashMap::<&str, (u32, u32)>::new();
for style in &self.styles.styles {
if style.style_type != StyleType::Paragraph {
continue;
}
let Some(properties) = style.ppr.as_ref() else {
continue;
};
// A `w:ilvl` without a `w:numId` is schema-valid. The style takes
// the instance of its `basedOn` chain, as layout and Word resolve
// it, and is not numbered when the chain has none. It owns no link
// of its own, so only a level that names it back records one.
let (num_id, inherited) = match properties.num_id {
Some(num_id) => (num_id, false),
None if properties.num_ilvl.is_some() => {
match style::resolve_paragraph_properties(Some(&style.style_id), &self.styles)
.num_id
{
Some(num_id) => (num_id, true),
None => continue,
}
}
None => continue,
};
if num_id == 0 {
continue;
}
let level = properties.num_ilvl.unwrap_or(0);
let instance = numbering
.nums
.iter()
.find(|instance| instance.num_id == num_id)
.ok_or_else(|| {
Error::Other(format!(
"style '{}' references missing numbering instance {num_id}",
style.style_id
))
})?;
let definition = numbering
.abstract_nums
.iter()
.find(|definition| definition.abstract_num_id == instance.abstract_num_id)
.expect("numbering instances were validated above");
let base = definition
.levels
.iter()
.find(|candidate| candidate.ilvl == level)
.ok_or_else(|| {
Error::Other(format!(
"style '{}' references numbering instance {num_id} at missing level {level}",
style.style_id
))
})?;
let effective = instance
.level_overrides
.iter()
.find(|value| value.ilvl == level)
.and_then(|value| value.level.as_ref())
.unwrap_or(base);
if effective.p_style.as_deref() != Some(style.style_id.as_str()) {
if inherited {
continue;
}
return Err(Error::Other(format!(
"style '{}' and numbering instance {num_id} level {level} are not reciprocally linked",
style.style_id
)));
}
style_links.insert(style.style_id.as_str(), (num_id, level));
}
for definition in &numbering.abstract_nums {
for level in &definition.levels {
let Some(style_id) = level.p_style.as_deref() else {
continue;
};
let linked = style_links.get(style_id).is_some_and(|(num_id, ilvl)| {
*ilvl == level.ilvl
&& numbering.nums.iter().any(|instance| {
instance.num_id == *num_id
&& instance.abstract_num_id == definition.abstract_num_id
})
});
if !linked {
return Err(Error::Other(format!(
"numbering definition {} level {} and style '{style_id}' are not reciprocally linked",
definition.abstract_num_id, level.ilvl
)));
}
}
}
for instance in &numbering.nums {
for level in instance
.level_overrides
.iter()
.filter_map(|value| value.level.as_ref())
{
let Some(style_id) = level.p_style.as_deref() else {
continue;
};
if style_links.get(style_id) != Some(&(instance.num_id, level.ilvl)) {
return Err(Error::Other(format!(
"numbering instance {} replacement level {} and style '{style_id}' are not reciprocally linked",
instance.num_id, level.ilvl
)));
}
}
}
let live_references = self.document_numbering_references()?;
for (num_id, level) in live_references {
let instance = numbering
.nums
.iter()
.find(|instance| instance.num_id == num_id)
.ok_or_else(|| {
Error::Other(format!(
"document content references missing numbering instance {num_id} at level {level}"
))
})?;
let definition = numbering
.abstract_nums
.iter()
.find(|definition| definition.abstract_num_id == instance.abstract_num_id)
.expect("numbering instances were validated above");
if !definition
.levels
.iter()
.any(|candidate| candidate.ilvl == level)
{
return Err(Error::Other(format!(
"document content references numbering instance {num_id} at missing level {level}"
)));
}
}
Ok(())
}
fn document_numbering_references(&self) -> Result<Vec<(u32, u32)>> {
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let mut references = Vec::new();
for xml in candidate
.word_story_part_names()
.into_iter()
.filter_map(|part_name| {
candidate
.package
.get_part(&part_name)
.filter(|bytes| identifier_xml_is_well_formed(bytes))
})
{
for direct in xml_paragraph_numbering_properties(xml)? {
let style_id = direct.style_id.as_deref().or_else(|| {
candidate
.styles
.get_default(StyleType::Paragraph)
.map(|style| style.style_id.as_str())
});
let mut effective =
style::resolve_paragraph_properties(style_id, &candidate.styles);
candidate.apply_effective_numbering(&mut effective, Some(&direct), style_id);
if let Some(num_id) = effective.num_id
&& num_id != 0
{
references.push((num_id, effective.num_ilvl.unwrap_or(0)));
}
}
}
Ok(references)
}
pub(crate) fn word_story_part_names(&self) -> Vec<String> {
let mut story_parts = vec![self.doc_part_name.clone()];
if let Some(relationships) = self.package.get_part_rels(&self.doc_part_name) {
story_parts.extend(
relationships
.items
.iter()
.filter(|relationship| {
relationship_is_internal(relationship)
&& matches!(
relationship.rel_type.as_str(),
rel_types::HEADER
| rel_types::FOOTER
| rel_types::FOOTNOTES
| rel_types::ENDNOTES
| rel_types::COMMENTS
| rel_types::GLOSSARY_DOCUMENT
)
})
.map(|relationship| {
OpcPackage::resolve_rel_target(&self.doc_part_name, &relationship.target)
}),
);
}
story_parts
}
fn document_references_numbering_instance(&self, id: u32) -> Result<bool> {
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let mut owners = candidate.word_story_part_names();
owners.extend(candidate.styles_part_name.clone());
for xml in owners.into_iter().filter_map(|part_name| {
candidate
.package
.get_part(&part_name)
.filter(|bytes| identifier_xml_is_well_formed(bytes))
}) {
if xml_references_numbering_instance(xml, id)? {
return Ok(true);
}
}
Ok(false)
}
/// Redefine one level (0–8) of an existing list definition, for callers
/// that only learn a deeper level's format when content first reaches it.
///
/// Returns `false` when `num_id` is unknown or `level` is out of range.
pub fn set_list_level(&mut self, num_id: u32, level: u32, spec: ListLevel) -> bool {
if validate_list_level(level, &spec).is_err() {
return false;
}
let mut candidate = self.clone_for_staging();
let updated = candidate.numbering.as_mut().is_some_and(|numbering| {
let Some(abstract_id) = numbering
.nums
.iter()
.find(|instance| instance.num_id == num_id)
.map(|instance| instance.abstract_num_id)
else {
return false;
};
let Some(definition) = numbering
.abstract_nums
.iter_mut()
.find(|definition| definition.abstract_num_id == abstract_id)
else {
return false;
};
let Some(index) = definition
.levels
.iter()
.position(|existing| existing.ilvl == level)
else {
return false;
};
let Ok(updated) = merge_list_level(&definition.levels[index], &spec) else {
return false;
};
definition.levels[index] = updated;
true
});
if updated {
candidate
.reserve_numbering_bundle()
.expect("an in-memory document can allocate a numbering part");
if candidate.validate_numbering_graph().is_err() {
return false;
}
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
}
updated
}
/// Atomically associate a paragraph style with one concrete numbering level.
///
/// Both the style's paragraph properties and the numbering level's
/// paragraph-style link are published together. Existing links must either
/// match this exact tuple or the operation fails without changing the
/// document. A style `w:numPr` holding a `w:ilvl` and no `w:numId` is not
/// a link, and the new link replaces its level. A repeated style ID
/// resolves to its first definition, and the style graph is validated as
/// in [`Document::add_style`].
pub fn link_style_to_numbering(
&mut self,
style_id: &str,
num_id: u32,
level: u32,
) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.validate_numbering_graph()?;
candidate.reserve_styles_bundle()?;
candidate.reserve_numbering_bundle()?;
let style_index = candidate
.styles
.styles
.iter()
.position(|style| style.style_id == style_id)
.ok_or_else(|| Error::Other(format!("style '{style_id}' does not exist")))?;
if candidate.styles.styles[style_index].style_type != StyleType::Paragraph {
return Err(Error::Other(format!(
"style '{style_id}' is not a paragraph style"
)));
}
let numbering = candidate
.numbering
.as_ref()
.ok_or_else(|| Error::Other(format!("numbering instance {num_id} does not exist")))?;
let instance_index = numbering
.nums
.iter()
.position(|instance| instance.num_id == num_id)
.ok_or_else(|| Error::Other(format!("numbering instance {num_id} does not exist")))?;
let definition_id = numbering.nums[instance_index].abstract_num_id;
let definition_index = numbering
.abstract_nums
.iter()
.position(|definition| definition.abstract_num_id == definition_id)
.expect("the numbering graph was validated");
let override_index = numbering.nums[instance_index]
.level_overrides
.iter()
.position(|value| value.ilvl == level && value.level.is_some());
let definition_level_index = numbering.abstract_nums[definition_index]
.levels
.iter()
.position(|value| value.ilvl == level)
.ok_or_else(|| {
Error::Other(format!("numbering instance {num_id} has no level {level}"))
})?;
let style_numbering = candidate.styles.styles[style_index]
.ppr
.as_ref()
.map(|properties| (properties.num_id, properties.num_ilvl))
.unwrap_or((None, None));
// A `w:ilvl` without a `w:numId` owns no link, so the link replaces it.
if style_numbering.0.is_some() && style_numbering != (Some(num_id), Some(level)) {
return Err(Error::Other(format!(
"style '{style_id}' already references a different numbering level"
)));
}
for (candidate_definition_index, definition) in numbering.abstract_nums.iter().enumerate() {
for (candidate_level_index, candidate_level) in definition.levels.iter().enumerate() {
if candidate_level.p_style.as_deref() == Some(style_id)
&& (candidate_definition_index, candidate_level_index)
!= (definition_index, definition_level_index)
{
return Err(Error::Other(format!(
"style '{style_id}' is already linked to another numbering level"
)));
}
}
}
for (candidate_instance_index, instance) in numbering.nums.iter().enumerate() {
for (candidate_override_index, value) in instance.level_overrides.iter().enumerate() {
if value
.level
.as_ref()
.and_then(|level| level.p_style.as_deref())
== Some(style_id)
&& Some(candidate_override_index)
!= override_index.filter(|_| candidate_instance_index == instance_index)
{
return Err(Error::Other(format!(
"style '{style_id}' is already linked to another numbering level"
)));
}
}
}
let target_style = override_index
.and_then(|index| {
numbering.nums[instance_index].level_overrides[index]
.level
.as_ref()
})
.unwrap_or(&numbering.abstract_nums[definition_index].levels[definition_level_index])
.p_style
.as_deref();
if target_style.is_some() && target_style != Some(style_id) {
return Err(Error::Other(format!(
"numbering instance {num_id} level {level} is already linked to another style"
)));
}
let properties = candidate.styles.styles[style_index]
.ppr
.get_or_insert_with(CT_PPr::default);
properties.num_id = Some(num_id);
properties.num_ilvl = Some(level);
let numbering = candidate
.numbering
.as_mut()
.expect("the numbering part was resolved above");
if let Some(index) = override_index {
numbering.nums[instance_index].level_overrides[index]
.level
.as_mut()
.expect("the override was selected only when it owns a level")
.p_style = Some(style_id.to_owned());
} else {
numbering.abstract_nums[definition_index].levels[definition_level_index].p_style =
Some(style_id.to_owned());
}
style::validate_style_graph_change(&self.styles, &candidate.styles)?;
candidate.validate_numbering_graph()?;
candidate.flush_to_package()?;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(())
}
/// Atomically remove one exact paragraph-style numbering association.
///
/// Unlinking removes the style's `w:numId` and `w:ilvl` together, so a
/// style that held only a `w:ilvl` before it was linked does not get it
/// back. A style that holds only a `w:ilvl`, takes its instance from its
/// `basedOn` chain and is named back by the level loses only that name
/// and keeps its level. A style holding only a `w:ilvl` that no level
/// names is not linked, and unlinking it changes nothing. A repeated
/// style ID resolves to its first definition, and the style graph is
/// validated as in [`Document::add_style`].
pub fn unlink_style_from_numbering(
&mut self,
style_id: &str,
num_id: u32,
level: u32,
) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.validate_numbering_graph()?;
let style_index = candidate
.styles
.styles
.iter()
.position(|style| style.style_id == style_id)
.ok_or_else(|| Error::Other(format!("style '{style_id}' does not exist")))?;
if candidate.styles.styles[style_index].style_type != StyleType::Paragraph {
return Err(Error::Other(format!(
"style '{style_id}' is not a paragraph style"
)));
}
let numbering = candidate
.numbering
.as_ref()
.ok_or_else(|| Error::Other(format!("numbering instance {num_id} does not exist")))?;
let instance_index = numbering
.nums
.iter()
.position(|instance| instance.num_id == num_id)
.ok_or_else(|| Error::Other(format!("numbering instance {num_id} does not exist")))?;
let definition_id = numbering.nums[instance_index].abstract_num_id;
let definition_index = numbering
.abstract_nums
.iter()
.position(|definition| definition.abstract_num_id == definition_id)
.expect("the numbering graph was validated");
let override_index = numbering.nums[instance_index]
.level_overrides
.iter()
.position(|value| value.ilvl == level && value.level.is_some());
let definition_level_index = numbering.abstract_nums[definition_index]
.levels
.iter()
.position(|value| value.ilvl == level)
.ok_or_else(|| {
Error::Other(format!("numbering instance {num_id} has no level {level}"))
})?;
let style_numbering = candidate.styles.styles[style_index]
.ppr
.as_ref()
.map(|properties| (properties.num_id, properties.num_ilvl))
.unwrap_or((None, None));
let target_style = override_index
.and_then(|index| {
numbering.nums[instance_index].level_overrides[index]
.level
.as_ref()
})
.unwrap_or(&numbering.abstract_nums[definition_index].levels[definition_level_index])
.p_style
.as_deref();
if style_numbering.0.is_none() && target_style.is_none() {
return Ok(());
}
// A level may name back a style that takes its instance from its
// `basedOn` chain, which is the link recorded for it.
let inherited = style_numbering == (None, Some(level))
&& style::resolve_paragraph_properties(Some(style_id), &candidate.styles).num_id
== Some(num_id);
if (style_numbering != (Some(num_id), Some(level)) && !inherited)
|| target_style != Some(style_id)
{
return Err(Error::Other(format!(
"style '{style_id}' and numbering instance {num_id} level {level} are not linked"
)));
}
candidate.reserve_styles_bundle()?;
candidate.reserve_numbering_bundle()?;
let properties = candidate.styles.styles[style_index]
.ppr
.as_mut()
.expect("the exact style link was validated above");
if !inherited {
properties.num_id = None;
properties.num_ilvl = None;
}
let numbering = candidate
.numbering
.as_mut()
.expect("the numbering part was resolved above");
if let Some(index) = override_index {
numbering.nums[instance_index].level_overrides[index]
.level
.as_mut()
.expect("the override was selected only when it owns a level")
.p_style = None;
} else {
numbering.abstract_nums[definition_index].levels[definition_level_index].p_style = None;
}
style::validate_style_graph_change(&self.styles, &candidate.styles)?;
candidate.validate_numbering_graph()?;
candidate.flush_to_package()?;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(())
}
// ---- Style access ----
/// Get all styles.
pub fn styles(&self) -> Vec<Style<'_>> {
self.styles
.styles
.iter()
.map(|s| Style { inner: s })
.collect()
}
/// Find a style by its ID.
pub fn style(&self, style_id: &str) -> Option<Style<'_>> {
self.styles.get_by_id(style_id).map(|s| Style { inner: s })
}
// ---- Style manipulation ----
/// Add a custom style after validating the change to the style graph.
///
/// A defect the styles part already has, such as a style ID a producer
/// repeated, is retained, and [`Document::validate_style_graph`] still
/// reports it. Only a defect the change introduces rejects it, and an ID
/// that already exists is refused. The style mutations resolve a repeated
/// ID to its first definition, as layout and TOC rebuilding do.
pub fn add_style(&mut self, builder: StyleBuilder) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.reserve_styles_bundle()?;
let (authored, _, _) = builder.build();
if candidate.styles.get_by_id(&authored.style_id).is_some() {
return Err(Error::Other(format!(
"style '{}' already exists",
authored.style_id
)));
}
if authored
.ppr
.as_ref()
.is_some_and(|properties| properties.num_id.is_some() || properties.num_ilvl.is_some())
{
return Err(Error::Other(format!(
"style '{}' numbering must be established with link_style_to_numbering",
authored.style_id
)));
}
let linked_style = authored.linked_style.clone();
let style_id = authored.style_id.clone();
candidate.styles.styles.push(authored);
update_reciprocal_style_link(
&mut candidate.styles,
&style_id,
None,
linked_style.as_deref(),
);
style::validate_style_graph_change(&self.styles, &candidate.styles)?;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(())
}
/// Replace an existing style after validating the change to the style graph.
///
/// A repeated style ID updates its first definition. Validation retains
/// existing defects as in [`Document::add_style`].
pub fn set_style(&mut self, builder: StyleBuilder) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.reserve_styles_bundle()?;
let (mut authored, cleared, removed_regions) = builder.build();
let index = candidate
.styles
.styles
.iter()
.position(|style| style.style_id == authored.style_id)
.ok_or_else(|| Error::Other(format!("style '{}' does not exist", authored.style_id)))?;
let existing = &candidate.styles.styles[index];
if existing.style_type != authored.style_type {
return Err(Error::Other(format!(
"style '{}' has type '{}', not '{}'",
authored.style_id,
existing.style_type.to_str(),
authored.style_type.to_str()
)));
}
let old_link = existing.linked_style.clone();
merge_style_update(existing, &mut authored, cleared, &removed_regions);
let existing_numbering = existing
.ppr
.as_ref()
.map(|properties| (properties.num_id, properties.num_ilvl))
.unwrap_or((None, None));
let authored_numbering = authored
.ppr
.as_ref()
.map(|properties| (properties.num_id, properties.num_ilvl))
.unwrap_or((None, None));
if authored_numbering != existing_numbering {
return Err(Error::Other(format!(
"style '{}' numbering must be changed with link_style_to_numbering or unlink_style_from_numbering",
authored.style_id
)));
}
let style_id = authored.style_id.clone();
let new_link = authored.linked_style.clone();
candidate.styles.styles[index] = authored;
update_reciprocal_style_link(
&mut candidate.styles,
&style_id,
old_link.as_deref(),
new_link.as_deref(),
);
style::validate_style_graph_change(&self.styles, &candidate.styles)?;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(())
}
/// Select the sole default style for one style type.
///
/// A repeated style ID makes its first definition the default. Validation
/// retains existing defects as in [`Document::add_style`].
pub fn set_default_style(&mut self, style_type: StyleType, style_id: &str) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.reserve_styles_bundle()?;
let index = candidate
.styles
.styles
.iter()
.position(|style| style.style_id == style_id)
.ok_or_else(|| Error::Other(format!("style '{style_id}' does not exist")))?;
let target = &candidate.styles.styles[index];
if target.style_type != style_type {
return Err(Error::Other(format!(
"style '{style_id}' has type '{}', not '{}'",
target.style_type.to_str(),
style_type.to_str()
)));
}
for (position, style) in candidate.styles.styles.iter_mut().enumerate() {
if style.style_type == style_type {
style.is_default = position == index;
}
}
style::validate_style_graph_change(&self.styles, &candidate.styles)?;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(())
}
/// Remove an unreferenced style, returning whether it existed.
///
/// Every definition of a repeated style ID is removed, so no later one takes
/// over the ID. Validation retains existing defects as in
/// [`Document::add_style`].
pub fn remove_style(&mut self, style_id: &str) -> Result<bool> {
if self.styles.get_by_id(style_id).is_none() {
return Ok(false);
}
if let Some(owner) = self.styles.styles.iter().find(|style| {
style.style_id != style_id
&& (style.based_on.as_deref() == Some(style_id)
|| style.next_style.as_deref() == Some(style_id)
|| style.linked_style.as_deref() == Some(style_id))
}) {
return Err(Error::Other(format!(
"style '{style_id}' is referenced by style '{}'",
owner.style_id
)));
}
if self.document_references_style(style_id)? {
return Err(Error::Other(format!(
"style '{style_id}' is referenced by document content"
)));
}
if self.numbering.as_ref().is_some_and(|numbering| {
numbering.abstract_nums.iter().any(|definition| {
definition
.levels
.iter()
.any(|level| level.p_style.as_deref() == Some(style_id))
}) || numbering.nums.iter().any(|instance| {
instance.level_overrides.iter().any(|value| {
value
.level
.as_ref()
.is_some_and(|level| level.p_style.as_deref() == Some(style_id))
})
})
}) {
return Err(Error::Other(format!(
"style '{style_id}' is referenced by a numbering level"
)));
}
let mut candidate = self.clone_for_staging();
candidate.reserve_styles_bundle()?;
candidate
.styles
.styles
.retain(|style| style.style_id != style_id);
style::validate_style_graph_change(&self.styles, &candidate.styles)?;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(true)
}
/// Add the Word built-in styles most documents use and python-docx's
/// default template defines, returning how many were added.
///
/// They are `heading 2` to `heading 9`, `Title`, `Subtitle`,
/// `No Spacing`, `Quote`, `List Paragraph`, `caption` and `Table Grid`,
/// none of them a default style, so content that names none of them lays
/// out as before. A style whose ID, or whose name regardless of case, the
/// document already defines is skipped. Most are based on `Normal`, so a
/// document without a `Normal` paragraph style is rejected unchanged.
pub fn add_common_styles(&mut self) -> Result<usize> {
let mut candidate = self.clone_for_staging();
candidate.reserve_styles_bundle()?;
let mut added = 0;
for common in style::common_styles() {
let defined = candidate.styles.styles.iter().any(|style| {
style.style_id == common.style_id
|| style
.name
.as_deref()
.zip(common.name.as_deref())
.is_some_and(|(name, common)| name.eq_ignore_ascii_case(common))
});
if !defined {
candidate.styles.styles.push(common);
added += 1;
}
}
if added == 0 {
return Ok(0);
}
style::validate_style_graph(&candidate.styles)?;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(added)
}
/// Materialize the requested supported built-in table style, without changing defaults.
pub fn ensure_builtin_table_style(&mut self, name: &str) -> Result<bool> {
if !matches!(name, "LightShading-Accent1" | "Light Shading Accent 1") {
return Ok(false);
}
if self.styles.styles.iter().any(|s| {
s.style_id == "LightShading-Accent1"
|| s.name
.as_deref()
.is_some_and(|n| n.eq_ignore_ascii_case(name))
}) {
return Ok(false);
}
// python-docx 1.2.0 default.docx, SHA-256 2094b5bddffe9cf973d61fe03388413804f034160718494a65db7e98da40d35d.
// The existing TableNormal base is retained. A copied absent base is nondefault.
let definitions = CT_Styles::from_xml(br#"<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:style w:type="table" w:styleId="TableNormal">
<w:name w:val="Normal Table"/>
<w:uiPriority w:val="99"/>
<w:semiHidden/>
<w:unhideWhenUsed/>
<w:tblPr>
<w:tblInd w:w="0" w:type="dxa"/>
<w:tblCellMar>
<w:top w:w="0" w:type="dxa"/>
<w:left w:w="108" w:type="dxa"/>
<w:bottom w:w="0" w:type="dxa"/>
<w:right w:w="108" w:type="dxa"/>
</w:tblCellMar>
</w:tblPr>
</w:style><w:style w:type="table" w:styleId="LightShading-Accent1">
<w:name w:val="Light Shading Accent 1"/>
<w:basedOn w:val="TableNormal"/>
<w:uiPriority w:val="60"/>
<w:rsid w:val="00FC693F"/>
<w:pPr>
<w:spacing w:after="0" w:line="240" w:lineRule="auto"/>
</w:pPr>
<w:rPr>
<w:color w:val="365F91" w:themeColor="accent1" w:themeShade="BF"/>
</w:rPr>
<w:tblPr>
<w:tblStyleRowBandSize w:val="1"/>
<w:tblStyleColBandSize w:val="1"/>
<w:tblInd w:w="0" w:type="dxa"/>
<w:tblBorders>
<w:top w:val="single" w:sz="8" w:space="0" w:color="4F81BD" w:themeColor="accent1"/>
<w:bottom w:val="single" w:sz="8" w:space="0" w:color="4F81BD" w:themeColor="accent1"/>
</w:tblBorders>
<w:tblCellMar>
<w:top w:w="0" w:type="dxa"/>
<w:left w:w="108" w:type="dxa"/>
<w:bottom w:w="0" w:type="dxa"/>
<w:right w:w="108" w:type="dxa"/>
</w:tblCellMar>
</w:tblPr>
<w:tblStylePr w:type="firstRow">
<w:pPr>
<w:spacing w:before="0" w:after="0" w:line="240" w:lineRule="auto"/>
</w:pPr>
<w:rPr>
<w:b/>
<w:bCs/>
</w:rPr>
<w:tblPr/>
<w:tcPr>
<w:tcBorders>
<w:top w:val="single" w:sz="8" w:space="0" w:color="4F81BD" w:themeColor="accent1"/>
<w:left w:val="nil"/>
<w:bottom w:val="single" w:sz="8" w:space="0" w:color="4F81BD" w:themeColor="accent1"/>
<w:right w:val="nil"/>
<w:insideH w:val="nil"/>
<w:insideV w:val="nil"/>
</w:tcBorders>
</w:tcPr>
</w:tblStylePr>
<w:tblStylePr w:type="lastRow">
<w:pPr>
<w:spacing w:before="0" w:after="0" w:line="240" w:lineRule="auto"/>
</w:pPr>
<w:rPr>
<w:b/>
<w:bCs/>
</w:rPr>
<w:tblPr/>
<w:tcPr>
<w:tcBorders>
<w:top w:val="single" w:sz="8" w:space="0" w:color="4F81BD" w:themeColor="accent1"/>
<w:left w:val="nil"/>
<w:bottom w:val="single" w:sz="8" w:space="0" w:color="4F81BD" w:themeColor="accent1"/>
<w:right w:val="nil"/>
<w:insideH w:val="nil"/>
<w:insideV w:val="nil"/>
</w:tcBorders>
</w:tcPr>
</w:tblStylePr>
<w:tblStylePr w:type="firstCol">
<w:rPr>
<w:b/>
<w:bCs/>
</w:rPr>
</w:tblStylePr>
<w:tblStylePr w:type="lastCol">
<w:rPr>
<w:b/>
<w:bCs/>
</w:rPr>
</w:tblStylePr>
<w:tblStylePr w:type="band1Vert">
<w:tblPr/>
<w:tcPr>
<w:tcBorders>
<w:left w:val="nil"/>
<w:right w:val="nil"/>
<w:insideH w:val="nil"/>
<w:insideV w:val="nil"/>
</w:tcBorders>
<w:shd w:val="clear" w:color="auto" w:fill="D3DFEE" w:themeFill="accent1" w:themeFillTint="3F"/>
</w:tcPr>
</w:tblStylePr>
<w:tblStylePr w:type="band1Horz">
<w:tblPr/>
<w:tcPr>
<w:tcBorders>
<w:left w:val="nil"/>
<w:right w:val="nil"/>
<w:insideH w:val="nil"/>
<w:insideV w:val="nil"/>
</w:tcBorders>
<w:shd w:val="clear" w:color="auto" w:fill="D3DFEE" w:themeFill="accent1" w:themeFillTint="3F"/>
</w:tcPr>
</w:tblStylePr>
</w:style></w:styles>"#)?;
let mut candidate = self.clone_for_staging();
candidate.reserve_styles_bundle()?;
for definition in definitions.styles {
if let Some(existing) = candidate
.styles
.styles
.iter()
.find(|s| s.style_id == definition.style_id)
{
if existing.style_type != definition.style_type {
return Err(Error::Other(
"built-in table style base has a conflicting type".into(),
));
}
} else {
candidate.styles.styles.push(definition);
}
}
style::validate_style_graph(&candidate.styles)?;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(true)
}
/// Validate all style IDs, defaults, links, next styles, and inheritance chains.
pub fn validate_style_graph(&self) -> Result<()> {
style::validate_style_graph(&self.styles)
}
fn document_references_style(&self, style_id: &str) -> Result<bool> {
if body_references_style(&self.document.body.content, style_id)
|| xml_references_style(&self.document.to_xml()?, style_id)?
|| self
.footnotes
.footnotes
.iter()
.flat_map(|note| ¬e.paragraphs)
.any(|paragraph| paragraph_references_style(paragraph, style_id))
|| self.comments.as_ref().is_some_and(|comments| {
comments
.comments
.iter()
.flat_map(|comment| &comment.paragraphs)
.any(|paragraph| paragraph_references_style(paragraph, style_id))
})
|| self.glossary.as_ref().is_some_and(|glossary| {
glossary
.doc_parts
.iter()
.any(|part| body_references_style(&part.body.content, style_id))
})
{
return Ok(true);
}
let Some(relationships) = self.package.get_part_rels(&self.doc_part_name) else {
return Ok(false);
};
for relationship in &relationships.items {
if relationship_is_internal(relationship)
&& matches!(
relationship.rel_type.as_str(),
rel_types::HEADER
| rel_types::FOOTER
| rel_types::FOOTNOTES
| rel_types::ENDNOTES
| rel_types::COMMENTS
| rel_types::GLOSSARY_DOCUMENT
)
{
let part_name =
OpcPackage::resolve_rel_target(&self.doc_part_name, &relationship.target);
if let Some(xml) = self.package.get_part(&part_name)
&& xml_references_style(xml, style_id)?
{
return Ok(true);
}
}
}
Ok(false)
}
/// Resolve the effective paragraph properties for a given style ID,
/// walking the full inheritance chain (docDefaults → basedOn → ...).
pub fn resolve_paragraph_properties(&self, style_id: Option<&str>) -> CT_PPr {
style::resolve_paragraph_properties(style_id, &self.styles)
}
/// Resolve inherited, numbering-level, and direct properties for a
/// concrete paragraph.
pub fn effective_paragraph_properties(&self, paragraph: &ParagraphRef<'_>) -> CT_PPr {
let direct = paragraph.inner.properties.as_ref();
let style_id = direct
.and_then(|properties| properties.style_id.as_deref())
.or_else(|| {
self.styles
.get_default(StyleType::Paragraph)
.map(|style| style.style_id.as_str())
});
let mut effective = style::resolve_paragraph_properties(style_id, &self.styles);
self.apply_effective_numbering(&mut effective, direct, style_id);
if let Some((num_id, level)) = effective.num_id.zip(effective.num_ilvl)
&& let Some(definition) = self.resolved_numbering_level_definition(num_id, level)
&& let Some(properties) = &definition.ppr
{
effective.merge_from(properties);
}
if let Some(properties) = direct {
effective.merge_from(properties);
}
effective
}
fn apply_effective_numbering(
&self,
effective: &mut CT_PPr,
direct: Option<&CT_PPr>,
style_id: Option<&str>,
) {
effective.num_id = direct
.and_then(|properties| properties.num_id)
.or(effective.num_id);
effective.num_ilvl = direct
.and_then(|properties| properties.num_ilvl)
.or(effective.num_ilvl);
if let Some(num_id) = effective.num_id {
effective.num_ilvl = effective
.num_ilvl
.or_else(|| self.numbering_level_for_style(num_id, style_id));
}
}
/// Resolve the effective run properties for the given paragraph and character styles,
/// walking the full inheritance chain.
pub fn resolve_run_properties(
&self,
para_style_id: Option<&str>,
run_style_id: Option<&str>,
) -> CT_RPr {
style::resolve_run_properties(para_style_id, run_style_id, &self.styles)
}
/// Resolve inherited, paragraph-mark, and direct properties for one
/// concrete body run.
pub fn effective_run_properties(
&self,
paragraph: &ParagraphRef<'_>,
run: &RunRef<'_>,
) -> CT_RPr {
self.effective_run_properties_from_direct(paragraph, run.inner.properties.as_ref())
}
/// Resolve inherited, paragraph-mark, and direct properties for one cached
/// complex-field display segment.
pub fn effective_field_segment_properties(
&self,
paragraph: &ParagraphRef<'_>,
segment: &FieldDisplaySegmentRef<'_>,
) -> CT_RPr {
self.effective_run_properties_from_direct(paragraph, segment.properties())
}
fn effective_run_properties_from_direct(
&self,
paragraph: &ParagraphRef<'_>,
direct: Option<&CT_RPr>,
) -> CT_RPr {
let paragraph_properties = paragraph.inner.properties.as_ref();
let mut effective = style::resolve_run_properties(
paragraph_properties.and_then(|properties| properties.style_id.as_deref()),
direct.and_then(|properties| properties.style_id.as_deref()),
&self.styles,
);
if let Some(properties) =
paragraph_properties.and_then(|properties| properties.rpr.as_ref())
{
effective.merge_from(properties);
}
if let Some(properties) = direct {
effective.merge_from(properties);
}
effective
}
fn resolved_numbering_level_definition(
&self,
num_id: u32,
level: u32,
) -> Option<&rdocx_oxml::numbering::CT_Lvl> {
if num_id == 0 {
return None;
}
self.numbering
.as_ref()?
.get_abstract_num_for(num_id)?
.levels
.iter()
.find(|definition| definition.ilvl == level)
}
fn numbering_level_for_style(&self, num_id: u32, style_id: Option<&str>) -> Option<u32> {
let definition = self.numbering.as_ref()?.get_abstract_num_for(num_id)?;
let mut style_id = style_id?;
for _ in 0..self.styles.styles.len() {
if let Some(level) = definition
.levels
.iter()
.find(|level| level.p_style.as_deref() == Some(style_id))
{
return Some(level.ilvl);
}
style_id = self.styles.get_by_id(style_id)?.based_on.as_deref()?;
}
None
}
fn section_properties_in_order(&self) -> impl Iterator<Item = &CT_SectPr> {
self.document
.body
.content
.iter()
.filter_map(|content| match content {
BodyContent::Paragraph(paragraph) => paragraph
.properties
.as_ref()
.and_then(|properties| properties.sect_pr.as_ref()),
BodyContent::Table(_) | BodyContent::ContentControl(_) | BodyContent::RawXml(_) => {
None
}
})
.chain(self.document.body.sect_pr.iter())
}
// ---- Section/Page setup ----
/// Return the number of section property owners in document order.
pub fn section_count(&self) -> usize {
self.section_properties_in_order().count()
}
/// Iterate over every section in document order.
pub fn sections(&self) -> impl Iterator<Item = SectionRef<'_>> {
let final_ordinal = self.section_count().checked_sub(1);
let has_final_body_owner = self.document.body.sect_pr.is_some();
self.section_properties_in_order()
.enumerate()
.map(move |(ordinal, inner)| SectionRef {
ordinal,
is_final: has_final_body_owner && Some(ordinal) == final_ordinal,
inner,
})
}
/// Get a section by its document-order index.
pub fn section(&self, index: usize) -> Option<SectionRef<'_>> {
let final_ordinal = self.section_count().checked_sub(1);
let has_final_body_owner = self.document.body.sect_pr.is_some();
self.section_properties_in_order()
.nth(index)
.map(|inner| SectionRef {
ordinal: index,
is_final: has_final_body_owner && Some(index) == final_ordinal,
inner,
})
}
/// Report explicit invalid or one-sided section page sizes without normalizing source.
pub fn validate_sections(&self) -> ValidationReport {
let mut findings = Vec::new();
for section in self.sections() {
let properties = section.properties();
if properties.page_width.is_some_and(|size| size.0 <= 0)
|| properties.page_height.is_some_and(|size| size.0 <= 0)
{
findings.push(ValidationFinding {
severity: ValidationSeverity::Error,
message: format!("section {} has nonpositive page size", section.ordinal()),
});
} else if properties.page_width.is_some() != properties.page_height.is_some() {
findings.push(ValidationFinding {
severity: ValidationSeverity::Warning,
message: format!(
"section {} has incomplete page size: width or height is missing",
section.ordinal()
),
});
}
}
ValidationReport { findings }
}
/// Resolve the effective header or footer story for one section variant.
pub fn section_story(
&self,
section_index: usize,
kind: HeaderFooterKind,
hdr_type: HdrFtrType,
) -> Result<Option<SectionStory>> {
if section_index >= self.section_count() {
return Err(Error::Other(format!(
"section index {section_index} is out of range"
)));
}
let mut effective = None;
for (source_section, section) in self
.section_properties_in_order()
.enumerate()
.take(section_index + 1)
{
let references = match kind {
HeaderFooterKind::Header => §ion.header_refs,
HeaderFooterKind::Footer => §ion.footer_refs,
};
if let Some(reference) = self
.first_usable_header_footer_reference(references, hdr_type, kind.is_header())
.cloned()
{
effective = Some((source_section, reference));
}
}
let Some((source_section, reference)) = effective else {
return Ok(None);
};
let part_name = self
.header_footer_part_name(&reference.rel_id, kind.is_header())
.ok_or_else(|| {
Error::Other(format!(
"section story relationship {} is not a valid internal {:?}",
reference.rel_id, kind
))
})?;
Ok(Some(SectionStory {
story: self.story_id_for_header_footer_part(&part_name, kind)?,
source_section,
inherited: source_section != section_index,
}))
}
/// Ensure that one section has an explicit story, creating an empty story
/// when the variant is absent or inherited.
pub fn create_section_story(
&mut self,
section_index: usize,
kind: HeaderFooterKind,
hdr_type: HdrFtrType,
) -> Result<StoryId> {
if let Some(story) = self.section_story(section_index, kind, hdr_type)?
&& !story.is_inherited()
{
let enabled = match hdr_type {
HdrFtrType::Default => true,
HdrFtrType::First => self
.section(section_index)
.is_some_and(|section| section.properties().title_pg.unwrap_or(false)),
HdrFtrType::Even => self.even_and_odd_headers(),
};
if enabled {
return Ok(story.story);
}
let mut candidate = if hdr_type == HdrFtrType::Even {
self.settings_mutation_candidate()?
} else {
self.clone_for_staging()
};
match hdr_type {
HdrFtrType::First => {
candidate
.section_mut(section_index)
.ok_or_else(|| {
Error::Other(format!("section index {section_index} is out of range"))
})?
.inner
.title_pg = Some(true);
}
HdrFtrType::Even => candidate
.settings
.get_or_insert_with(CT_Settings::new)
.set_even_and_odd_headers(true)?,
HdrFtrType::Default => {}
}
let reopened = candidate.prepare_and_reopen_staged()?;
let result = reopened
.section_story(section_index, kind, hdr_type)?
.map(|section_story| section_story.story)
.ok_or_else(|| {
Error::Other("existing section story was not published".to_owned())
})?;
self.commit_staged_mutation(reopened);
return Ok(result);
}
let mut candidate = if hdr_type == HdrFtrType::Even {
self.settings_mutation_candidate()?
} else {
self.clone_for_staging()
};
if hdr_type == HdrFtrType::Even {
candidate
.settings
.get_or_insert_with(CT_Settings::new)
.set_even_and_odd_headers(true)?;
}
candidate.flush_to_package()?;
let story = candidate.install_empty_section_story(section_index, kind, hdr_type)?;
candidate.reconcile_comment_removal(self)?;
let reopened = candidate.prepare_and_reopen_staged()?;
let result = reopened
.section_story(section_index, kind, hdr_type)?
.map(|section_story| section_story.story)
.ok_or_else(|| Error::Other("created section story was not published".to_owned()))?;
debug_assert_eq!(result.part_name(), story.part_name());
self.commit_staged_mutation(reopened);
Ok(result)
}
/// Point one section variant at an existing same-kind story in this document.
pub fn link_section_story(
&mut self,
section_index: usize,
kind: HeaderFooterKind,
hdr_type: HdrFtrType,
story: &StoryId,
) -> Result<StoryId> {
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
candidate.validate_section_story_target(story, kind)?;
let relationship_id = candidate.document_story_relationship(kind, story.part_name())?;
let removed = candidate.set_direct_section_story_reference(
section_index,
kind,
hdr_type,
Some(relationship_id),
)?;
candidate.prune_unreachable_authored_section_stories(&removed)?;
candidate.reconcile_comment_removal(self)?;
let reopened = candidate.prepare_and_reopen_staged()?;
let result = reopened
.section_story(section_index, kind, hdr_type)?
.map(|section_story| section_story.story)
.ok_or_else(|| Error::Other("linked section story was not published".to_owned()))?;
self.commit_staged_mutation(reopened);
Ok(result)
}
/// Remove the direct reference so the same variant inherits from the
/// nearest preceding section, or remains absent when none exists.
pub fn inherit_section_story(
&mut self,
section_index: usize,
kind: HeaderFooterKind,
hdr_type: HdrFtrType,
) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
let removed =
candidate.set_direct_section_story_reference(section_index, kind, hdr_type, None)?;
candidate.prune_unreachable_authored_section_stories(&removed)?;
candidate.reconcile_comment_removal(self)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Clone the effective story into an independent story owned by this section.
pub fn unlink_section_story(
&mut self,
section_index: usize,
kind: HeaderFooterKind,
hdr_type: HdrFtrType,
) -> Result<StoryId> {
let effective = self
.section_story(section_index, kind, hdr_type)?
.ok_or_else(|| Error::Other("cannot unlink an absent section story".to_owned()))?;
self.replace_section_story(section_index, kind, hdr_type, effective.story())
}
/// Replace one variant with an independent copy of an existing same-kind story.
pub fn replace_section_story(
&mut self,
section_index: usize,
kind: HeaderFooterKind,
hdr_type: HdrFtrType,
source: &StoryId,
) -> Result<StoryId> {
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
candidate.validate_section_story_target(source, kind)?;
let part_name = candidate.clone_header_footer_story_part(source.part_name(), kind)?;
let relationship_id = candidate.document_story_relationship(kind, &part_name)?;
let removed = candidate.set_direct_section_story_reference(
section_index,
kind,
hdr_type,
Some(relationship_id),
)?;
candidate.prune_unreachable_authored_section_stories(&removed)?;
candidate.reconcile_comment_removal(self)?;
let reopened = candidate.prepare_and_reopen_staged()?;
let result = reopened
.section_story(section_index, kind, hdr_type)?
.map(|section_story| section_story.story)
.ok_or_else(|| Error::Other("replaced section story was not published".to_owned()))?;
self.commit_staged_mutation(reopened);
Ok(result)
}
/// Replace one section variant with a distinct empty story.
pub fn remove_section_story(
&mut self,
section_index: usize,
kind: HeaderFooterKind,
hdr_type: HdrFtrType,
) -> Result<StoryId> {
let mut candidate = self.clone_for_staging();
candidate.flush_to_package()?;
candidate.install_empty_section_story(section_index, kind, hdr_type)?;
candidate.reconcile_comment_removal(self)?;
let reopened = candidate.prepare_and_reopen_staged()?;
let result = reopened
.section_story(section_index, kind, hdr_type)?
.map(|section_story| section_story.story)
.ok_or_else(|| Error::Other("empty section story was not published".to_owned()))?;
self.commit_staged_mutation(reopened);
Ok(result)
}
fn story_id_for_header_footer_part(
&self,
part_name: &str,
kind: HeaderFooterKind,
) -> Result<StoryId> {
let xml = self
.package
.get_part(part_name)
.ok_or_else(|| Error::Other(format!("section story part {part_name} is missing")))?;
let story_kind = kind.story_kind();
let owner = scan_story_owners(xml, story_kind)?
.into_iter()
.find(|owner| owner.kind == story_kind)
.ok_or_else(|| Error::Other(format!("section story part {part_name} has no root")))?;
Ok(StoryId {
kind: story_kind,
part_name: part_name.to_owned(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
})
}
fn validate_section_story_target(&self, story: &StoryId, kind: HeaderFooterKind) -> Result<()> {
if story.kind != kind.story_kind() {
return Err(StoryError::WrongOwner {
expected: kind.story_kind(),
actual: story.kind,
}
.into());
}
let (_, owner) = self.story_source_and_owner(story)?;
if owner.kind != kind.story_kind() || owner.owner_index != 0 {
return Err(Error::Other(
"section story target must be the physical header or footer root".to_owned(),
));
}
Ok(())
}
fn document_story_relationship(
&mut self,
kind: HeaderFooterKind,
part_name: &str,
) -> Result<String> {
if let Some(id) = self
.package
.get_part_rels(&self.doc_part_name)
.and_then(|relationships| {
relationships.items.iter().find(|relationship| {
relationship.rel_type == kind.relationship_type()
&& relationship_is_internal(relationship)
&& part_name_identity(&OpcPackage::resolve_rel_target(
&self.doc_part_name,
&relationship.target,
)) == part_name_identity(part_name)
})
})
.map(|relationship| relationship.id.clone())
{
return Ok(id);
}
let id = self
.identifiers
.reserve_relationship_id_checked(&self.doc_part_name)?;
let target = relative_target(&self.doc_part_name, part_name);
self.package
.get_or_create_part_rels(&self.doc_part_name)
.add_with_id(&id, kind.relationship_type(), &target);
Ok(id)
}
fn set_direct_section_story_reference(
&mut self,
section_index: usize,
kind: HeaderFooterKind,
hdr_type: HdrFtrType,
relationship_id: Option<String>,
) -> Result<Vec<HdrFtrRef>> {
let section = self.section_mut(section_index).ok_or_else(|| {
Error::Other(format!("section index {section_index} is out of range"))
})?;
let references = match kind {
HeaderFooterKind::Header => &mut section.inner.header_refs,
HeaderFooterKind::Footer => &mut section.inner.footer_refs,
};
let mut removed = Vec::new();
references.retain(|reference| {
if reference.hdr_ftr_type == hdr_type {
removed.push(reference.clone());
false
} else {
true
}
});
if let Some(rel_id) = relationship_id {
references.push(HdrFtrRef {
hdr_ftr_type: hdr_type,
rel_id,
});
if hdr_type == HdrFtrType::First {
section.inner.title_pg = Some(true);
}
}
Ok(removed)
}
fn install_empty_section_story(
&mut self,
section_index: usize,
kind: HeaderFooterKind,
hdr_type: HdrFtrType,
) -> Result<StoryId> {
let xml = Self::serialize_hdr_ftr(&CT_HdrFtr::new(), kind.is_header())?;
let part_name = self.install_header_footer_story_part(xml, None, kind)?;
let relationship_id = self.document_story_relationship(kind, &part_name)?;
let removed = self.set_direct_section_story_reference(
section_index,
kind,
hdr_type,
Some(relationship_id),
)?;
self.prune_unreachable_authored_section_stories(&removed)?;
self.story_id_for_header_footer_part(&part_name, kind)
}
fn clone_header_footer_story_part(
&mut self,
source_part: &str,
kind: HeaderFooterKind,
) -> Result<String> {
let xml = self
.package
.get_part(source_part)
.ok_or_else(|| Error::Other(format!("section story part {source_part} is missing")))?;
if !header_footer_story_has_expected_root(xml, kind.is_header()) {
return Err(Error::Other(format!(
"section story part {source_part} has the wrong root"
)));
}
let mut xml = xml.to_vec();
xml = rewrite_authored_doc_pr_ids(&xml, &mut self.identifiers.drawing_ids)?;
let relationships = self.package.get_part_rels(source_part).cloned();
self.install_header_footer_story_part(
xml,
relationships.as_ref().map(|rels| (source_part, rels)),
kind,
)
}
fn install_header_footer_story_part(
&mut self,
xml: Vec<u8>,
copied_relationships: Option<(&str, &oxml_opc::relationship::Relationships)>,
kind: HeaderFooterKind,
) -> Result<String> {
let part_name = self
.identifiers
.reserve_part_name("/word", kind.part_stem(), "xml")?;
self.package.set_part(&part_name, xml);
self.package
.content_types
.add_override(&part_name, kind.content_type());
self.identifiers.register_content_type_override(&part_name);
self.identifiers
.authored_story_parts
.insert(part_name.clone());
if let Some((source_part, relationships)) = copied_relationships {
let mut relationships = relationships.clone();
for relationship in &mut relationships.items {
if relationship_is_internal(relationship) {
let target = OpcPackage::resolve_rel_target(source_part, &relationship.target);
relationship.target = relative_target(&part_name, &target);
}
}
let owner = relationship_owner_identity(&part_name);
let ids = relationships
.items
.iter()
.map(|relationship| relationship.id.clone())
.collect::<HashSet<_>>();
self.identifiers
.relationship_ids
.insert(owner.clone(), ids.clone());
if !ids.is_empty() {
self.identifiers
.authored_story_relationship_ids
.insert(owner, ids);
}
self.package.set_part_rels(&part_name, relationships);
}
Ok(part_name)
}
/// Get a mutable section by its document-order index.
pub fn section_mut(&mut self, index: usize) -> Option<Section<'_>> {
let count = self.section_count();
let has_final_body_owner = self.document.body.sect_pr.is_some();
if index >= count {
return None;
}
self.invalidate_layout();
let mut remaining = index;
for content in &mut self.document.body.content {
if let BodyContent::Paragraph(paragraph) = content
&& let Some(inner) = paragraph
.properties
.as_mut()
.and_then(|properties| properties.sect_pr.as_mut())
{
if remaining == 0 {
return Some(Section {
ordinal: index,
is_final: has_final_body_owner && index + 1 == count,
inner,
});
}
remaining -= 1;
}
}
self.document.body.sect_pr.as_mut().map(|inner| Section {
ordinal: index,
is_final: has_final_body_owner && index + 1 == count,
inner,
})
}
/// Insert an empty section at a document-order index.
///
/// `index == section_count()` appends a new final section. Larger indices
/// are rejected without changing the document.
pub fn insert_section(&mut self, index: usize) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.insert_section_in_place(index)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
/// Remove a section by document-order index and merge its content.
///
/// A non-final section merges into the following section. Removing the
/// final section promotes the preceding section properties to the final
/// body owner. The sole section cannot be removed.
pub fn remove_section(&mut self, index: usize) -> Result<()> {
let mut candidate = self.clone_for_staging();
let removed_references = candidate.remove_section_in_place(index)?;
candidate.prune_unreachable_authored_section_stories(&removed_references)?;
candidate.reconcile_comment_removal(self)?;
let reopened = candidate.prepare_and_reopen_staged()?;
self.commit_staged_mutation(reopened);
Ok(())
}
fn insert_section_in_place(&mut self, index: usize) -> Result<()> {
let count = self.section_count();
if index > count {
return Err(Error::Other(format!(
"section index {index} is out of bounds for insertion into {count} sections"
)));
}
// Copy resolved geometry only. Relationship owners and other section
// policies belong to the old section and must not be cloned.
let previous = self
.section(index.saturating_sub(1))
.map(|s| s.properties());
let mut inserted = empty_section_properties();
inserted.page_width = Some(previous.and_then(|s| s.page_width).unwrap_or(Twips(12240)));
inserted.page_height = Some(previous.and_then(|s| s.page_height).unwrap_or(Twips(15840)));
inserted.orientation = Some(
previous
.and_then(|s| s.orientation)
.unwrap_or(ST_PageOrientation::Portrait),
);
inserted.margin_top = Some(previous.and_then(|s| s.margin_top).unwrap_or(Twips(1440)));
inserted.margin_right = Some(previous.and_then(|s| s.margin_right).unwrap_or(Twips(1440)));
inserted.margin_bottom = Some(
previous
.and_then(|s| s.margin_bottom)
.unwrap_or(Twips(1440)),
);
inserted.margin_left = Some(previous.and_then(|s| s.margin_left).unwrap_or(Twips(1440)));
inserted.gutter = Some(previous.and_then(|s| s.gutter).unwrap_or(Twips(0)));
inserted.header_distance = Some(
previous
.and_then(|s| s.header_distance)
.unwrap_or(Twips(720)),
);
inserted.footer_distance = Some(
previous
.and_then(|s| s.footer_distance)
.unwrap_or(Twips(720)),
);
self.invalidate_layout();
if count == 0 {
self.document.body.sect_pr = Some(inserted);
return Ok(());
}
if index == count {
let previous_final = self.document.body.sect_pr.take().ok_or_else(|| {
Error::Other("document section ownership changed during insertion".to_owned())
})?;
let mut paragraph = CT_P::new();
paragraph.properties = Some(CT_PPr {
sect_pr: Some(previous_final),
..CT_PPr::default()
});
self.document
.body
.content
.push(BodyContent::Paragraph(paragraph));
self.document.body.sect_pr = Some(inserted);
return Ok(());
}
let insert_at = if index == 0 {
0
} else {
self.paragraph_section_owner_index(index - 1)
.ok_or_else(|| {
Error::Other("document section ownership changed during insertion".to_owned())
})?
+ 1
};
let mut paragraph = CT_P::new();
paragraph.properties = Some(CT_PPr {
sect_pr: Some(inserted),
..CT_PPr::default()
});
self.document
.body
.content
.insert(insert_at, BodyContent::Paragraph(paragraph));
Ok(())
}
fn remove_section_in_place(&mut self, index: usize) -> Result<Vec<HdrFtrRef>> {
let count = self.section_count();
if count <= 1 {
return Err(Error::Other(
"the sole document section cannot be removed".to_owned(),
));
}
if index >= count {
return Err(Error::Other(format!(
"section index {index} is out of bounds for {count} sections"
)));
}
let removed_section = self
.section(index)
.expect("validated section index")
.properties();
let removed = removed_section
.header_refs
.iter()
.chain(&removed_section.footer_refs)
.cloned()
.collect::<Vec<_>>();
self.invalidate_layout();
if index + 1 < count {
let mut effective_headers: [Option<HdrFtrRef>; 3] = [None, None, None];
let mut effective_footers: [Option<HdrFtrRef>; 3] = [None, None, None];
for section in self.section_properties_in_order().take(index + 1) {
for hdr_type in [HdrFtrType::Default, HdrFtrType::First, HdrFtrType::Even] {
let slot = header_type_index(hdr_type);
if let Some(reference) = self.first_usable_header_footer_reference(
§ion.header_refs,
hdr_type,
true,
) {
effective_headers[slot] = Some(reference.clone());
}
if let Some(reference) = self.first_usable_header_footer_reference(
§ion.footer_refs,
hdr_type,
false,
) {
effective_footers[slot] = Some(reference.clone());
}
}
}
let following = self
.section(index + 1)
.expect("every non-final section has a following section");
let mut following_header_overrides = [false; 3];
let mut following_footer_overrides = [false; 3];
for hdr_type in [HdrFtrType::Default, HdrFtrType::First, HdrFtrType::Even] {
let slot = header_type_index(hdr_type);
following_header_overrides[slot] = self
.first_usable_header_footer_reference(
&following.properties().header_refs,
hdr_type,
true,
)
.is_some();
following_footer_overrides[slot] = self
.first_usable_header_footer_reference(
&following.properties().footer_refs,
hdr_type,
false,
)
.is_some();
}
let mut following = self
.section_mut(index + 1)
.expect("every non-final section has a following section");
for (slot, inherited) in effective_headers.into_iter().enumerate() {
if !following_header_overrides[slot]
&& let Some(inherited) = inherited
{
following.properties_mut().header_refs.push(inherited);
}
}
for (slot, inherited) in effective_footers.into_iter().enumerate() {
if !following_footer_overrides[slot]
&& let Some(inherited) = inherited
{
following.properties_mut().footer_refs.push(inherited);
}
}
}
if index + 1 < count || self.document.body.sect_pr.is_none() {
let owner_index = self
.paragraph_section_owner_index(index)
.expect("every non-final section has a paragraph owner");
let remove_empty_owner = {
let BodyContent::Paragraph(paragraph) =
&mut self.document.body.content[owner_index]
else {
unreachable!("recorded section owner is not a paragraph")
};
let properties = paragraph
.properties
.as_mut()
.expect("a section owner has paragraph properties");
properties.sect_pr = None;
if *properties == CT_PPr::default() {
paragraph.properties = None;
}
*paragraph == CT_P::new()
};
if remove_empty_owner {
self.document.body.content.remove(owner_index);
}
} else {
let previous_owner = self
.paragraph_section_owner_index(index - 1)
.expect("a removable final section has a preceding paragraph owner");
let (promoted, remove_empty_owner) = {
let BodyContent::Paragraph(paragraph) =
&mut self.document.body.content[previous_owner]
else {
unreachable!("recorded section owner is not a paragraph")
};
let properties = paragraph
.properties
.as_mut()
.expect("a section owner has paragraph properties");
let promoted = properties
.sect_pr
.take()
.expect("recorded section properties disappeared");
if *properties == CT_PPr::default() {
paragraph.properties = None;
}
(promoted, *paragraph == CT_P::new())
};
if remove_empty_owner {
self.document.body.content.remove(previous_owner);
}
self.document.body.sect_pr = Some(promoted);
}
Ok(removed)
}
fn paragraph_section_owner_index(&self, ordinal: usize) -> Option<usize> {
self.document
.body
.content
.iter()
.enumerate()
.filter(|(_, content)| {
matches!(
content,
BodyContent::Paragraph(CT_P {
properties: Some(properties),
..
}) if properties.sect_pr.is_some()
)
})
.nth(ordinal)
.map(|(index, _)| index)
}
fn prune_unreachable_authored_section_stories(&mut self, removed: &[HdrFtrRef]) -> Result<()> {
let modeled_ids = self.main_story_relationship_ids();
let mut active_ids = modeled_ids
.iter()
.map(String::as_str)
.collect::<HashSet<_>>();
let opaque_ids = self.opaque_relationship_ids_from_serialized_story(&modeled_ids)?;
active_ids.extend(opaque_ids.iter().map(String::as_str));
// Imported producer parts stay in the package. Retire only their qualified
// marker spans when no modeled, inherited or opaque physical use survives.
let retired_targets = removed
.iter()
.map(|reference| {
let relationship = self
.package
.get_part_rels(&self.doc_part_name)
.and_then(|rels| rels.items.iter().find(|rel| rel.id == reference.rel_id))
.ok_or_else(|| {
Error::Other(format!(
"retired section relationship {} is missing",
reference.rel_id
))
})?;
if !relationship_is_internal(relationship)
|| !matches!(
relationship.rel_type.as_str(),
rel_types::HEADER | rel_types::FOOTER
)
{
return Err(Error::Other(
"retired section relationship is not an internal story".into(),
));
}
Ok(OpcPackage::resolve_rel_target(
&self.doc_part_name,
&relationship.target,
))
})
.collect::<Result<HashSet<_>>>()?;
for target in retired_targets {
let identity = part_name_identity(&target);
let mut used = false;
for (owner, relationships) in &self.package.part_rels {
for relationship in &relationships.items {
if !relationship_is_internal(relationship)
|| part_name_identity(&OpcPackage::resolve_rel_target(
owner,
&relationship.target,
)) != identity
{
continue;
}
if part_name_identity(owner) == part_name_identity(&self.doc_part_name) {
used |= active_ids.contains(relationship.id.as_str());
} else {
let xml = self.package.get_part(owner).ok_or_else(|| {
Error::Other(format!(
"cannot prove incoming section-story ownership from missing {owner}"
))
})?;
if !xml_relationship_ids_in_order(xml)?.contains(&relationship.id) {
return Err(Error::Other(format!(
"cannot prove opaque incoming section-story relationship {} from {owner}",
relationship.id
)));
}
used = true;
}
}
}
if !used && let Some(xml) = self.package.get_part(&target) {
let omitted = Self::omit_comment_markers(xml)?;
if omitted != xml {
set_story_source_xml(self, &target, omitted)?;
}
}
}
let removable_ids = removed
.iter()
.map(|reference| reference.rel_id.as_str())
.filter(|id| !active_ids.contains(id))
.collect::<HashSet<_>>();
let candidates = self
.package
.get_part_rels(&self.doc_part_name)
.into_iter()
.flat_map(|relationships| &relationships.items)
.filter(|relationship| {
removable_ids.contains(relationship.id.as_str())
&& relationship_is_internal(relationship)
&& matches!(
relationship.rel_type.as_str(),
rel_types::HEADER | rel_types::FOOTER
)
})
.map(|relationship| {
(
relationship.id.clone(),
OpcPackage::resolve_rel_target(&self.doc_part_name, &relationship.target),
)
})
.filter(|(_, target)| self.identifiers.authored_story_parts.contains(target))
.collect::<Vec<_>>();
for (relationship_id, target) in candidates {
if let Some(relationships) = self.package.get_part_rels_mut(&self.doc_part_name) {
relationships
.items
.retain(|relationship| relationship.id != relationship_id);
}
self.identifiers
.retire_authored_story_relationships(&self.doc_part_name, [relationship_id]);
let target_identity = part_name_identity(&target);
let still_referenced = self.package.part_rels.iter().any(|(owner, relationships)| {
relationships.items.iter().any(|relationship| {
relationship_is_internal(relationship)
&& part_name_identity(&OpcPackage::resolve_rel_target(
owner,
&relationship.target,
)) == target_identity
})
});
if still_referenced {
continue;
}
self.clear_authored_story_relationships(&target);
self.package.remove_part(&target);
self.package.remove_part_rels(&target);
self.package.content_types.remove_override(&target);
self.identifiers.authored_story_parts.remove(&target);
self.identifiers.retire_authored_part(&target);
}
Ok(())
}
/// Get the section properties (page size, margins).
pub fn section_properties(&self) -> Option<&CT_SectPr> {
self.document.body.sect_pr.as_ref()
}
/// Get a mutable reference to section properties, creating defaults if needed.
pub fn section_properties_mut(&mut self) -> &mut CT_SectPr {
self.invalidate_layout();
self.document
.body
.sect_pr
.get_or_insert_with(CT_SectPr::default_letter)
}
/// Set page size without validation, preserving the legacy infallible behavior.
pub fn set_page_size(&mut self, width: Length, height: Length) {
let section = self.section_properties_mut();
section.page_width = Some(width.as_twips());
section.page_height = Some(height.as_twips());
}
/// Set page orientation to landscape (swaps width and height if needed).
pub fn set_landscape(&mut self) {
let ordinal = self.section_count().saturating_sub(1);
Section {
ordinal,
is_final: true,
inner: self.section_properties_mut(),
}
.set_orientation(ST_PageOrientation::Landscape);
}
/// Set page orientation to portrait (swaps width and height if needed).
pub fn set_portrait(&mut self) {
let ordinal = self.section_count().saturating_sub(1);
Section {
ordinal,
is_final: true,
inner: self.section_properties_mut(),
}
.set_orientation(ST_PageOrientation::Portrait);
}
/// Set page margins without validation, preserving legacy behavior.
pub fn set_margins(&mut self, top: Length, right: Length, bottom: Length, left: Length) {
let section = self.section_properties_mut();
section.margin_top = Some(top.as_twips());
section.margin_right = Some(right.as_twips());
section.margin_bottom = Some(bottom.as_twips());
section.margin_left = Some(left.as_twips());
}
/// Set equal-width columns without validation, preserving legacy behavior.
pub fn set_columns(&mut self, num: u32, spacing: Length) {
self.section_properties_mut().columns = Some(CT_Columns {
num: Some(num),
space: Some(spacing.as_twips()),
equal_width: Some(true),
sep: None,
columns: Vec::new(),
});
}
/// Set header and footer distances without validation for compatibility.
pub fn set_header_footer_distance(&mut self, header: Length, footer: Length) {
let section = self.section_properties_mut();
section.header_distance = Some(header.as_twips());
section.footer_distance = Some(footer.as_twips());
}
/// Set the gutter margin without validation, preserving legacy behavior.
pub fn set_gutter(&mut self, gutter: Length) {
self.section_properties_mut().gutter = Some(gutter.as_twips());
}
/// Enable or disable different first page header/footer.
pub fn set_different_first_page(&mut self, val: bool) {
let ordinal = self.section_count().saturating_sub(1);
Section {
ordinal,
is_final: true,
inner: self.section_properties_mut(),
}
.set_different_first_page(val);
}
/// Enable or disable automatic document hyphenation.
pub fn set_auto_hyphenation(&mut self, enabled: bool) -> Result<()> {
let mut candidate = self.clone_for_staging();
let created = candidate.settings_part_name.is_none();
candidate
.identifiers
.observe_package_graph(&candidate.package)?;
let part_name = match &candidate.settings_part_name {
Some(part_name) => part_name.clone(),
None => candidate
.identifiers
.reserve_preferred_part_name(DEFAULT_SETTINGS_PART)?,
};
candidate
.settings
.get_or_insert_with(CT_Settings::new)
.set_automatic_hyphenation(enabled)?;
candidate.invalidate_layout();
candidate.settings_part_name = Some(part_name.clone());
candidate.settings_owned |= created;
candidate
.ensure_part_relationship_checked(
&part_name,
rel_types::SETTINGS,
SETTINGS_CONTENT_TYPE,
)
.map_err(|error| {
Error::Other(format!("settings relationship allocation failed: {error}"))
})?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Return whether distinct even-page header and footer stories are enabled.
pub fn even_and_odd_headers(&self) -> bool {
self.settings
.as_ref()
.is_some_and(CT_Settings::even_and_odd_headers)
}
/// Enable or disable distinct even-page header and footer story selection.
pub fn set_even_and_odd_headers(&mut self, enabled: bool) -> Result<()> {
let mut candidate = self.settings_mutation_candidate()?;
candidate
.settings
.get_or_insert_with(CT_Settings::new)
.set_even_and_odd_headers(enabled)?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Return whether the settings part asks Word to update fields when it
/// opens the document, or `None` when the part does not say.
pub fn update_fields_on_open(&self) -> Option<bool> {
self.settings.as_ref()?.update_fields()
}
/// Document-wide note policy in the relationship-resolved settings part.
pub fn note_properties(&self, family: NoteFamily) -> Option<&CT_NoteProperties> {
let settings = self.settings.as_ref()?;
match family {
NoteFamily::Footnote => settings.footnote_properties(),
NoteFamily::Endnote => settings.endnote_properties(),
}
}
/// Set a checked document-wide note policy and publish the staged package.
pub fn set_note_policy(&mut self, family: NoteFamily, policy: NotePolicy) -> Result<()> {
let mut properties = self.note_properties(family).cloned().unwrap_or_default();
let checked = policy.properties(family)?;
properties.pos = checked.pos;
properties.num_fmt = checked.num_fmt;
properties.num_start = checked.num_start;
properties.num_restart = checked.num_restart;
self.stage_settings_mutation(|settings| match family {
NoteFamily::Footnote => settings.set_footnote_properties(properties),
NoteFamily::Endnote => settings.set_endnote_properties(properties),
})
}
/// Remove an explicit document-wide note policy.
pub fn remove_note_policy(&mut self, family: NoteFamily) -> Result<Option<CT_NoteProperties>> {
let Some(mut properties) = self.note_properties(family).cloned() else {
return Ok(None);
};
if properties.pos.is_none()
&& properties.num_fmt.is_none()
&& properties.num_start.is_none()
&& properties.num_restart.is_none()
{
return Ok(None);
}
let removed = properties.clone();
properties.pos = None;
properties.num_fmt = None;
properties.num_start = None;
properties.num_restart = None;
if properties.special_references.is_empty() && properties.extra_xml.is_empty() {
match family {
NoteFamily::Footnote => {
self.stage_settings_removal(CT_Settings::remove_footnote_properties)?;
}
NoteFamily::Endnote => {
self.stage_settings_removal(CT_Settings::remove_endnote_properties)?;
}
}
} else {
self.stage_settings_mutation(|settings| match family {
NoteFamily::Footnote => settings.set_footnote_properties(properties),
NoteFamily::Endnote => settings.set_endnote_properties(properties),
})?;
}
Ok(Some(removed))
}
/// Ask Word to update fields when it opens the document, stop asking with
/// `Some(false)`, or remove the setting with `None`.
pub fn set_update_fields_on_open(&mut self, value: Option<bool>) -> Result<()> {
if value.is_none() && self.settings.is_none() {
return Ok(());
}
let mut candidate = self.settings_mutation_candidate()?;
candidate
.settings
.get_or_insert_with(CT_Settings::new)
.set_update_fields(value)?;
candidate.prune_empty_owned_settings();
self.commit_staged_mutation(candidate);
Ok(())
}
/// Return document-wide OfficeMath defaults from the settings part.
pub fn math_properties(&self) -> Option<&MathProperties> {
self.settings.as_ref()?.math_properties()
}
/// Set document-wide OfficeMath defaults in the relationship-resolved settings part.
pub fn set_math_properties(&mut self, properties: MathProperties) -> Result<()> {
let mut candidate = self.clone_for_staging();
let created = candidate.settings_part_name.is_none();
candidate
.identifiers
.observe_package_graph(&candidate.package)?;
let part_name = match &candidate.settings_part_name {
Some(part_name) => part_name.clone(),
None => candidate
.identifiers
.reserve_preferred_part_name(DEFAULT_SETTINGS_PART)?,
};
candidate
.settings
.get_or_insert_with(CT_Settings::new)
.set_math_properties(properties)?;
candidate.invalidate_layout();
candidate.settings_part_name = Some(part_name.clone());
candidate.settings_owned |= created;
candidate
.ensure_part_relationship_checked(
&part_name,
rel_types::SETTINGS,
SETTINGS_CONTENT_TYPE,
)
.map_err(|error| {
Error::Other(format!("settings relationship allocation failed: {error}"))
})?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Return a document variable by name.
pub fn document_variable(&self, name: &str) -> Option<&str> {
self.settings.as_ref()?.document_variable(name)
}
/// Set a document variable in the relationship-resolved settings part.
pub fn set_document_variable(&mut self, name: &str, value: &str) -> Result<()> {
let mut candidate = self.settings_mutation_candidate()?;
candidate
.settings
.get_or_insert_with(CT_Settings::new)
.set_document_variable(name.to_owned(), value.to_owned())?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Remove one document variable by name.
pub fn remove_document_variable(&mut self, name: &str) -> Result<Option<String>> {
let Some(settings) = &self.settings else {
return Ok(None);
};
if settings.document_variable(name).is_none() {
return Ok(None);
}
let mut candidate = self.settings_mutation_candidate()?;
let removed = candidate
.settings
.as_mut()
.expect("settings candidate retains its model")
.remove_document_variable(name)?
.map(|variable| variable.value);
candidate.prune_empty_owned_settings();
self.commit_staged_mutation(candidate);
Ok(removed)
}
/// Return typed compatibility settings in package order.
pub fn compatibility_settings(&self) -> &[CompatibilitySetting] {
self.settings
.as_ref()
.map(CT_Settings::compatibility_settings)
.unwrap_or_default()
}
/// Add or replace one compatibility setting selected by name and URI.
pub fn set_compatibility_setting(&mut self, name: &str, uri: &str, value: &str) -> Result<()> {
let mut candidate = self.settings_mutation_candidate()?;
candidate
.settings
.get_or_insert_with(CT_Settings::new)
.set_compatibility_setting(CompatibilitySetting {
name: name.to_owned(),
uri: uri.to_owned(),
value: value.to_owned(),
})?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Remove one compatibility setting selected by name and URI.
pub fn remove_compatibility_setting(
&mut self,
name: &str,
uri: &str,
) -> Result<Option<CompatibilitySetting>> {
let Some(settings) = &self.settings else {
return Ok(None);
};
if !settings
.compatibility_settings()
.iter()
.any(|setting| setting.name == name && setting.uri == uri)
{
return Ok(None);
}
let mut candidate = self.settings_mutation_candidate()?;
let removed = candidate
.settings
.as_mut()
.expect("settings candidate retains its model")
.remove_compatibility_setting(name, uri)?;
candidate.prune_empty_owned_settings();
self.commit_staged_mutation(candidate);
Ok(removed)
}
pub fn default_tab_stop(&self) -> Option<oxml_core::Twips> {
self.settings.as_ref()?.default_tab_stop()
}
pub fn set_default_tab_stop(&mut self, value: oxml_core::Twips) -> Result<()> {
let mut candidate = self.settings_mutation_candidate()?;
candidate
.settings
.get_or_insert_with(CT_Settings::new)
.set_default_tab_stop(value)?;
self.commit_staged_mutation(candidate);
Ok(())
}
pub fn remove_default_tab_stop(&mut self) -> Result<Option<oxml_core::Twips>> {
if self.default_tab_stop().is_none() {
return Ok(None);
}
let mut candidate = self.settings_mutation_candidate()?;
let removed = candidate
.settings
.as_mut()
.expect("settings candidate retains its model")
.remove_default_tab_stop()?;
candidate.prune_empty_owned_settings();
self.commit_staged_mutation(candidate);
Ok(removed)
}
pub fn character_spacing_control(&self) -> Option<CharacterSpacingControl> {
self.settings.as_ref()?.character_spacing_control()
}
pub fn set_character_spacing_control(&mut self, value: CharacterSpacingControl) -> Result<()> {
let mut candidate = self.settings_mutation_candidate()?;
candidate
.settings
.get_or_insert_with(CT_Settings::new)
.set_character_spacing_control(value)?;
self.commit_staged_mutation(candidate);
Ok(())
}
pub fn remove_character_spacing_control(&mut self) -> Result<Option<CharacterSpacingControl>> {
if self.character_spacing_control().is_none() {
return Ok(None);
}
let mut candidate = self.settings_mutation_candidate()?;
let removed = candidate
.settings
.as_mut()
.expect("settings candidate retains its model")
.remove_character_spacing_control()?;
candidate.prune_empty_owned_settings();
self.commit_staged_mutation(candidate);
Ok(removed)
}
pub fn theme_font_language(&self) -> Option<&ThemeFontLanguage> {
self.settings.as_ref()?.theme_font_language()
}
/// Set the default document theme languages.
///
/// The getter and remover already existed. The setter completes the pair,
/// so a caller can author what it is allowed to take away.
pub fn set_theme_font_language(&mut self, value: ThemeFontLanguage) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_theme_font_language(value))
}
pub fn remove_theme_font_language(&mut self) -> Result<Option<ThemeFontLanguage>> {
if self.theme_font_language().is_none() {
return Ok(None);
}
let mut candidate = self.settings_mutation_candidate()?;
let removed = candidate
.settings
.as_mut()
.expect("settings candidate retains its model")
.remove_theme_font_language()?;
candidate.prune_empty_owned_settings();
self.commit_staged_mutation(candidate);
Ok(removed)
}
/// Stage one settings mutation, apply it, and commit it atomically.
///
/// Every typed settings member routes through here, which keeps part
/// allocation, relationship allocation and layout invalidation in one
/// place instead of once per accessor.
fn stage_settings_mutation(
&mut self,
apply: impl FnOnce(&mut CT_Settings) -> std::result::Result<(), rdocx_oxml::OxmlError>,
) -> Result<()> {
let mut candidate = self.settings_mutation_candidate()?;
apply(candidate.settings.get_or_insert_with(CT_Settings::new))?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Remove one typed settings member and prune an empty owned part.
fn stage_settings_removal<T>(
&mut self,
apply: impl FnOnce(&mut CT_Settings) -> std::result::Result<Option<T>, rdocx_oxml::OxmlError>,
) -> Result<Option<T>> {
if self.settings.is_none() {
return Ok(None);
}
let mut candidate = self.settings_mutation_candidate()?;
let removed = apply(
candidate
.settings
.as_mut()
.expect("settings candidate retains its model"),
)?;
candidate.prune_empty_owned_settings();
self.commit_staged_mutation(candidate);
Ok(removed)
}
/// Stage one web settings mutation, applying it to a part created on
/// demand.
fn stage_web_settings_mutation(
&mut self,
apply: impl FnOnce(&mut CT_WebSettings) -> std::result::Result<(), rdocx_oxml::OxmlError>,
) -> Result<()> {
let mut candidate = self.web_settings_mutation_candidate()?;
apply(
candidate
.web_settings
.get_or_insert_with(CT_WebSettings::new),
)?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Remove one typed web settings member and prune an empty owned part.
fn stage_web_settings_removal<T>(
&mut self,
apply: impl FnOnce(&mut CT_WebSettings) -> std::result::Result<Option<T>, rdocx_oxml::OxmlError>,
) -> Result<Option<T>> {
if self.web_settings.is_none() {
return Ok(None);
}
let mut candidate = self.web_settings_mutation_candidate()?;
let removed = apply(
candidate
.web_settings
.as_mut()
.expect("web settings candidate retains its model"),
)?;
candidate.prune_empty_owned_web_settings();
self.commit_staged_mutation(candidate);
Ok(removed)
}
/// Report every supported settings child the typed model could not own.
///
/// A package authored entirely through this facade reports nothing.
pub fn settings_diagnostics(&self) -> &[SettingsDiagnostic] {
self.settings
.as_ref()
.map_or(&[][..], CT_Settings::diagnostics)
}
/// Report every supported web settings child the typed model could not own.
pub fn web_settings_diagnostics(&self) -> &[SettingsDiagnostic] {
self.web_settings
.as_ref()
.map_or(&[][..], CT_WebSettings::diagnostics)
}
/// Report every HTML division the web settings part declares.
///
/// A `w:divId` on a paragraph resolves only when its value appears here.
pub fn web_division_ids(&self) -> Vec<u32> {
self.web_settings
.as_ref()
.map(CT_WebSettings::div_ids)
.unwrap_or_default()
}
/// Record caller-supplied document-protection metadata verbatim.
///
/// Password derivation is an explicit non-goal. Nothing here computes a
/// hash, chooses a salt or picks a spin count.
pub fn set_document_protection(&mut self, value: DocumentProtection) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_document_protection(value))
}
/// Remove document-protection metadata.
pub fn remove_document_protection(&mut self) -> Result<Option<DocumentProtection>> {
self.stage_settings_removal(CT_Settings::remove_document_protection)
}
/// Return the bounded mail-merge configuration from the settings part.
///
/// The name carries the `_settings` suffix because `mail_merge` already
/// names the field-merge operation on this type.
pub fn mail_merge_settings(&self) -> Option<&MailMerge> {
self.settings.as_ref()?.mail_merge()
}
/// Replace the modeled mail-merge members at their schema positions.
pub fn set_mail_merge_settings(&mut self, value: MailMerge) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_mail_merge(value))
}
/// Remove every modeled mail-merge member.
pub fn remove_mail_merge_settings(&mut self) -> Result<Option<MailMerge>> {
self.stage_settings_removal(CT_Settings::remove_mail_merge)
}
/// Return every modeled `w:compat` toggle option in schema order.
pub fn compatibility_options(&self) -> &[(CompatibilityOption, bool)] {
self.settings
.as_ref()
.map_or(&[][..], CT_Settings::compatibility_options)
}
/// Return one `w:compat` toggle option.
pub fn compatibility_option(&self, option: CompatibilityOption) -> Option<bool> {
self.settings.as_ref()?.compatibility_option(option)
}
/// Set one `w:compat` toggle option at its schema position.
pub fn set_compatibility_option(
&mut self,
option: CompatibilityOption,
enabled: bool,
) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_compatibility_option(option, enabled))
}
/// Remove one `w:compat` toggle option.
pub fn remove_compatibility_option(
&mut self,
option: CompatibilityOption,
) -> Result<Option<bool>> {
self.stage_settings_removal(|settings| settings.remove_compatibility_option(option))
}
/// Remove the document automatic-hyphenation toggle.
pub fn remove_auto_hyphenation(&mut self) -> Result<Option<bool>> {
self.stage_settings_removal(CT_Settings::remove_automatic_hyphenation)
}
/// Remove distinct even-page header and footer story selection.
pub fn remove_even_and_odd_headers(&mut self) -> Result<Option<bool>> {
self.stage_settings_removal(CT_Settings::remove_even_and_odd_headers)
}
/// Remove document-wide OfficeMath defaults.
pub fn remove_math_properties(&mut self) -> Result<Option<MathProperties>> {
self.stage_settings_removal(CT_Settings::remove_math_properties)
}
/// Return the document view Word opens with from the settings part.
pub fn view(&self) -> Option<DocumentView> {
self.settings.as_ref()?.view()
}
/// Set the document view Word opens with in the relationship-resolved settings part.
pub fn set_view(&mut self, value: DocumentView) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_view(value))
}
/// Remove the document view Word opens with, pruning an empty part this facade created.
pub fn remove_view(&mut self) -> Result<Option<DocumentView>> {
self.stage_settings_removal(CT_Settings::remove_view)
}
/// Return the magnification preset and percentage from the settings part.
pub fn zoom(&self) -> Option<DocumentZoom> {
self.settings.as_ref()?.zoom()
}
/// Set the magnification preset and percentage in the relationship-resolved settings part.
pub fn set_zoom(&mut self, value: DocumentZoom) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_zoom(value))
}
/// Remove the magnification preset and percentage, pruning an empty part this facade created.
pub fn remove_zoom(&mut self) -> Result<Option<DocumentZoom>> {
self.stage_settings_removal(CT_Settings::remove_zoom)
}
/// Return the `w:removePersonalInformation` toggle from the settings part.
pub fn remove_personal_information(&self) -> Option<bool> {
self.settings.as_ref()?.remove_personal_information()
}
/// Set the `w:removePersonalInformation` toggle in the relationship-resolved settings part.
pub fn set_remove_personal_information(&mut self, value: bool) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_remove_personal_information(value))
}
/// Remove the `w:removePersonalInformation` toggle, pruning an empty part this facade created.
pub fn remove_remove_personal_information(&mut self) -> Result<Option<bool>> {
self.stage_settings_removal(CT_Settings::remove_remove_personal_information)
}
/// Return the `w:removeDateAndTime` toggle from the settings part.
pub fn remove_date_and_time(&self) -> Option<bool> {
self.settings.as_ref()?.remove_date_and_time()
}
/// Set the `w:removeDateAndTime` toggle in the relationship-resolved settings part.
pub fn set_remove_date_and_time(&mut self, value: bool) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_remove_date_and_time(value))
}
/// Remove the `w:removeDateAndTime` toggle, pruning an empty part this facade created.
pub fn remove_remove_date_and_time(&mut self) -> Result<Option<bool>> {
self.stage_settings_removal(CT_Settings::remove_remove_date_and_time)
}
/// Return the `w:mirrorMargins` toggle from the settings part.
pub fn mirror_margins(&self) -> Option<bool> {
self.settings.as_ref()?.mirror_margins()
}
/// Set the `w:mirrorMargins` toggle in the relationship-resolved settings part.
pub fn set_mirror_margins(&mut self, value: bool) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_mirror_margins(value))
}
/// Remove the `w:mirrorMargins` toggle, pruning an empty part this facade created.
pub fn remove_mirror_margins(&mut self) -> Result<Option<bool>> {
self.stage_settings_removal(CT_Settings::remove_mirror_margins)
}
/// Return the `w:gutterAtTop` toggle from the settings part.
pub fn gutter_at_top(&self) -> Option<bool> {
self.settings.as_ref()?.gutter_at_top()
}
/// Set the `w:gutterAtTop` toggle in the relationship-resolved settings part.
pub fn set_gutter_at_top(&mut self, value: bool) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_gutter_at_top(value))
}
/// Remove the `w:gutterAtTop` toggle, pruning an empty part this facade created.
pub fn remove_gutter_at_top(&mut self) -> Result<Option<bool>> {
self.stage_settings_removal(CT_Settings::remove_gutter_at_top)
}
/// Return the spelling and grammar proofing state from the settings part.
pub fn proof_state(&self) -> Option<DocumentProofState> {
self.settings.as_ref()?.proof_state()
}
/// Set the spelling and grammar proofing state in the relationship-resolved settings part.
pub fn set_proof_state(&mut self, value: DocumentProofState) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_proof_state(value))
}
/// Remove the spelling and grammar proofing state, pruning an empty part this facade created.
pub fn remove_proof_state(&mut self) -> Result<Option<DocumentProofState>> {
self.stage_settings_removal(CT_Settings::remove_proof_state)
}
/// Return the `w:linkStyles` toggle from the settings part.
pub fn link_styles(&self) -> Option<bool> {
self.settings.as_ref()?.link_styles()
}
/// Set the `w:linkStyles` toggle in the relationship-resolved settings part.
pub fn set_link_styles(&mut self, value: bool) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_link_styles(value))
}
/// Remove the `w:linkStyles` toggle, pruning an empty part this facade created.
pub fn remove_link_styles(&mut self) -> Result<Option<bool>> {
self.stage_settings_removal(CT_Settings::remove_link_styles)
}
/// Return the `w:trackRevisions` toggle from the settings part.
pub fn track_revisions(&self) -> Option<bool> {
self.settings.as_ref()?.track_revisions()
}
/// Set the `w:trackRevisions` toggle in the relationship-resolved settings part.
pub fn set_track_revisions(&mut self, value: bool) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_track_revisions(value))
}
/// Remove the `w:trackRevisions` toggle, pruning an empty part this facade created.
pub fn remove_track_revisions(&mut self) -> Result<Option<bool>> {
self.stage_settings_removal(CT_Settings::remove_track_revisions)
}
/// Return the `w:doNotTrackMoves` toggle from the settings part.
pub fn do_not_track_moves(&self) -> Option<bool> {
self.settings.as_ref()?.do_not_track_moves()
}
/// Set the `w:doNotTrackMoves` toggle in the relationship-resolved settings part.
pub fn set_do_not_track_moves(&mut self, value: bool) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_do_not_track_moves(value))
}
/// Remove the `w:doNotTrackMoves` toggle, pruning an empty part this facade created.
pub fn remove_do_not_track_moves(&mut self) -> Result<Option<bool>> {
self.stage_settings_removal(CT_Settings::remove_do_not_track_moves)
}
/// Return the `w:doNotTrackFormatting` toggle from the settings part.
pub fn do_not_track_formatting(&self) -> Option<bool> {
self.settings.as_ref()?.do_not_track_formatting()
}
/// Set the `w:doNotTrackFormatting` toggle in the relationship-resolved settings part.
pub fn set_do_not_track_formatting(&mut self, value: bool) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_do_not_track_formatting(value))
}
/// Remove the `w:doNotTrackFormatting` toggle, pruning an empty part this facade created.
pub fn remove_do_not_track_formatting(&mut self) -> Result<Option<bool>> {
self.stage_settings_removal(CT_Settings::remove_do_not_track_formatting)
}
/// Return the maximum count of consecutive hyphenated lines from the settings part.
pub fn consecutive_hyphen_limit(&self) -> Option<i32> {
self.settings.as_ref()?.consecutive_hyphen_limit()
}
/// Set the maximum count of consecutive hyphenated lines in the relationship-resolved settings part.
pub fn set_consecutive_hyphen_limit(&mut self, value: i32) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_consecutive_hyphen_limit(value))
}
/// Remove the maximum count of consecutive hyphenated lines, pruning an empty part this facade created.
pub fn remove_consecutive_hyphen_limit(&mut self) -> Result<Option<i32>> {
self.stage_settings_removal(CT_Settings::remove_consecutive_hyphen_limit)
}
/// Return the hyphenation zone width from the settings part.
pub fn hyphenation_zone(&self) -> Option<oxml_core::Twips> {
self.settings.as_ref()?.hyphenation_zone()
}
/// Set the hyphenation zone width in the relationship-resolved settings part.
pub fn set_hyphenation_zone(&mut self, value: oxml_core::Twips) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_hyphenation_zone(value))
}
/// Remove the hyphenation zone width, pruning an empty part this facade created.
pub fn remove_hyphenation_zone(&mut self) -> Result<Option<oxml_core::Twips>> {
self.stage_settings_removal(CT_Settings::remove_hyphenation_zone)
}
/// Return the `w:doNotHyphenateCaps` toggle from the settings part.
pub fn do_not_hyphenate_caps(&self) -> Option<bool> {
self.settings.as_ref()?.do_not_hyphenate_caps()
}
/// Set the `w:doNotHyphenateCaps` toggle in the relationship-resolved settings part.
pub fn set_do_not_hyphenate_caps(&mut self, value: bool) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_do_not_hyphenate_caps(value))
}
/// Remove the `w:doNotHyphenateCaps` toggle, pruning an empty part this facade created.
pub fn remove_do_not_hyphenate_caps(&mut self) -> Result<Option<bool>> {
self.stage_settings_removal(CT_Settings::remove_do_not_hyphenate_caps)
}
/// Return the default table style identifier from the settings part.
pub fn default_table_style(&self) -> Option<&str> {
self.settings.as_ref()?.default_table_style()
}
/// Set the default table style identifier in the relationship-resolved settings part.
pub fn set_default_table_style(&mut self, value: &str) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_default_table_style(value.to_owned()))
}
/// Remove the default table style identifier, pruning an empty part this facade created.
pub fn remove_default_table_style(&mut self) -> Result<Option<String>> {
self.stage_settings_removal(CT_Settings::remove_default_table_style)
}
/// Return the `w:bookFoldRevPrinting` toggle from the settings part.
pub fn book_fold_rev_printing(&self) -> Option<bool> {
self.settings.as_ref()?.book_fold_rev_printing()
}
/// Set the `w:bookFoldRevPrinting` toggle in the relationship-resolved settings part.
pub fn set_book_fold_rev_printing(&mut self, value: bool) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_book_fold_rev_printing(value))
}
/// Remove the `w:bookFoldRevPrinting` toggle, pruning an empty part this facade created.
pub fn remove_book_fold_rev_printing(&mut self) -> Result<Option<bool>> {
self.stage_settings_removal(CT_Settings::remove_book_fold_rev_printing)
}
/// Return the `w:bookFoldPrinting` toggle from the settings part.
pub fn book_fold_printing(&self) -> Option<bool> {
self.settings.as_ref()?.book_fold_printing()
}
/// Set the `w:bookFoldPrinting` toggle in the relationship-resolved settings part.
pub fn set_book_fold_printing(&mut self, value: bool) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_book_fold_printing(value))
}
/// Remove the `w:bookFoldPrinting` toggle, pruning an empty part this facade created.
pub fn remove_book_fold_printing(&mut self) -> Result<Option<bool>> {
self.stage_settings_removal(CT_Settings::remove_book_fold_printing)
}
/// Return the signed sheet count per book-fold booklet from the settings part.
pub fn book_fold_printing_sheets(&self) -> Option<i32> {
self.settings.as_ref()?.book_fold_printing_sheets()
}
/// Set the signed sheet count per book-fold booklet in the relationship-resolved settings part.
pub fn set_book_fold_printing_sheets(&mut self, value: i32) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_book_fold_printing_sheets(value))
}
/// Remove the signed sheet count per book-fold booklet, pruning an empty part this facade created.
pub fn remove_book_fold_printing_sheets(&mut self) -> Result<Option<i32>> {
self.stage_settings_removal(CT_Settings::remove_book_fold_printing_sheets)
}
/// Return the decimal separator Word uses in fields from the settings part.
pub fn decimal_symbol(&self) -> Option<&str> {
self.settings.as_ref()?.decimal_symbol()
}
/// Set the decimal separator Word uses in fields in the relationship-resolved settings part.
pub fn set_decimal_symbol(&mut self, value: &str) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_decimal_symbol(value.to_owned()))
}
/// Remove the decimal separator Word uses in fields, pruning an empty part this facade created.
pub fn remove_decimal_symbol(&mut self) -> Result<Option<String>> {
self.stage_settings_removal(CT_Settings::remove_decimal_symbol)
}
/// Return the list separator Word uses in fields from the settings part.
pub fn list_separator(&self) -> Option<&str> {
self.settings.as_ref()?.list_separator()
}
/// Set the list separator Word uses in fields in the relationship-resolved settings part.
pub fn set_list_separator(&mut self, value: &str) -> Result<()> {
self.stage_settings_mutation(|settings| settings.set_list_separator(value.to_owned()))
}
/// Remove the list separator Word uses in fields, pruning an empty part this facade created.
pub fn remove_list_separator(&mut self) -> Result<Option<String>> {
self.stage_settings_removal(CT_Settings::remove_list_separator)
}
/// Return the character encoding Word writes web pages with from the web settings part.
pub fn web_encoding(&self) -> Option<&str> {
self.web_settings.as_ref()?.encoding()
}
/// Set the character encoding Word writes web pages with in the relationship-resolved web settings part.
pub fn set_web_encoding(&mut self, value: &str) -> Result<()> {
self.stage_web_settings_mutation(|settings| settings.set_encoding(value.to_owned()))
}
/// Remove the character encoding Word writes web pages with, pruning an empty part this facade created.
pub fn remove_web_encoding(&mut self) -> Result<Option<String>> {
self.stage_web_settings_removal(CT_WebSettings::remove_encoding)
}
/// Return the `w:optimizeForBrowser` toggle from the web settings part.
pub fn web_optimize_for_browser(&self) -> Option<bool> {
self.web_settings.as_ref()?.optimize_for_browser()
}
/// Set the `w:optimizeForBrowser` toggle in the relationship-resolved web settings part.
pub fn set_web_optimize_for_browser(&mut self, value: bool) -> Result<()> {
self.stage_web_settings_mutation(|settings| settings.set_optimize_for_browser(value))
}
/// Remove the `w:optimizeForBrowser` toggle, pruning an empty part this facade created.
pub fn remove_web_optimize_for_browser(&mut self) -> Result<Option<bool>> {
self.stage_web_settings_removal(CT_WebSettings::remove_optimize_for_browser)
}
/// Return the `w:relyOnVML` toggle from the web settings part.
pub fn web_rely_on_vml(&self) -> Option<bool> {
self.web_settings.as_ref()?.rely_on_vml()
}
/// Set the `w:relyOnVML` toggle in the relationship-resolved web settings part.
pub fn set_web_rely_on_vml(&mut self, value: bool) -> Result<()> {
self.stage_web_settings_mutation(|settings| settings.set_rely_on_vml(value))
}
/// Remove the `w:relyOnVML` toggle, pruning an empty part this facade created.
pub fn remove_web_rely_on_vml(&mut self) -> Result<Option<bool>> {
self.stage_web_settings_removal(CT_WebSettings::remove_rely_on_vml)
}
/// Return the `w:allowPNG` toggle from the web settings part.
pub fn web_allow_png(&self) -> Option<bool> {
self.web_settings.as_ref()?.allow_png()
}
/// Set the `w:allowPNG` toggle in the relationship-resolved web settings part.
pub fn set_web_allow_png(&mut self, value: bool) -> Result<()> {
self.stage_web_settings_mutation(|settings| settings.set_allow_png(value))
}
/// Remove the `w:allowPNG` toggle, pruning an empty part this facade created.
pub fn remove_web_allow_png(&mut self) -> Result<Option<bool>> {
self.stage_web_settings_removal(CT_WebSettings::remove_allow_png)
}
/// Return the `w:doNotRelyOnCSS` toggle from the web settings part.
pub fn web_do_not_rely_on_css(&self) -> Option<bool> {
self.web_settings.as_ref()?.do_not_rely_on_css()
}
/// Set the `w:doNotRelyOnCSS` toggle in the relationship-resolved web settings part.
pub fn set_web_do_not_rely_on_css(&mut self, value: bool) -> Result<()> {
self.stage_web_settings_mutation(|settings| settings.set_do_not_rely_on_css(value))
}
/// Remove the `w:doNotRelyOnCSS` toggle, pruning an empty part this facade created.
pub fn remove_web_do_not_rely_on_css(&mut self) -> Result<Option<bool>> {
self.stage_web_settings_removal(CT_WebSettings::remove_do_not_rely_on_css)
}
/// Return the `w:doNotSaveAsSingleFile` toggle from the web settings part.
pub fn web_do_not_save_as_single_file(&self) -> Option<bool> {
self.web_settings.as_ref()?.do_not_save_as_single_file()
}
/// Set the `w:doNotSaveAsSingleFile` toggle in the relationship-resolved web settings part.
pub fn set_web_do_not_save_as_single_file(&mut self, value: bool) -> Result<()> {
self.stage_web_settings_mutation(|settings| settings.set_do_not_save_as_single_file(value))
}
/// Remove the `w:doNotSaveAsSingleFile` toggle, pruning an empty part this facade created.
pub fn remove_web_do_not_save_as_single_file(&mut self) -> Result<Option<bool>> {
self.stage_web_settings_removal(CT_WebSettings::remove_do_not_save_as_single_file)
}
/// Return the `w:doNotOrganizeInFolder` toggle from the web settings part.
pub fn web_do_not_organize_in_folder(&self) -> Option<bool> {
self.web_settings.as_ref()?.do_not_organize_in_folder()
}
/// Set the `w:doNotOrganizeInFolder` toggle in the relationship-resolved web settings part.
pub fn set_web_do_not_organize_in_folder(&mut self, value: bool) -> Result<()> {
self.stage_web_settings_mutation(|settings| settings.set_do_not_organize_in_folder(value))
}
/// Remove the `w:doNotOrganizeInFolder` toggle, pruning an empty part this facade created.
pub fn remove_web_do_not_organize_in_folder(&mut self) -> Result<Option<bool>> {
self.stage_web_settings_removal(CT_WebSettings::remove_do_not_organize_in_folder)
}
/// Return the `w:doNotUseLongFileNames` toggle from the web settings part.
pub fn web_do_not_use_long_file_names(&self) -> Option<bool> {
self.web_settings.as_ref()?.do_not_use_long_file_names()
}
/// Set the `w:doNotUseLongFileNames` toggle in the relationship-resolved web settings part.
pub fn set_web_do_not_use_long_file_names(&mut self, value: bool) -> Result<()> {
self.stage_web_settings_mutation(|settings| settings.set_do_not_use_long_file_names(value))
}
/// Remove the `w:doNotUseLongFileNames` toggle, pruning an empty part this facade created.
pub fn remove_web_do_not_use_long_file_names(&mut self) -> Result<Option<bool>> {
self.stage_web_settings_removal(CT_WebSettings::remove_do_not_use_long_file_names)
}
/// Return the target pixel density for exported web pages from the web settings part.
pub fn web_pixels_per_inch(&self) -> Option<i32> {
self.web_settings.as_ref()?.pixels_per_inch()
}
/// Set the target pixel density for exported web pages in the relationship-resolved web settings part.
pub fn set_web_pixels_per_inch(&mut self, value: i32) -> Result<()> {
self.stage_web_settings_mutation(|settings| settings.set_pixels_per_inch(value))
}
/// Remove the target pixel density for exported web pages, pruning an empty part this facade created.
pub fn remove_web_pixels_per_inch(&mut self) -> Result<Option<i32>> {
self.stage_web_settings_removal(CT_WebSettings::remove_pixels_per_inch)
}
/// Return the target screen size, such as `800x600` from the web settings part.
pub fn web_target_screen_size(&self) -> Option<&str> {
self.web_settings.as_ref()?.target_screen_size()
}
/// Set the target screen size, such as `800x600` in the relationship-resolved web settings part.
pub fn set_web_target_screen_size(&mut self, value: &str) -> Result<()> {
self.stage_web_settings_mutation(|settings| {
settings.set_target_screen_size(value.to_owned())
})
}
/// Remove the target screen size, such as `800x600`, pruning an empty part this facade created.
pub fn remove_web_target_screen_size(&mut self) -> Result<Option<String>> {
self.stage_web_settings_removal(CT_WebSettings::remove_target_screen_size)
}
/// Return the `w:saveSmartTagsAsXml` toggle from the web settings part.
pub fn web_save_smart_tags_as_xml(&self) -> Option<bool> {
self.web_settings.as_ref()?.save_smart_tags_as_xml()
}
/// Set the `w:saveSmartTagsAsXml` toggle in the relationship-resolved web settings part.
pub fn set_web_save_smart_tags_as_xml(&mut self, value: bool) -> Result<()> {
self.stage_web_settings_mutation(|settings| settings.set_save_smart_tags_as_xml(value))
}
/// Remove the `w:saveSmartTagsAsXml` toggle, pruning an empty part this facade created.
pub fn remove_web_save_smart_tags_as_xml(&mut self) -> Result<Option<bool>> {
self.stage_web_settings_removal(CT_WebSettings::remove_save_smart_tags_as_xml)
}
/// Return the relationship-resolved DrawingML theme.
pub fn theme(&self) -> Option<&oxml_drawing::theme::CT_OfficeStyleSheet> {
self.theme.as_ref()
}
/// Replace or create the relationship-resolved DrawingML theme atomically.
pub fn set_theme(&mut self, theme: oxml_drawing::theme::CT_OfficeStyleSheet) -> Result<()> {
theme
.to_xml()
.map_err(|error| Error::Other(format!("theme serialization failed: {error}")))?;
let mut candidate = self.clone_for_staging();
candidate
.identifiers
.observe_package_graph(&candidate.package)?;
let part_name = match &candidate.theme_part_name {
Some(part_name) => part_name.clone(),
None => candidate
.identifiers
.reserve_preferred_part_name(DEFAULT_THEME_PART)?,
};
candidate
.ensure_part_relationship_checked(&part_name, rel_types::THEME, content_types::THEME)
.map_err(|error| {
Error::Other(format!("theme relationship allocation failed: {error}"))
})?;
candidate.theme = Some(theme);
candidate.theme_part_name = Some(part_name);
candidate.theme_dirty = true;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(())
}
/// Set the document-wide Latin, East Asian, and bidirectional languages.
pub fn set_language_defaults(&mut self, value: ThemeFontLanguage) -> Result<()> {
let mut candidate = self.settings_mutation_candidate()?;
candidate
.settings
.get_or_insert_with(CT_Settings::new)
.set_theme_font_language(value)?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Return font-table records with related embedded bytes deobfuscated.
pub fn fonts(&self) -> Vec<FontDefinition> {
self.try_fonts().unwrap_or_default()
}
/// Return checked font snapshots after bounding every projected byte copy.
pub fn try_fonts(&self) -> Result<Vec<FontDefinition>> {
let Some(table) = &self.font_table else {
if self.font_table_part_name.is_some() {
return Err(Error::Other("font table is unmodeled".to_owned()));
}
return Ok(Vec::new());
};
if table.fonts().len() > 4096 {
return Err(Error::Other(
"font snapshot exceeds 4096 records".to_owned(),
));
}
self.project_font_records(&table.fonts().iter().collect::<Vec<_>>())
}
fn project_font_records(&self, records: &[&FontRecord]) -> Result<Vec<FontDefinition>> {
if records.len() > 4096 {
return Err(Error::Other(
"font snapshot exceeds 4096 records".to_owned(),
));
}
let mut bytes = 0usize;
let mut faces = 0usize;
let mut metadata = 0usize;
for font in records {
for value in [
Some(&font.name),
font.alternate_name.as_ref(),
font.family.as_ref(),
font.pitch.as_ref(),
]
.into_iter()
.flatten()
{
metadata = metadata
.checked_add(value.len())
.ok_or_else(|| Error::Other("font metadata size overflow".to_owned()))?;
if value.len() > 4096 || metadata > 4 * 1024 * 1024 {
return Err(Error::Other(
"font snapshot metadata exceeds its bound".to_owned(),
));
}
}
for reference in &font.embedded_fonts {
faces += 1;
let (_, body) = self.checked_embedded_font_source(reference)?;
bytes = bytes
.checked_add(body.len())
.ok_or_else(|| Error::Other("font snapshot size overflow".to_owned()))?;
if body.len() > 64 * 1024 * 1024 || bytes > 256 * 1024 * 1024 || faces > 16384 {
return Err(Error::Other(
"font snapshot exceeds its byte or face bound".to_owned(),
));
}
metadata = metadata
.checked_add(reference.font_key.len())
.and_then(|size| {
size.checked_add(reference.license_identity.as_ref().map_or(0, String::len))
})
.ok_or_else(|| Error::Other("font metadata size overflow".to_owned()))?;
if reference.font_key.len() > 4096
|| reference
.license_identity
.as_ref()
.is_some_and(|value| value.len() > 4096)
|| metadata > 4 * 1024 * 1024
{
return Err(Error::Other(
"font snapshot metadata exceeds its bound".to_owned(),
));
}
}
}
records
.iter()
.map(|font| {
let embedded_fonts = font
.embedded_fonts
.iter()
.map(|reference| {
let (_, bytes) = self.checked_embedded_font_source(reference)?;
Ok(EmbeddedFont {
kind: reference.kind.into(),
data: deobfuscate_odttf_with_key(bytes, &reference.font_key)
.unwrap_or_default(),
font_key: reference.font_key.clone(),
subsetted: reference.subsetted.unwrap_or(false),
license: FontEmbeddingLicense {
authorized: reference.authorized.unwrap_or(false),
identity: reference.license_identity.clone().unwrap_or_default(),
},
})
})
.collect::<Result<Vec<_>>>()?;
Ok(FontDefinition {
name: font.name.clone(),
alternate_name: font.alternate_name.clone(),
family: font.family.clone(),
pitch: font.pitch.clone(),
embedded_fonts,
})
})
.collect()
}
/// Resolve the actual stored part and byte length for an indexed font face.
/// Indices follow `try_fonts()` record and face order.
pub fn font_part_metadata(
&self,
record_index: usize,
face_index: usize,
) -> Result<(String, usize)> {
let reference = self
.font_table
.as_ref()
.and_then(|table| table.fonts().get(record_index))
.and_then(|record| record.embedded_fonts.get(face_index))
.ok_or_else(|| Error::Other("font record or face index is out of range".to_owned()))?;
let (part, bytes) = self.checked_embedded_font_source(reference)?;
if bytes.len() > 64 * 1024 * 1024 {
return Err(Error::Other(
"embedded font part exceeds its byte bound".to_owned(),
));
}
Ok((part, bytes.len()))
}
fn checked_embedded_font_source(
&self,
reference: &EmbeddedFontReference,
) -> Result<(String, &[u8])> {
if reference.relationship_id.len() > 4096 || reference.font_key.len() > 4096 {
return Err(Error::Other(
"embedded font metadata exceeds its bound".to_owned(),
));
}
let owner = self
.font_table_part_name
.as_deref()
.ok_or_else(|| Error::Other("font-table part name is missing".to_owned()))?;
let relationships = self
.package
.get_part_rels(owner)
.ok_or_else(|| Error::Other("font relationships are missing".to_owned()))?;
let mut matches = relationships
.items
.iter()
.filter(|rel| rel.id == reference.relationship_id);
let relationship = matches
.next()
.filter(|rel| rel.rel_type == rel_types::FONT && relationship_is_internal(rel))
.ok_or_else(|| {
Error::Other("embedded font relationship is missing or unsupported".to_owned())
})?;
if matches.next().is_some() {
return Err(Error::Other(
"embedded font relationship is ambiguous".to_owned(),
));
}
if relationship.target.len() > 4096 {
return Err(Error::Other(
"embedded font target exceeds its bound".to_owned(),
));
}
let target = OpcPackage::resolve_rel_target(owner, &relationship.target);
let part = self
.package
.parts
.keys()
.find(|part| part.eq_ignore_ascii_case(&target))
.ok_or_else(|| Error::Other("embedded font part is missing".to_owned()))?;
if self.package.content_types.content_type_for(part) != Some(EMBEDDED_FONT_CONTENT_TYPE) {
return Err(Error::Other(
"embedded font part has an unsupported content type".to_owned(),
));
}
Ok((
part.clone(),
self.package.get_part(part).expect("checked font part"),
))
}
/// Return the presence-sensitive embedded TrueType setting.
pub fn embed_true_type_fonts(&self) -> Result<Option<bool>> {
if self.settings.is_none() && self.settings_part_name.is_some() {
return Err(Error::Other("cannot inspect unmodeled settings".to_owned()));
}
Ok(self
.settings
.as_ref()
.map(CT_Settings::embed_true_type_fonts)
.transpose()?
.flatten())
}
/// Set or remove the embedded TrueType setting atomically.
pub fn set_embed_true_type_fonts(&mut self, value: Option<bool>) -> Result<()> {
if self.embed_true_type_fonts()? == value {
return Ok(());
}
let mut candidate = self.settings_mutation_candidate()?;
candidate
.settings
.get_or_insert_with(CT_Settings::new)
.set_embed_true_type_fonts(value)?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Return the presence-sensitive embedded system font setting.
pub fn embed_system_fonts(&self) -> Result<Option<bool>> {
if self.settings.is_none() && self.settings_part_name.is_some() {
return Err(Error::Other("cannot inspect unmodeled settings".to_owned()));
}
Ok(self
.settings
.as_ref()
.map(CT_Settings::embed_system_fonts)
.transpose()?
.flatten())
}
/// Set or remove the embedded system font setting atomically.
pub fn set_embed_system_fonts(&mut self, value: Option<bool>) -> Result<()> {
if self.embed_system_fonts()? == value {
return Ok(());
}
let mut candidate = self.settings_mutation_candidate()?;
candidate
.settings
.get_or_insert_with(CT_Settings::new)
.set_embed_system_fonts(value)?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Return the presence-sensitive font subsetting setting.
pub fn save_subset_fonts(&self) -> Result<Option<bool>> {
if self.settings.is_none() && self.settings_part_name.is_some() {
return Err(Error::Other("cannot inspect unmodeled settings".to_owned()));
}
Ok(self
.settings
.as_ref()
.map(CT_Settings::save_subset_fonts)
.transpose()?
.flatten())
}
/// Set or remove the font subsetting setting atomically.
pub fn set_save_subset_fonts(&mut self, value: Option<bool>) -> Result<()> {
if self.save_subset_fonts()? == value {
return Ok(());
}
let mut candidate = self.settings_mutation_candidate()?;
candidate
.settings
.get_or_insert_with(CT_Settings::new)
.set_save_subset_fonts(value)?;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Add or replace one descriptive font-table record.
pub fn set_font(&mut self, font: FontDefinition) -> Result<()> {
if font.name.trim().is_empty()
|| std::iter::once(font.name.as_str())
.chain(font.alternate_name.as_deref())
.any(|value| {
value.len() > 4096
|| value
.chars()
.any(|ch| ch < ' ' || ch == '\u{fffe}' || ch == '\u{ffff}')
})
{
return Err(Error::Other(
"font metadata is empty or unsupported".to_owned(),
));
}
if font.family.as_deref().is_some_and(|family| {
!matches!(
family,
"decorative" | "modern" | "roman" | "script" | "swiss" | "auto"
)
}) {
return Err(Error::Other(
"font family must be a valid OOXML family enumeration".to_owned(),
));
}
if font
.pitch
.as_deref()
.is_some_and(|pitch| !matches!(pitch, "default" | "fixed" | "variable"))
{
return Err(Error::Other(
"font pitch must be a valid OOXML pitch enumeration".to_owned(),
));
}
if !font.embedded_fonts.is_empty() {
return Err(Error::Other(
"set_font does not accept embedded bytes, use embed_font".to_owned(),
));
}
if let Some(table) = &self.font_table {
let mut matches = table
.fonts()
.iter()
.filter(|record| record.name == font.name);
if let Some(record) = matches.next() {
if matches.next().is_some() {
return Err(Error::Other("ambiguous font record".to_owned()));
}
if record.alternate_name == font.alternate_name
&& record.family == font.family
&& record.pitch == font.pitch
{
return Ok(());
}
if (record.alternate_name.is_some() && font.alternate_name.is_none())
|| (record.family.is_some() && font.family.is_none())
|| (record.pitch.is_some() && font.pitch.is_none())
{
table.can_remove_font(&font.name)?;
}
}
}
let mut candidate = self.font_table_mutation_candidate()?;
candidate
.font_table
.as_mut()
.expect("font-table mutation candidate has a model")
.set_font(font.name, font.alternate_name, font.family, font.pitch);
candidate
.font_table
.as_ref()
.expect("font-table mutation candidate has a model")
.to_xml()?;
candidate.font_table_dirty = true;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(())
}
/// Remove one font-table record and every embedded face it owns.
pub fn remove_font(&mut self, name: &str) -> Result<Option<FontDefinition>> {
if let Some(table) = &self.font_table {
table.can_remove_font(name)?;
}
let Some(record) = self
.font_table
.as_ref()
.and_then(|table| table.fonts().iter().find(|font| font.name == name))
else {
return Ok(None);
};
let removed = self.project_font_records(&[record])?.remove(0);
let mut candidate = self.font_table_mutation_candidate()?;
let references = candidate
.font_table
.as_mut()
.expect("font-table mutation candidate has a model")
.remove_font(name)
.expect("font record existed before staging")
.embedded_fonts;
for reference in references {
candidate.remove_embedded_font_part(&reference)?;
}
candidate.font_table_dirty = true;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(Some(removed))
}
/// Embed caller-provided font bytes after explicit license authorization.
pub fn embed_font(&mut self, font_name: &str, embedded: EmbeddedFont) -> Result<()> {
if font_name.trim().is_empty()
|| font_name.len() > 4096
|| font_name
.chars()
.any(|ch| ch < ' ' || ch == '\u{fffe}' || ch == '\u{ffff}')
{
return Err(Error::Other("font name is empty or unsupported".to_owned()));
}
if !embedded.license.authorized {
return Err(Error::Other(
"font embedding requires explicit caller authorization".to_owned(),
));
}
if embedded.license.identity.trim().is_empty() {
return Err(Error::Other(
"font embedding requires an exact license identity".to_owned(),
));
}
if embedded.license.identity.len() > 4096 {
return Err(Error::Other(
"font embedding license identity exceeds 4096 bytes".to_owned(),
));
}
if embedded
.license
.identity
.chars()
.any(|ch| ch < ' ' || ch == '\u{fffe}' || ch == '\u{ffff}')
{
return Err(Error::Other(
"font embedding license identity contains unsupported XML characters".to_owned(),
));
}
if !valid_authored_sfnt(&embedded.data) {
return Err(Error::Other(
"embedded font bytes are not a supported sfnt font".to_owned(),
));
}
let guid = font_key_bytes(&embedded.font_key).ok_or_else(|| {
Error::Other("embedded font key must be a 16-byte OOXML GUID".to_owned())
})?;
let mut candidate = self.font_table_mutation_candidate()?;
let table = candidate
.font_table
.as_mut()
.expect("font-table mutation candidate has a model");
if table
.fonts()
.iter()
.filter(|font| font.name == font_name)
.count()
> 1
{
return Err(Error::Other("font-table record is ambiguous".to_owned()));
}
if !table.fonts().iter().any(|font| font.name == font_name) {
table.set_font(font_name.to_owned(), None, None, None);
}
let table = candidate.font_table.as_ref().unwrap();
let font = table
.fonts()
.iter()
.find(|font| font.name == font_name)
.unwrap();
let kind = FontFaceKind::from(embedded.kind);
let existing = font
.embedded_fonts
.iter()
.find(|value| value.kind == kind)
.cloned();
let font_table_part = candidate
.font_table_part_name
.clone()
.expect("font-table mutation candidate has a part name");
if font
.embedded_fonts
.iter()
.filter(|face| face.kind == kind)
.count()
> 1
{
return Err(Error::Other("embedded font face is ambiguous".to_owned()));
}
let existing_target = existing
.as_ref()
.map(|reference| {
candidate
.checked_embedded_font_source(reference)
.map(|(target, _)| target)
})
.transpose()?;
let obfuscated = obfuscate_odttf_with_key(&embedded.data, &guid);
if let (Some(reference), Some(target)) = (&existing, &existing_target)
&& reference.font_key == embedded.font_key
&& reference.subsetted == Some(embedded.subsetted)
&& reference.authorized == Some(true)
&& reference.license_identity.as_ref() == Some(&embedded.license.identity)
&& candidate.package.get_part(target) == Some(obfuscated.as_slice())
&& candidate.embed_true_type_fonts()? == Some(true)
{
return Ok(());
}
let can_reuse_existing = existing.as_ref().is_some_and(|existing| {
let relationship_reference_count =
table.embedded_relationship_reference_count(&existing.relationship_id);
let target = existing_target
.as_ref()
.expect("existing embedded font has a resolved target");
let target_relationship_count = std::iter::once(("/", &candidate.package.package_rels))
.chain(
candidate
.package
.part_rels
.iter()
.map(|(source, relationships)| (source.as_str(), relationships)),
)
.flat_map(|(source, relationships)| {
relationships
.items
.iter()
.map(move |relationship| (source, relationship))
})
.filter(|(source, relationship)| {
relationship_is_internal(relationship)
&& OpcPackage::resolve_rel_target(source, &relationship.target)
.eq_ignore_ascii_case(target)
})
.count();
relationship_reference_count == 1
&& target_relationship_count == 1
&& !table.retained_raw_mentions_relationship(&existing.relationship_id)
});
let (relationship_id, part_name) = if can_reuse_existing {
(
existing
.as_ref()
.expect("reusable embedded font exists")
.relationship_id
.clone(),
existing_target.expect("reusable embedded font has a target"),
)
} else {
let part_name = candidate
.identifiers
.reserve_preferred_part_name("/word/fonts/font1.odttf")?;
let relationship_id = candidate
.identifiers
.reserve_relationship_id_checked(&font_table_part)?;
let target = relative_target(&font_table_part, &part_name);
candidate
.package
.get_or_create_part_rels(&font_table_part)
.add_with_id(&relationship_id, rel_types::FONT, &target);
(relationship_id, part_name)
};
candidate.package.set_part(&part_name, obfuscated);
candidate
.package
.content_types
.add_override(&part_name, EMBEDDED_FONT_CONTENT_TYPE);
candidate
.identifiers
.register_content_type_override(&part_name);
let reference = EmbeddedFontReference::new(
kind,
relationship_id,
embedded.font_key,
Some(embedded.subsetted),
Some(true),
Some(embedded.license.identity),
);
let inserted = candidate
.font_table
.as_mut()
.expect("font-table mutation candidate has a model")
.set_embedded_font(font_name, reference);
debug_assert!(inserted);
if !can_reuse_existing && let Some(existing) = &existing {
candidate.remove_embedded_font_part(existing)?;
}
candidate
.font_table
.as_ref()
.expect("font-table mutation candidate has a model")
.to_xml()?;
if candidate.settings_part_name.is_some() && candidate.settings.is_none() {
return Err(Error::Other("cannot mutate unmodeled settings".to_owned()));
}
let created = candidate.settings_part_name.is_none();
let settings_part = match &candidate.settings_part_name {
Some(part) => part.clone(),
None => candidate
.identifiers
.reserve_preferred_part_name(DEFAULT_SETTINGS_PART)?,
};
candidate.ensure_part_relationship_checked(
&settings_part,
rel_types::SETTINGS,
SETTINGS_CONTENT_TYPE,
)?;
candidate.settings_part_name = Some(settings_part);
candidate.settings_owned |= created;
candidate
.settings
.get_or_insert_with(CT_Settings::new)
.set_embed_true_type_fonts(Some(true))?;
candidate.font_table_dirty = true;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(())
}
/// Remove one embedded face without removing its font-table record.
pub fn remove_embedded_font(
&mut self,
font_name: &str,
kind: EmbeddedFontKind,
) -> Result<Option<EmbeddedFont>> {
if let Some(table) = &self.font_table {
table.can_remove_embedded_font(font_name, kind.into())?;
}
let Some(reference) = self
.font_table
.as_ref()
.and_then(|table| table.fonts().iter().find(|font| font.name == font_name))
.and_then(|font| {
font.embedded_fonts
.iter()
.find(|face| face.kind == kind.into())
})
else {
return Ok(None);
};
let (_, data) = self.checked_embedded_font_source(reference)?;
if data.len() > 64 * 1024 * 1024
|| reference.font_key.len() > 4096
|| reference
.license_identity
.as_ref()
.is_some_and(|value| value.len() > 4096)
{
return Err(Error::Other(
"embedded font snapshot exceeds its bound".to_owned(),
));
}
let removed = EmbeddedFont {
kind,
data: deobfuscate_odttf_with_key(data, &reference.font_key).unwrap_or_default(),
font_key: reference.font_key.clone(),
subsetted: reference.subsetted.unwrap_or(false),
license: FontEmbeddingLicense {
authorized: reference.authorized.unwrap_or(false),
identity: reference.license_identity.clone().unwrap_or_default(),
},
};
let mut candidate = self.font_table_mutation_candidate()?;
let reference = candidate
.font_table
.as_mut()
.expect("font-table mutation candidate has a model")
.remove_embedded_font(font_name, kind.into())
.expect("embedded font existed before staging");
candidate.remove_embedded_font_part(&reference)?;
candidate.font_table_dirty = true;
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
Ok(Some(removed))
}
fn font_table_mutation_candidate(&self) -> Result<Self> {
let mut candidate = self.clone_for_staging();
candidate
.identifiers
.observe_package_graph(&candidate.package)?;
let part_name = match &candidate.font_table_part_name {
Some(part_name) => {
if candidate.font_table.is_none() {
return Err(Error::Other(format!(
"cannot mutate unmodeled font table at {part_name}"
)));
}
part_name.clone()
}
None => candidate
.identifiers
.reserve_preferred_part_name(DEFAULT_FONT_TABLE_PART)?,
};
candidate
.ensure_part_relationship_checked(
&part_name,
rel_types::FONT_TABLE,
FONT_TABLE_CONTENT_TYPE,
)
.map_err(|error| {
Error::Other(format!(
"font-table relationship allocation failed: {error}"
))
})?;
candidate.font_table.get_or_insert_with(FontTable::new);
candidate.font_table_part_name = Some(part_name);
Ok(candidate)
}
fn remove_embedded_font_part(&mut self, reference: &EmbeddedFontReference) -> Result<()> {
let owner = self
.font_table_part_name
.clone()
.ok_or_else(|| Error::Other("font-table part name is missing".to_owned()))?;
let (target, _) = self.checked_embedded_font_source(reference)?;
if self.font_table.as_ref().is_some_and(|table| {
table.embedded_relationship_reference_count(&reference.relationship_id) > 0
|| table.retained_raw_mentions_relationship(&reference.relationship_id)
}) {
return Ok(());
}
if let Some(relationships) = self.package.get_part_rels_mut(&owner) {
relationships.items.retain(|relationship| {
relationship.id != reference.relationship_id
|| relationship.rel_type != rel_types::FONT
|| !relationship_is_internal(relationship)
});
}
self.identifiers
.retire_authored_story_relationships(&owner, vec![reference.relationship_id.clone()]);
let still_referenced = std::iter::once(("/", &self.package.package_rels))
.chain(
self.package
.part_rels
.iter()
.map(|(source, relationships)| (source.as_str(), relationships)),
)
.any(|(source, relationships)| {
relationships.items.iter().any(|relationship| {
relationship_is_internal(relationship)
&& OpcPackage::resolve_rel_target(source, &relationship.target)
.eq_ignore_ascii_case(&target)
})
});
if !still_referenced {
self.package.remove_part(&target);
self.package.remove_part_rels(&target);
self.package.content_types.remove_override(&target);
self.identifiers.retire_authored_part(&target);
}
Ok(())
}
fn settings_mutation_candidate(&self) -> Result<Self> {
let mut candidate = self.clone_for_staging();
let created = candidate.settings_part_name.is_none();
candidate
.identifiers
.observe_package_graph(&candidate.package)?;
let part_name = match &candidate.settings_part_name {
Some(part_name) => part_name.clone(),
None => candidate
.identifiers
.reserve_preferred_part_name(DEFAULT_SETTINGS_PART)?,
};
candidate.invalidate_layout();
candidate.settings_part_name = Some(part_name.clone());
candidate.settings_owned |= created;
candidate
.ensure_part_relationship_checked(
&part_name,
rel_types::SETTINGS,
SETTINGS_CONTENT_TYPE,
)
.map_err(|error| {
Error::Other(format!("settings relationship allocation failed: {error}"))
})?;
Ok(candidate)
}
fn web_settings_mutation_candidate(&self) -> Result<Self> {
let mut candidate = self.clone_for_staging();
let created = candidate.web_settings_part_name.is_none();
candidate
.identifiers
.observe_package_graph(&candidate.package)?;
let part_name = match &candidate.web_settings_part_name {
Some(part_name) => part_name.clone(),
None => candidate
.identifiers
.reserve_preferred_part_name(DEFAULT_WEB_SETTINGS_PART)?,
};
candidate.web_settings_part_name = Some(part_name.clone());
candidate.web_settings_owned |= created;
candidate
.ensure_part_relationship_checked(
&part_name,
rel_types::WEB_SETTINGS,
WEB_SETTINGS_CONTENT_TYPE,
)
.map_err(|error| {
Error::Other(format!(
"web settings relationship allocation failed: {error}"
))
})?;
Ok(candidate)
}
fn prune_empty_owned_web_settings(&mut self) {
if !self.web_settings_owned
|| !self
.web_settings
.as_ref()
.is_some_and(CT_WebSettings::is_empty)
{
return;
}
let Some(part_name) = self.web_settings_part_name.take() else {
return;
};
let owner = self.doc_part_name.clone();
let mut removed_ids = Vec::new();
if let Some(relationships) = self.package.get_part_rels_mut(&owner) {
relationships.items.retain(|relationship| {
let remove = relationship.rel_type == rel_types::WEB_SETTINGS
&& relationship_is_internal(relationship)
&& OpcPackage::resolve_rel_target(&owner, &relationship.target) == part_name;
if remove {
removed_ids.push(relationship.id.clone());
}
!remove
});
}
self.identifiers
.retire_authored_story_relationships(&owner, removed_ids);
self.package.remove_part(&part_name);
self.package.remove_part_rels(&part_name);
self.package.content_types.remove_override(&part_name);
self.identifiers.retire_authored_part(&part_name);
self.web_settings = None;
self.web_settings_owned = false;
}
fn prune_empty_owned_settings(&mut self) {
if !self.settings_owned || !self.settings.as_ref().is_some_and(CT_Settings::is_empty) {
return;
}
let Some(part_name) = self.settings_part_name.take() else {
return;
};
let owner = self.doc_part_name.clone();
let mut removed_ids = Vec::new();
if let Some(relationships) = self.package.get_part_rels_mut(&owner) {
relationships.items.retain(|relationship| {
let remove = relationship.rel_type == rel_types::SETTINGS
&& relationship_is_internal(relationship)
&& OpcPackage::resolve_rel_target(&owner, &relationship.target) == part_name;
if remove {
removed_ids.push(relationship.id.clone());
}
!remove
});
}
self.identifiers
.retire_authored_story_relationships(&owner, removed_ids);
self.package.remove_part(&part_name);
self.package.remove_part_rels(&part_name);
self.package.content_types.remove_override(&part_name);
self.identifiers.retire_authored_part(&part_name);
self.settings = None;
self.settings_owned = false;
}
// ---- Metadata access ----
/// Return the complete core-properties model.
pub fn core_properties(&self) -> Option<&CoreProperties> {
self.core_properties.as_deref()
}
/// Replace the complete core-properties model.
pub fn set_core_properties(&mut self, properties: CoreProperties) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.reserve_core_properties_bundle()?;
candidate.core_properties = Some(Box::new(properties));
self.commit_staged_mutation(candidate);
Ok(())
}
/// Remove the complete core-properties part and its package relationship.
pub fn remove_core_properties(&mut self) -> Result<Option<CoreProperties>> {
let Some(properties) = self.core_properties.as_deref().cloned() else {
return Ok(None);
};
let mut candidate = self.clone_for_staging();
if let Some(part_name) = candidate.core_properties_part_name.take() {
candidate.remove_package_properties_bundle(&part_name, rel_types::CORE_PROPERTIES);
}
candidate.core_properties = None;
self.commit_staged_mutation(candidate);
Ok(Some(properties))
}
/// Return the complete application-properties model.
pub fn application_properties(&self) -> Option<&AppProperties> {
self.application_properties.as_ref()
}
/// Replace the complete application-properties model.
///
/// An `application_version` must have the form `XX.YYYY` (ECMA-376 Part 1,
/// 22.2.2.3), because Word refuses to open a package with any other shape.
pub fn set_application_properties(&mut self, properties: AppProperties) -> Result<()> {
if let Some(version) = properties.application_version.as_deref()
&& !is_app_version(version)
{
return Err(Error::Other(format!(
"application version {version:?} must have the form XX.YYYY \
(two digits, a dot, four digits), which Word requires to open the file"
)));
}
let mut candidate = self.clone_for_staging();
candidate.reserve_application_properties_bundle()?;
candidate.application_properties = Some(properties);
self.commit_staged_mutation(candidate);
Ok(())
}
/// Remove the complete application-properties part and package relationship.
pub fn remove_application_properties(&mut self) -> Result<Option<AppProperties>> {
let Some(properties) = self.application_properties.clone() else {
return Ok(None);
};
let mut candidate = self.clone_for_staging();
if let Some(part_name) = candidate.application_properties_part_name.take() {
candidate.remove_package_properties_bundle(&part_name, rel_types::EXTENDED_PROPERTIES);
}
candidate.application_properties = None;
self.commit_staged_mutation(candidate);
Ok(Some(properties))
}
/// Return all custom properties in package order.
pub fn custom_properties(&self) -> &[CustomProperty] {
self.custom_properties
.as_ref()
.map(|properties| properties.properties.as_slice())
.unwrap_or_default()
}
/// Return a custom property by its case-sensitive name.
pub fn custom_property(&self, name: &str) -> Option<&CustomProperty> {
self.custom_properties()
.iter()
.find(|property| property.name.as_deref() == Some(name))
}
/// Add or replace one named custom property.
pub fn set_custom_property(&mut self, property: CustomProperty) -> Result<()> {
let Some(name) = property.name.as_deref().filter(|name| !name.is_empty()) else {
return Err(Error::Other(
"an authored custom property requires a non-empty name".to_owned(),
));
};
if property.pid < 2 {
return Err(Error::Other(
"an authored custom property pid must be at least 2".to_owned(),
));
}
if self
.custom_properties()
.iter()
.any(|existing| existing.name.as_deref() != Some(name) && existing.pid == property.pid)
{
return Err(Error::Other(format!(
"custom property pid {} is already in use",
property.pid
)));
}
let mut candidate = self.clone_for_staging();
let created = candidate.custom_properties_part_name.is_none();
candidate.reserve_custom_properties_bundle()?;
let properties = candidate
.custom_properties
.get_or_insert_with(CustomProperties::default);
if let Some(index) = properties
.properties
.iter()
.position(|existing| existing.name.as_deref() == Some(name))
{
properties
.properties
.retain(|existing| existing.name.as_deref() != Some(name));
properties.properties.insert(index, property);
} else {
properties.properties.push(property);
}
candidate.custom_properties_owned |= created;
self.commit_staged_mutation(candidate);
Ok(())
}
/// Remove one named custom property.
pub fn remove_custom_property(&mut self, name: &str) -> Result<Option<CustomProperty>> {
let Some(removed) = self
.custom_properties()
.iter()
.find(|property| property.name.as_deref() == Some(name))
.cloned()
else {
return Ok(None);
};
let mut candidate = self.clone_for_staging();
let properties = candidate
.custom_properties
.as_mut()
.expect("custom property lookup proved the model exists");
properties
.properties
.retain(|property| property.name.as_deref() != Some(name));
if properties.properties.is_empty() && candidate.custom_properties_owned {
if let Some(part_name) = candidate.custom_properties_part_name.take() {
candidate
.remove_package_properties_bundle(&part_name, rel_types::CUSTOM_PROPERTIES);
}
candidate.custom_properties = None;
candidate.custom_properties_owned = false;
}
self.commit_staged_mutation(candidate);
Ok(Some(removed))
}
/// Remove the complete custom-properties part and package relationship.
pub fn remove_custom_properties(&mut self) -> Result<Option<Vec<CustomProperty>>> {
let Some(properties) = self.custom_properties.clone() else {
return Ok(None);
};
let mut candidate = self.clone_for_staging();
if let Some(part_name) = candidate.custom_properties_part_name.take() {
candidate.remove_package_properties_bundle(&part_name, rel_types::CUSTOM_PROPERTIES);
}
candidate.custom_properties = None;
candidate.custom_properties_owned = false;
self.commit_staged_mutation(candidate);
Ok(Some(properties.properties))
}
fn remove_package_properties_bundle(&mut self, part_name: &str, rel_type: &str) {
let mut removed_ids = Vec::new();
self.package.package_rels.items.retain(|relationship| {
let remove = relationship.rel_type == rel_type
&& relationship_is_internal(relationship)
&& OpcPackage::resolve_rel_target("/", &relationship.target) == part_name;
if remove {
removed_ids.push(relationship.id.clone());
}
!remove
});
self.identifiers
.retire_authored_story_relationships("/", removed_ids);
self.package.remove_part(part_name);
self.package.remove_part_rels(part_name);
self.package.content_types.remove_override(part_name);
self.identifiers.retire_authored_part(part_name);
}
/// Get the document title.
pub fn title(&self) -> Option<&str> {
self.core_properties.as_ref()?.title.as_deref()
}
/// Set the document title.
pub fn set_title(&mut self, title: &str) {
let mut candidate = self.clone_for_staging();
candidate
.reserve_core_properties_bundle()
.expect("an in-memory document can allocate a core-properties part");
candidate.invalidate_layout();
candidate.ensure_core_properties().title = Some(title.to_string());
self.commit_staged_mutation(candidate);
}
/// Get the document author/creator.
pub fn author(&self) -> Option<&str> {
self.core_properties.as_ref()?.creator.as_deref()
}
/// Set the document author/creator.
pub fn set_author(&mut self, author: &str) {
let mut candidate = self.clone_for_staging();
candidate
.reserve_core_properties_bundle()
.expect("an in-memory document can allocate a core-properties part");
candidate.invalidate_layout();
candidate.ensure_core_properties().creator = Some(author.to_string());
self.commit_staged_mutation(candidate);
}
/// Get the document subject.
pub fn subject(&self) -> Option<&str> {
self.core_properties.as_ref()?.subject.as_deref()
}
/// Set the document subject.
pub fn set_subject(&mut self, subject: &str) {
let mut candidate = self.clone_for_staging();
candidate
.reserve_core_properties_bundle()
.expect("an in-memory document can allocate a core-properties part");
candidate.invalidate_layout();
candidate.ensure_core_properties().subject = Some(subject.to_string());
self.commit_staged_mutation(candidate);
}
/// Get the document keywords.
pub fn keywords(&self) -> Option<&str> {
self.core_properties.as_ref()?.keywords.as_deref()
}
/// Set the document keywords.
pub fn set_keywords(&mut self, keywords: &str) {
let mut candidate = self.clone_for_staging();
candidate
.reserve_core_properties_bundle()
.expect("an in-memory document can allocate a core-properties part");
candidate.invalidate_layout();
candidate.ensure_core_properties().keywords = Some(keywords.to_string());
self.commit_staged_mutation(candidate);
}
fn ensure_core_properties(&mut self) -> &mut CoreProperties {
self.core_properties.get_or_insert_default()
}
// ---- Document Merging ----
/// Append the content of another document to this document.
///
/// Copies all body content (paragraphs and tables) from the other document.
/// Handles style deduplication and numbering remapping.
pub fn append(&mut self, other: &Document) {
let mut candidate = self.clone_for_staging();
candidate
.append_document_content(other)
.expect("document append preflight failed");
self.commit_staged_mutation(candidate);
}
/// Append the content of another document with a section break.
pub fn append_with_break(&mut self, other: &Document, break_type: crate::SectionBreak) {
let mut candidate = self.clone_for_staging();
candidate.invalidate_layout();
// Insert a section break paragraph before the merged content
let mut p = CT_P::new();
let sect_pr = match break_type {
crate::SectionBreak::NextPage => CT_SectPr::default_letter(),
crate::SectionBreak::Continuous => {
let mut sp = CT_SectPr::default_letter();
sp.section_type = Some(ST_SectionType::Continuous);
sp
}
crate::SectionBreak::EvenPage => {
let mut sp = CT_SectPr::default_letter();
sp.section_type = Some(ST_SectionType::EvenPage);
sp
}
crate::SectionBreak::OddPage => {
let mut sp = CT_SectPr::default_letter();
sp.section_type = Some(ST_SectionType::OddPage);
sp
}
};
p.properties = Some(CT_PPr {
sect_pr: Some(sect_pr),
..Default::default()
});
candidate
.document
.body
.content
.push(BodyContent::Paragraph(p));
candidate
.append_document_content(other)
.expect("document append-with-break preflight failed");
self.commit_staged_mutation(candidate);
}
/// Insert the content of another document at a specified body index.
///
/// An `index` past the end is clamped to the end rather than panicking.
pub fn insert_document(&mut self, index: usize, other: &Document) {
let mut candidate = self.clone_for_staging();
candidate
.insert_document_content_staged(index, other)
.expect("document insertion preflight failed");
self.commit_staged_mutation(candidate);
}
/// Insert into a caller-owned staged candidate and return any preflight failure.
pub(crate) fn insert_document_content_staged(
&mut self,
index: usize,
other: &Document,
) -> Result<()> {
self.insert_document_content_staged_with_numbering_policy(index, other, false)
.map(|_| ())
}
pub(crate) fn insert_document_fragment_content_staged(
&mut self,
index: usize,
other: &Document,
reuse_equivalent_numbering: bool,
) -> Result<HashMap<u32, u32>> {
self.insert_document_content_staged_with_numbering_policy(
index,
other,
reuse_equivalent_numbering,
)
}
fn insert_document_content_staged_with_numbering_policy(
&mut self,
index: usize,
other: &Document,
reuse_equivalent_numbering: bool,
) -> Result<HashMap<u32, u32>> {
self.reserve_merge_bundles(other)?;
self.invalidate_layout();
self.merge_styles(other)?;
let insert_at = index.min(self.document.body.content.len());
for (i, content) in other.document.body.content.iter().enumerate() {
self.document
.body
.content
.insert(insert_at + i, content.clone());
}
self.remap_merged_numbering(other, insert_at, reuse_equivalent_numbering)
}
fn append_document_content(&mut self, other: &Document) -> Result<()> {
self.reserve_merge_bundles(other)?;
self.invalidate_layout();
self.merge_styles(other)?;
let start_idx = self.document.body.content.len();
self.document
.body
.content
.extend(other.document.body.content.iter().cloned());
self.remap_merged_numbering(other, start_idx, false)
.map(|_| ())
}
fn reserve_merge_bundles(&mut self, other: &Document) -> Result<()> {
let needs_styles = other
.styles
.styles
.iter()
.any(|style| self.styles.get_by_id(&style.style_id).is_none());
let needs_numbering = other.numbering.is_some();
let additional_relationships = usize::from(
needs_styles
&& self.document_bundle_relationship_is_missing(
self.styles_part_name.as_deref(),
rel_types::STYLES,
),
) + usize::from(
needs_numbering
&& self.document_bundle_relationship_is_missing(
self.numbering_part_name.as_deref(),
rel_types::NUMBERING,
),
);
self.identifiers
.ensure_relationship_capacity(&self.doc_part_name, additional_relationships)?;
if needs_styles {
self.reserve_styles_bundle()?;
}
if needs_numbering {
self.reserve_numbering_bundle()?;
}
Ok(())
}
fn document_bundle_relationship_is_missing(
&self,
part_name: Option<&str>,
rel_type: &str,
) -> bool {
let Some(part_name) = part_name else {
return true;
};
!self
.package
.get_part_rels(&self.doc_part_name)
.is_some_and(|relationships| {
relationships.items.iter().any(|relationship| {
relationship.rel_type == rel_type
&& relationship_is_internal(relationship)
&& OpcPackage::resolve_rel_target(&self.doc_part_name, &relationship.target)
== part_name
})
})
}
/// Merge styles from another document, avoiding duplicates.
///
/// A defect the destination styles already have is retained. Only a
/// defect the merged styles introduce rejects the merge.
fn merge_styles(&mut self, other: &Document) -> Result<()> {
let source = self.styles.clone();
for style in &other.styles.styles {
if self.styles.get_by_id(&style.style_id).is_none() {
self.styles.styles.push(style.clone());
}
}
style::validate_style_graph_change(&source, &self.styles).map(|_| ())
}
pub(crate) fn equivalent_numbering_dependency_id(
&self,
other: &Document,
num_id: u32,
) -> Option<u32> {
let (left_numbering, right_numbering) =
self.numbering.as_ref().zip(other.numbering.as_ref())?;
let right = right_numbering
.nums
.iter()
.find(|instance| instance.num_id == num_id)?;
let right_abstract = right_numbering
.abstract_nums
.iter()
.find(|definition| definition.abstract_num_id == right.abstract_num_id)?;
left_numbering.nums.iter().find_map(|left| {
let left_abstract = left_numbering
.abstract_nums
.iter()
.find(|definition| definition.abstract_num_id == left.abstract_num_id)?;
(equivalent_abstract_numbering(left_abstract, right_abstract)
&& equivalent_numbering_instance(left, right, left.abstract_num_id))
.then_some(left.num_id)
})
}
/// Merge numbering from another document and remap IDs in the merged content.
/// `start_idx` is the index where the other document's content starts in self.
fn remap_merged_numbering(
&mut self,
other: &Document,
start_idx: usize,
reuse_equivalent: bool,
) -> Result<HashMap<u32, u32>> {
let Some(other_numbering) = &other.numbering else {
return Ok(HashMap::new());
};
let existing_numbering = self.numbering.clone();
let mut abstract_remap = HashMap::new();
let mut new_abstract_ids = HashSet::new();
for abs_num in &other_numbering.abstract_nums {
let new_id = existing_numbering
.as_ref()
.filter(|_| reuse_equivalent)
.and_then(|numbering| {
numbering.abstract_nums.iter().find_map(|existing| {
equivalent_abstract_numbering(existing, abs_num)
.then_some(existing.abstract_num_id)
})
})
.map(Ok)
.unwrap_or_else(|| self.identifiers.reserve_abstract_numbering_id())?;
if !existing_numbering.as_ref().is_some_and(|numbering| {
numbering
.abstract_nums
.iter()
.any(|existing| existing.abstract_num_id == new_id)
}) {
new_abstract_ids.insert(abs_num.abstract_num_id);
}
abstract_remap.insert(abs_num.abstract_num_id, new_id);
}
let mut num_remap = HashMap::new();
let mut new_num_ids = HashSet::new();
for num in &other_numbering.nums {
if let std::collections::hash_map::Entry::Vacant(entry) =
abstract_remap.entry(num.abstract_num_id)
{
let allocated = self.identifiers.reserve_abstract_numbering_id()?;
new_abstract_ids.insert(num.abstract_num_id);
entry.insert(allocated);
}
let mapped_abstract = abstract_remap[&num.abstract_num_id];
let new_id = existing_numbering
.as_ref()
.filter(|_| reuse_equivalent)
.and_then(|numbering| {
numbering.nums.iter().find_map(|existing| {
equivalent_numbering_instance(existing, num, mapped_abstract)
.then_some(existing.num_id)
})
})
.map(Ok)
.unwrap_or_else(|| self.identifiers.reserve_numbering_instance_id())?;
if !existing_numbering.as_ref().is_some_and(|numbering| {
numbering
.nums
.iter()
.any(|existing| existing.num_id == new_id)
}) {
new_num_ids.insert(num.num_id);
}
num_remap.insert(num.num_id, new_id);
}
for source_style in &other.styles.styles {
let Some(source_num_id) = source_style
.ppr
.as_ref()
.and_then(|properties| properties.num_id)
else {
continue;
};
let Some(mapped_num_id) = num_remap.get(&source_num_id).copied() else {
continue;
};
let Some(destination_style) = self
.styles
.styles
.iter_mut()
.find(|style| style.style_id == source_style.style_id)
else {
continue;
};
if destination_style == source_style
&& let Some(properties) = destination_style.ppr.as_mut()
{
properties.num_id = Some(mapped_num_id);
properties.num_id_raw = None;
}
}
let numbering = self
.numbering
.get_or_insert_with(|| rdocx_oxml::numbering::CT_Numbering {
abstract_nums: Vec::new(),
nums: Vec::new(),
root_attributes: Vec::new(),
extra_xml: Vec::new(),
});
for abs_num in &other_numbering.abstract_nums {
if !new_abstract_ids.contains(&abs_num.abstract_num_id) {
continue;
}
let mut new_abs = abs_num.clone();
new_abs.abstract_num_id = abstract_remap[&abs_num.abstract_num_id];
numbering.abstract_nums.push(new_abs);
}
for num in &other_numbering.nums {
if !new_num_ids.contains(&num.num_id) {
continue;
}
let mut new_num = num.clone();
new_num.num_id = num_remap[&num.num_id];
new_num.abstract_num_id = abstract_remap[&num.abstract_num_id];
new_num.abstract_num_id_raw = None;
numbering.nums.push(new_num);
}
let incoming_count = other.document.body.content.len();
visit_body_paragraphs_mut(
&mut self.document.body.content[start_idx..start_idx + incoming_count],
&mut |paragraph| {
if let Some(num_id) = paragraph
.properties
.as_mut()
.and_then(|properties| properties.num_id.as_mut())
&& let Some(updated) = num_remap.get(num_id)
{
*num_id = *updated;
}
},
);
Ok(num_remap)
}
// ---- Table of Contents ----
/// Insert a Table of Contents at the given body content index.
///
/// Scans the document for heading paragraphs (Heading1..HeadingN where N <= max_level),
/// inserts bookmark markers at each heading, and generates TOC entry paragraphs
/// with internal hyperlinks and dot-leader tab stops.
///
/// # Arguments
/// * `index` - Body content index at which to insert the TOC
/// * `max_level` - Maximum heading level to include (1-9, typically 3)
pub fn insert_toc(&mut self, index: usize, max_level: u32) -> Result<()> {
let mut candidate = self.clone_for_staging();
candidate.insert_toc_in_place(index, max_level)?;
self.commit_staged_mutation(candidate);
Ok(())
}
fn insert_toc_in_place(&mut self, index: usize, max_level: u32) -> Result<()> {
use rdocx_oxml::borders::{CT_TabStop, CT_Tabs};
use rdocx_oxml::shared::{ST_TabJc, ST_TabLeader};
use rdocx_oxml::text::HyperlinkSpan;
use rdocx_oxml::units::Twips;
let max_level = max_level.clamp(1, 9);
// Step 1: Collect heading info from the document body
struct HeadingInfo {
content_index: usize,
level: u32,
text: String,
bookmark_name: String,
bookmark_id: i32,
}
// Calling insert_toc twice must not mint bookmarks that collide with
// the ones the first call left behind — duplicate `w:name` values make
// the internal links ambiguous. Continue numbering past whatever is
// already there.
let mut occupied_suffixes = self.toc_bookmark_suffixes();
let mut toc_counter = occupied_suffixes.iter().copied().max().unwrap_or(0);
let mut identifiers = self.identifiers.clone();
let mut duplicate_typed_id = false;
let mut typed_ids = HashSet::new();
visit_body_paragraphs(&self.document.body.content, false, &mut |paragraph| {
for marker in ¶graph.bookmark_markers {
if marker.is_start()
&& let Some(id) = marker.id()
&& id >= 0
&& !typed_ids.insert(id)
{
duplicate_typed_id = true;
}
}
});
if duplicate_typed_id {
return Err(Error::Other("duplicate heading bookmark ID".to_owned()));
}
identifiers.bookmark_ids.extend(typed_ids);
let mut headings = Vec::new();
for (idx, content) in self.document.body.content.iter().enumerate() {
if let BodyContent::Paragraph(p) = content
&& let Some(level) = Self::detect_heading_level_for_toc(p)
&& level <= max_level
{
let text = p.text();
if !text.trim().is_empty() {
let Some(suffix) = next_toc_bookmark_suffix(&occupied_suffixes, toc_counter)
else {
return Err(Error::Other(
"TOC bookmark suffixes are exhausted".to_owned(),
));
};
toc_counter = suffix;
occupied_suffixes.insert(suffix);
let Some(preferred_id) = suffix
.checked_add(99)
.and_then(|candidate| i32::try_from(candidate).ok())
else {
return Err(Error::Other("TOC bookmark IDs are exhausted".to_owned()));
};
let bookmark_id = identifiers.reserve_preferred_bookmark_id(preferred_id)?;
headings.push(HeadingInfo {
content_index: idx,
level,
text,
bookmark_name: format!("_Toc{suffix}"),
bookmark_id,
});
}
}
}
// Step 2: Insert typed bookmark markers at each heading paragraph.
for heading in &headings {
if let Some(BodyContent::Paragraph(p)) =
self.document.body.content.get_mut(heading.content_index)
{
let run_count = p.runs.len();
let inserted_start =
p.insert_bookmark_start(0, heading.bookmark_id, &heading.bookmark_name);
let inserted_end = p.insert_bookmark_end(run_count, heading.bookmark_id);
if !inserted_start || !inserted_end {
return Err(Error::Other(
"TOC heading bookmark insertion failed".to_owned(),
));
}
}
}
// Step 3: Build TOC entry paragraphs.
// The dot leader runs to the right text margin, which depends on the
// section's page size and margins rather than being a fixed 6.5".
let right_tab = CT_Tabs {
tabs: vec![CT_TabStop {
val: ST_TabJc::Right,
pos: Twips(self.text_width_twips()),
leader: Some(ST_TabLeader::Dot),
source_occurrence: None,
}],
};
let mut toc_paragraphs: Vec<CT_P> = Vec::new();
// TOC title
let mut title_p = CT_P::new();
let mut title_r = CT_R::new("Table of Contents");
title_r.properties = Some(CT_RPr {
bold: Some(true),
..Default::default()
});
title_p.runs.push(title_r);
title_p.properties = Some(CT_PPr {
space_after: Some(Twips(120)),
..Default::default()
});
toc_paragraphs.push(title_p);
for heading in &headings {
let mut p = CT_P::new();
// Indentation based on heading level (each level indented 360 twips = 0.25")
let indent = Twips(360 * (heading.level as i32 - 1));
p.properties = Some(CT_PPr {
tabs: Some(right_tab.clone()),
ind_left: if indent.0 > 0 { Some(indent) } else { None },
..Default::default()
});
// Run with heading text
let text_run = CT_R::new(&heading.text);
p.runs.push(text_run);
// Tab run (separates text from page number)
p.runs.push(CT_R {
alt_drawings: Vec::new(),
properties: None,
content: vec![rdocx_oxml::text::RunContent::Tab],
extra_xml: Vec::new(),
extra_xml_positions: Vec::new(),
});
// Wrap the text run in a hyperlink to the bookmark
p.hyperlinks.push(HyperlinkSpan {
rel_id: None,
anchor: Some(heading.bookmark_name.clone()),
tooltip: None,
doc_location: None,
run_start: 0,
run_end: 1, // Just the text run, not the tab
extra_attributes: Vec::new(),
extra_xml: Vec::new(),
preserved_raw_before: None,
});
toc_paragraphs.push(p);
}
let field_run = |xml: String| {
let mut run = CT_R::new("");
run.content.clear();
run.extra_xml.push(xml.into_bytes());
run.extra_xml_positions.push(0);
run
};
toc_paragraphs[0]
.runs
.push(field_run("<w:fldChar w:fldCharType=\"begin\"/>".to_owned()));
toc_paragraphs[0].runs.push(field_run(format!(
"<w:instrText>TOC \\o \"1-{max_level}\" \\h</w:instrText>"
)));
toc_paragraphs[0].runs.push(field_run(
"<w:fldChar w:fldCharType=\"separate\"/>".to_owned(),
));
toc_paragraphs
.last_mut()
.expect("the TOC title exists")
.runs
.push(field_run("<w:fldChar w:fldCharType=\"end\"/>".to_owned()));
// Step 4: Insert TOC paragraphs at the specified index
let insert_at = index.min(self.document.body.content.len());
for (i, p) in toc_paragraphs.into_iter().enumerate() {
self.document
.body
.content
.insert(insert_at + i, BodyContent::Paragraph(p));
}
self.identifiers = identifiers;
self.invalidate_layout();
Ok(())
}
/// Numeric `_TocN` bookmark suffixes already present in the body.
fn toc_bookmark_suffixes(&self) -> HashSet<u64> {
let mut suffixes = HashSet::new();
visit_body_paragraphs(&self.document.body.content, false, &mut |p| {
for marker in &p.bookmark_markers {
let Some(name) = marker.name() else {
continue;
};
let Some(after) = name.strip_prefix("_Toc") else {
continue;
};
if let Ok(suffix) = after.parse::<u64>() {
suffixes.insert(suffix);
}
}
});
suffixes
}
/// Width of the text column in twips: page width less both side margins.
///
/// Falls back to the US Letter default (6.5") when the section does not
/// specify a size, and never returns a non-positive width.
fn text_width_twips(&self) -> i32 {
const DEFAULT_TEXT_WIDTH: i32 = 9360;
let Some(sect) = self.document.body.sect_pr.as_ref() else {
return DEFAULT_TEXT_WIDTH;
};
let page_width = sect.page_width.map(|w| w.0).unwrap_or(12240);
let left = sect.margin_left.map(|m| m.0).unwrap_or(1440);
let right = sect.margin_right.map(|m| m.0).unwrap_or(1440);
let width = i64::from(page_width) - i64::from(left) - i64::from(right);
match i32::try_from(width) {
Ok(width) if width > 0 => width,
_ => DEFAULT_TEXT_WIDTH,
}
}
/// Detect heading level from a paragraph's style ID.
fn detect_heading_level_for_toc(para: &CT_P) -> Option<u32> {
let ppr = para.properties.as_ref()?;
let style_id = ppr.style_id.as_deref()?;
let rest = style_id.strip_prefix("Heading")?;
rest.parse::<u32>().ok().filter(|n| (1..=9).contains(n))
}
// ---- Placeholder replacement ----
/// Replace all occurrences of `placeholder` with `replacement` throughout the document.
///
/// Searches body paragraphs, tables (including nested), headers, footers,
/// footnotes, endnotes, the text boxes of the body, headers and footers,
/// and the chart labels of the body, and the content controls at every
/// level of them, inline controls included. It reads the text
/// [`crate::Paragraph::text`] reads: the text of a tracked insertion or
/// move destination, replaced inside that revision, and not deleted or
/// moved-away text. A text box inside a deleted run is the exception, as
/// the text box pass rewrites it like any other text box. A text box
/// inside a note, and the separator and continuation entries of the notes
/// parts, are never searched. Handles placeholders split across
/// multiple runs. A match that straddles a content-control or a
/// tracked-insertion boundary is not replaced. Returns the total number
/// of replacements made.
///
/// A `replacement` that contains `placeholder` is substituted once, not
/// repeatedly.
///
/// A `replacement` holding a character XML 1.0 cannot carry is accepted
/// here and refused when the document is saved. [`Self::try_replace_text`]
/// refuses it at once.
pub fn replace_text(&mut self, placeholder: &str, replacement: &str) -> usize {
let mut candidate = self.clone_for_staging();
let count = candidate
.replace_batch(&[(placeholder, replacement)])
.expect("text replacement package preflight failed");
self.commit_staged_mutation(candidate);
count
}
/// Fallible twin of [`Self::replace_text`].
///
/// The complete replacement is staged, serialized, and reopened before it
/// replaces the live document. A preflight failure leaves `self` unchanged.
/// A `replacement` holding a character XML 1.0 cannot carry, such as
/// U+0001, is refused with an error naming it and its position, as
/// python-docx refuses such text.
pub fn try_replace_text(&mut self, placeholder: &str, replacement: &str) -> Result<usize> {
oxml_core::xml::reject_non_xml_characters("replacement text", replacement)?;
let mut candidate = self.clone_for_staging();
let count = candidate.replace_batch(&[(placeholder, replacement)])?;
self.commit_staged_mutation(candidate);
Ok(count)
}
/// Replace literal text only within one checked paragraph, table or block control.
/// Matching retains run formatting and never crosses paragraph or wrapper boundaries.
/// Count mismatch and preparation failures leave the complete document unchanged.
pub fn try_replace_text_at(
&mut self,
location: &ContentLocation,
placeholder: &str,
replacement: &str,
expected: Option<usize>,
) -> Result<std::result::Result<usize, ReplacementCountMismatch>> {
self.try_replace_scoped(
ScopedReplacementTarget::Item(location),
placeholder,
replacement,
expected,
)
}
/// Replace text in exactly one physical body table cell or one of its paragraphs.
/// Whole-cell scope includes supported nested tables and controls.
#[doc(hidden)]
pub fn try_replace_text_in_cell(
&mut self,
cell: (usize, usize, usize),
paragraph: Option<usize>,
placeholder: &str,
replacement: &str,
expected: Option<usize>,
) -> Result<std::result::Result<usize, ReplacementCountMismatch>> {
self.try_replace_scoped(
ScopedReplacementTarget::Cell {
coordinates: cell,
paragraph,
},
placeholder,
replacement,
expected,
)
}
/// Replace literal text only in one checked related physical table cell.
#[doc(hidden)]
pub fn try_replace_text_in_story_cell(
&mut self,
story: &StoryId,
placeholder: &str,
replacement: &str,
expected: Option<usize>,
) -> Result<std::result::Result<usize, ReplacementCountMismatch>> {
self.try_replace_scoped(
ScopedReplacementTarget::RelatedCell(story),
placeholder,
replacement,
expected,
)
}
fn try_replace_scoped(
&mut self,
target: ScopedReplacementTarget<'_>,
placeholder: &str,
replacement: &str,
expected: Option<usize>,
) -> Result<std::result::Result<usize, ReplacementCountMismatch>> {
oxml_core::xml::reject_non_xml_characters("replacement text", replacement)?;
let mut candidate = self.clone_for_staging();
let (story, path, paragraph) = match target {
ScopedReplacementTarget::Item(location) => {
if location.is_end
|| !matches!(
location.item_kind,
StoryItemKind::Paragraph
| StoryItemKind::Table
| StoryItemKind::ContentControl
)
{
return Err(Error::Other("scoped replacement requires an existing paragraph, table or content control".into()));
}
candidate.story_range_paragraph_source(location)?;
(location.story.clone(), Some(location.clone()), None)
}
ScopedReplacementTarget::RelatedCell(story) => {
if story.kind() != StoryKind::TableCell {
return Err(Error::Other(
"scoped cell replacement needs a table-cell owner".to_owned(),
));
}
candidate.story_source_and_owner(story)?;
(story.clone(), None, None)
}
ScopedReplacementTarget::Cell {
coordinates: (table, row, cell),
paragraph,
} => {
let selected = candidate
.table(table)
.and_then(|t| t.cell(row, cell).map(|c| c.inner as *const CT_Tc))
.ok_or_else(|| {
Error::Other(format!("cell {table}:{row}:{cell} is out of range"))
})?;
let xml = candidate.document.to_xml()?;
let owners = scan_story_owners(&xml, StoryKind::Body)?;
let mut found = None;
for owner in owners
.iter()
.filter(|owner| owner.kind == StoryKind::TableCell)
{
let (content, mut ordinal) = modeled_main_cell_route(&xml, owner)?;
let modeled = match candidate.document.body.content.get_mut(content) {
Some(BodyContent::Table(t)) => nth_cell_in_table(t, &mut ordinal),
Some(BodyContent::ContentControl(c)) => {
nth_cell_in_control(c, &mut ordinal)
}
_ => None,
};
if modeled.is_some_and(|c| std::ptr::eq(selected, c)) {
found = Some(StoryId {
kind: owner.kind,
part_name: candidate.doc_part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
});
break;
}
}
(
found.ok_or_else(|| {
Error::Other("cell has no checked physical story owner".into())
})?,
None,
paragraph,
)
}
};
// Flush only the private candidate before choosing the preserved physical span.
candidate.prepare_staged_package()?;
let (canonical, canonical_owner) = candidate.story_source_and_owner(&story)?;
let (source, owner) = candidate.fragment_story_source_and_owner(&story)?;
let canonical_owners = scan_story_owners(&canonical.xml, canonical.root_kind)?;
let actual_owners = scan_story_owners(&source.xml, source.root_kind)?;
if canonical_owners
.iter()
.map(|o| (o.kind, o.owner_index))
.collect::<Vec<_>>()
!= actual_owners
.iter()
.map(|o| (o.kind, o.owner_index))
.collect::<Vec<_>>()
{
return Err(Error::Other(
"scoped replacement namespace context changes physical ownership".into(),
));
}
let (canonical_span, span) = if let Some(location) = path {
let canonical_items = scan_story_items(&canonical.xml, &canonical_owner)?;
let index = location.index_path[0];
let selected = canonical_items.get(index).ok_or(StoryError::OutOfBounds {
index,
len: canonical_items.len(),
})?;
if !selected.direct_owner_child {
return Err(Error::Other(
"scoped replacement requires a block owner item".into(),
));
}
let canonical_direct = direct_story_content_items(&canonical.xml, &canonical_owner)?;
let actual_direct = direct_story_content_items(&source.xml, &owner)?;
let slot = canonical_direct
.iter()
.position(|item| item.full == selected.full)
.ok_or_else(|| {
Error::Other("scoped replacement has no direct physical slot".into())
})?;
let item = actual_direct.get(slot).ok_or(StoryError::OutOfBounds {
index: slot,
len: actual_direct.len(),
})?;
if item.kind != selected.kind {
return Err(StoryError::KindMismatch {
expected: selected.kind,
actual: item.kind,
}
.into());
}
if let [_, ordinal] = location.index_path.as_slice() {
let canonical_paragraphs = scan_story_control_paragraphs(&canonical.xml, selected)?;
let paragraphs = scan_story_control_paragraphs(&source.xml, item)?;
let select = |paragraphs: &[Range<usize>]| {
paragraphs
.get(*ordinal)
.cloned()
.ok_or(StoryError::OutOfBounds {
index: *ordinal,
len: paragraphs.len(),
})
};
(select(&canonical_paragraphs)?, select(¶graphs)?)
} else {
(selected.full.clone(), item.full.clone())
}
} else {
(canonical_owner.full.clone(), owner.full.clone())
};
if source.part_name == candidate.doc_part_name {
candidate.prove_scoped_main_source(
&canonical.xml,
canonical_span,
&source.xml,
span.clone(),
)?;
} else if canonical.xml != source.xml || canonical_span != span {
return Err(Error::Other(
"scoped related-story source correspondence changed".into(),
));
}
let part = source.part_name.clone();
let mut xml = source.xml.into_owned();
let scope = story_namespace_scope_at(&xml, span.start)?;
let mut raw = close_content_fragment_namespaces(&xml[span.clone()], &scope)?;
let count = rdocx_oxml::placeholder::try_replace_in_block(
&mut raw,
paragraph,
placeholder,
replacement,
)?;
if let Some(expected) = expected
&& count != expected
{
return Ok(Err(ReplacementCountMismatch {
index: 0,
placeholder: placeholder.into(),
expected,
found: count,
}));
}
if count == 0 {
return Ok(Ok(0));
}
if let Some(edits) = paired_scoped_edits(
&xml,
&owner,
&PairedReplacement {
span: &span,
paragraph,
placeholder,
replacement,
count,
},
)? {
for edit in edits.into_iter().rev() {
xml.splice(edit.range, edit.raw);
}
} else {
xml.splice(span, raw);
}
set_story_source_xml(&mut candidate, &part, xml)?;
candidate.prepare_staged_package()?;
let limits = PackageReadLimits::UNBOUNDED;
#[cfg(test)]
let limits = if FAIL_NEXT_SCOPED_REOPEN.replace(false) {
PackageReadLimits {
max_entries: 0,
..limits
}
} else {
limits
};
let reopened = candidate.reopen_prepared_staged_with_limits(limits)?;
self.commit_staged_mutation(reopened);
Ok(Ok(count))
}
// A private model probe distinguishes physical positions even when siblings
// contain identical text. Producer normalization is allowed, raw output is
// still edited only at the proved retained span.
fn prove_scoped_main_source(
&self,
canonical: &[u8],
canonical_span: Range<usize>,
original: &[u8],
retained_span: Range<usize>,
) -> Result<()> {
let restored = self.namespace_restored_main_source()?;
let (boundaries, _) = comment_namespace_source_boundaries(canonical, &restored)?
.ok_or_else(|| {
Error::Other("scoped canonical namespace source changed payload".into())
})?;
let start = *boundaries.get(&canonical_span.start).ok_or_else(|| {
Error::Other("scoped start has no physical namespace boundary".into())
})?;
let end = *boundaries
.get(&canonical_span.end)
.ok_or_else(|| Error::Other("scoped end has no physical namespace boundary".into()))?;
// One typed serialization establishes the same canonical defaults and
// root namespace inventory on both sides before model comparison.
let normalize = |xml: &[u8]| -> Result<CT_Document> {
Ok(CT_Document::from_xml(
&CT_Document::from_xml(xml)?.to_xml()?,
)?)
};
let baseline = normalize(original)?;
if normalize(&self.document.to_xml()?)? != baseline || normalize(&restored)? != baseline {
return Err(Error::Other(
"scoped source has no equivalent checked model projection".into(),
));
}
let mut identity = 0usize;
let marker = loop {
let marker = format!("rdocxScopedPositionProof{identity}");
if !original
.windows(marker.len())
.any(|bytes| bytes == marker.as_bytes())
&& !restored
.windows(marker.len())
.any(|bytes| bytes == marker.as_bytes())
{
break marker;
}
identity = identity
.checked_add(1)
.ok_or_else(|| Error::Other("scoped source proof identity exhausted".into()))?;
};
let scope = story_namespace_scope_at(&restored, start)?;
let selected_closed = close_content_fragment_namespaces(&restored[start..end], &scope)?;
let mut reader = NsReader::from_reader(selected_closed.as_slice());
let (namespace, event) = reader
.read_resolved_event()
.map_err(|error| Error::Other(error.to_string()))?;
let root = match event {
Event::Start(element) | Event::Empty(element) => element,
_ => return Err(Error::Other("scoped source proof has no root".into())),
};
if !word_element(&namespace) {
return Err(Error::Other(
"scoped source proof needs a qualified Word block".into(),
));
}
let paragraph =
format!(r#"<w:p xmlns:w="{WORD_NAMESPACE}"><w:r><w:t>{marker}</w:t></w:r></w:p>"#);
let sentinel = match root.local_name().as_ref() {
b"p" => paragraph,
b"tbl" => format!(
r#"<w:tbl xmlns:w="{WORD_NAMESPACE}"><w:tr><w:tc>{paragraph}</w:tc></w:tr></w:tbl>"#
),
b"tc" => format!(r#"<w:tc xmlns:w="{WORD_NAMESPACE}">{paragraph}</w:tc>"#),
b"sdt" => format!(
r#"<w:sdt xmlns:w="{WORD_NAMESPACE}"><w:sdtPr/><w:sdtContent>{paragraph}</w:sdtContent></w:sdt>"#
),
_ => return Err(Error::Other("unsupported scoped source proof kind".into())),
};
let mut checked_probe = restored;
checked_probe.splice(start..end, sentinel.bytes());
let mut retained_probe = original.to_vec();
retained_probe.splice(retained_span, sentinel.bytes());
let checked_model = normalize(&checked_probe)?;
let retained_model = normalize(&retained_probe)?;
let occurrences = |model: &CT_Document| -> Result<usize> {
Ok(model
.to_xml()?
.windows(marker.len())
.filter(|bytes| *bytes == marker.as_bytes())
.count())
};
if occurrences(&checked_model)? != 1
|| occurrences(&retained_model)? != 1
|| checked_model != retained_model
{
return Err(Error::Other(
"scoped selection does not identify the same physical source".into(),
));
}
Ok(())
}
/// Replace multiple placeholders at once. Returns total replacements.
///
/// Cheaper than calling [`Self::replace_text`] per entry: the document is
/// serialised and re-parsed once for the whole batch rather than once per
/// placeholder.
pub fn replace_all(&mut self, replacements: &std::collections::HashMap<&str, &str>) -> usize {
let pairs: Vec<(&str, &str)> = replacements.iter().map(|(k, v)| (*k, *v)).collect();
let mut candidate = self.clone_for_staging();
let count = candidate
.replace_batch(&pairs)
.expect("text replacement package preflight failed");
self.commit_staged_mutation(candidate);
count
}
/// Replace literal pairs in order, all or nothing, and return the count of
/// each pair.
///
/// Each pair searches what [`Self::replace_text`] searches and runs over
/// the whole document after the pairs before it, so a later pair sees the
/// text an earlier one wrote. A pair may give the count it expects. When a
/// pair finds another count, the document is left unchanged and the inner
/// error names that pair. A pair without an expected count may find
/// nothing. The outer error is a staging failure, which also leaves the
/// document unchanged.
pub fn try_replace_all_expected(
&mut self,
pairs: &[(&str, &str, Option<usize>)],
) -> Result<std::result::Result<Vec<usize>, ReplacementCountMismatch>> {
if pairs.is_empty() {
return Ok(Ok(Vec::new()));
}
let mut candidate = self.clone_for_staging();
let mut counts = Vec::with_capacity(pairs.len());
for (index, &(placeholder, replacement, expected)) in pairs.iter().enumerate() {
let found = candidate.replace_batch(&[(placeholder, replacement)])?;
if let Some(expected) = expected
&& expected != found
{
return Ok(Err(ReplacementCountMismatch {
index,
placeholder: placeholder.to_owned(),
expected,
found,
}));
}
counts.push(found);
}
self.commit_staged_mutation(candidate);
Ok(Ok(counts))
}
/// Render scalar and structural template tags from structured JSON data.
///
/// Tags use `{{ path.to.value }}` syntax and may cross ordinary Word run
/// boundaries. String, number and boolean leaves render as text, while
/// `null` renders as an empty string. Missing paths, malformed tags, and
/// object or array leaves return an error without changing the document.
/// Dedicated main-body paragraphs and table rows may contain nested
/// `{% for item in path %}` or `{% if path %}` blocks with their matching
/// end markers. Loop paths require arrays and introduce lexical scopes.
/// Conditions use JSON truthiness, where false, null, zero, and empty
/// strings or collections are false.
///
/// This additive API is native-only. Python, WASM, and CLI binding surfaces
/// remain unchanged and continue to preserve documents rendered here.
pub fn render_template(&mut self, data: &serde_json::Value) -> Result<usize> {
crate::template::render(self, data)
}
pub(crate) fn template_numbering_reference_exists(&self, num_id: u32, level: u32) -> bool {
self.numbering
.as_ref()
.and_then(|numbering| numbering.get_abstract_num_for(num_id))
.is_some_and(|abstract_numbering| {
abstract_numbering
.levels
.iter()
.any(|candidate| candidate.ilvl == level)
})
}
pub(crate) fn template_sources(&mut self) -> Result<Vec<String>> {
self.flush_to_package()?;
let mut sources = crate::template::body_sources(&self.document);
for (rel_id, is_header) in self.header_footer_rel_ids() {
if let Some(header_footer) = self.load_header_footer(&rel_id, is_header) {
sources.extend(rdocx_oxml::placeholder::header_footer_replaceable_texts(
&header_footer,
));
}
}
for (part_name, _) in self.raw_text_bearing_part_names() {
if let Some(xml) = self.package.get_part(&part_name) {
sources.extend(rdocx_oxml::placeholder::xml_part_replaceable_texts(xml)?);
}
}
for part_name in self.note_part_names() {
if let Some(xml) = self.package.get_part(&part_name) {
sources.extend(rdocx_oxml::placeholder::notes_part_replaceable_texts(xml)?);
}
}
for part_name in self.chart_part_names() {
if let Some(xml) = self.package.get_part(&part_name) {
sources.extend(crate::template::chart_sources(xml)?);
}
}
Ok(sources)
}
pub(crate) fn apply_template_pairs(&mut self, pairs: &[(&str, &str)]) -> Result<usize> {
self.replace_batch(pairs)
}
pub(crate) fn commit_template(&mut self, mut candidate: Self) {
candidate.invalidate_layout();
self.commit_staged_mutation(candidate);
}
/// Apply a batch of literal replacements across the whole document.
fn replace_batch(&mut self, pairs: &[(&str, &str)]) -> Result<usize> {
if pairs.is_empty() {
return Ok(0);
}
let mut count = 0;
// Typed model: body content, then headers and footers.
for (placeholder, replacement) in pairs {
count += self.replace_in_body(placeholder, replacement);
}
count += self.replace_in_headers_footers(pairs)?;
// Raw XML: text boxes, shapes and charts live in markup the typed model
// does not cover, so flush first and work on the serialised parts.
self.flush_to_package()?;
count += self.replace_in_xml_parts(pairs);
Ok(count)
}
/// Run the typed replacement over every body paragraph, those of tables
/// and content controls at every level included.
fn replace_in_body(&mut self, placeholder: &str, replacement: &str) -> usize {
let mut count = 0;
visit_body_paragraphs_mut(&mut self.document.body.content, &mut |paragraph| {
count +=
rdocx_oxml::placeholder::replace_in_paragraph(paragraph, placeholder, replacement);
});
count
}
/// Run the typed replacement over every referenced header and footer part.
fn replace_in_headers_footers(&mut self, pairs: &[(&str, &str)]) -> Result<usize> {
use rdocx_oxml::placeholder;
let mut count = 0;
for (rel_id, is_header) in self.header_footer_rel_ids() {
let Some(mut hf) = self.load_header_footer(&rel_id, is_header) else {
continue;
};
let mut part_count = 0;
for (placeholder, replacement) in pairs {
part_count +=
placeholder::replace_in_header_footer(&mut hf, placeholder, replacement);
}
if part_count > 0 {
self.save_header_footer(&rel_id, &hf, is_header)?;
count += part_count;
}
}
Ok(count)
}
/// Relationship IDs of every section's headers and footers, with a flag
/// saying which kind each one is.
fn header_footer_rel_ids(&self) -> Vec<(String, bool)> {
let mut rel_ids = Vec::new();
let mut seen = HashSet::new();
let mut collect = |section: &CT_SectPr| {
for reference in §ion.header_refs {
let rel_id = (reference.rel_id.clone(), true);
if seen.insert(rel_id.clone()) {
rel_ids.push(rel_id);
}
}
for reference in §ion.footer_refs {
let rel_id = (reference.rel_id.clone(), false);
if seen.insert(rel_id.clone()) {
rel_ids.push(rel_id);
}
}
};
visit_body_paragraphs(&self.document.body.content, false, &mut |paragraph| {
if let Some(section) = paragraph
.properties
.as_ref()
.and_then(|properties| properties.sect_pr.as_ref())
{
collect(section);
}
});
if let Some(section) = &self.document.body.sect_pr {
collect(section);
}
rel_ids
}
fn header_footer_rel_ids_for_layout(&self) -> HashSet<String> {
let even_headers_enabled = self.even_headers_enabled();
let mut rel_ids = HashSet::new();
let sections = rdocx_layout::engine::document_sections(&self.document);
for section in sections {
for reference in section.header_refs.iter().chain(§ion.footer_refs) {
if reference.hdr_ftr_type != HdrFtrType::Even || even_headers_enabled {
rel_ids.insert(reference.rel_id.clone());
}
}
}
rel_ids
}
fn even_headers_enabled(&self) -> bool {
self.even_and_odd_headers()
}
// ---- Regex replacement ----
/// Replace all regex matches with `replacement` throughout the document.
///
/// The `replacement` string supports capture groups: `$1`, `$2`, etc.
/// Searches the stories [`Self::replace_text`] searches, except chart
/// labels: body paragraphs, tables (including nested), headers, footers,
/// footnotes, endnotes and text boxes, the content controls at every level
/// of them and the text of tracked insertions. Returns the total number of replacements made, or an error if the
/// regex is invalid.
pub fn replace_regex(&mut self, pattern: &str, replacement: &str) -> Result<usize> {
oxml_core::xml::reject_non_xml_characters("replacement text", replacement)?;
let re =
regex::Regex::new(pattern).map_err(|e| Error::Other(format!("invalid regex: {e}")))?;
let mut candidate = self.clone_for_staging();
let count = candidate.replace_regex_compiled(&re, replacement)?;
self.commit_staged_mutation(candidate);
Ok(count)
}
/// Replace multiple regex patterns at once. Returns total replacements.
pub fn replace_all_regex(&mut self, patterns: &[(String, String)]) -> Result<usize> {
for (_, replacement) in patterns {
oxml_core::xml::reject_non_xml_characters("replacement text", replacement)?;
}
let compiled = patterns
.iter()
.map(|(pattern, replacement)| {
regex::Regex::new(pattern)
.map(|regex| (regex, replacement.as_str()))
.map_err(|error| Error::Other(format!("invalid regex: {error}")))
})
.collect::<Result<Vec<_>>>()?;
let mut candidate = self.clone_for_staging();
let mut count = 0;
for (regex, replacement) in &compiled {
count += candidate.replace_regex_compiled(regex, replacement)?;
}
self.commit_staged_mutation(candidate);
Ok(count)
}
/// Apply ordered regex pairs with checked owner errors and atomic expected counts.
/// Patterns use Rust regex syntax. Empty batches preserve the original state.
pub fn try_replace_all_regex_expected(
&mut self,
pairs: &[(&str, &str, Option<usize>)],
) -> Result<std::result::Result<Vec<usize>, ReplacementCountMismatch>> {
let compiled = pairs
.iter()
.map(|(pattern, replacement, expected)| {
oxml_core::xml::reject_non_xml_characters("replacement text", replacement)?;
let regex = regex::Regex::new(pattern)
.map_err(|error| Error::Other(format!("invalid regex: {error}")))?;
Ok((regex, *replacement, *expected))
})
.collect::<Result<Vec<_>>>()?;
if pairs.is_empty() {
return Ok(Ok(Vec::new()));
}
let mut candidate = self.clone_for_staging();
let mut counts = Vec::with_capacity(pairs.len());
for (index, (regex, replacement, expected)) in compiled.iter().enumerate() {
let found = candidate.replace_regex_compiled_checked(regex, replacement)?;
if let Some(expected) = expected
&& *expected != found
{
return Ok(Err(ReplacementCountMismatch {
index,
placeholder: pairs[index].0.to_owned(),
expected: *expected,
found,
}));
}
counts.push(found);
}
self.commit_staged_mutation(candidate.prepare_and_reopen_staged()?);
Ok(Ok(counts))
}
fn replace_regex_compiled_checked(
&mut self,
re: ®ex::Regex,
replacement: &str,
) -> Result<usize> {
use rdocx_oxml::placeholder;
self.flush_to_package()?;
let mut count = 0;
visit_body_paragraphs_mut(&mut self.document.body.content, &mut |paragraph| {
count += placeholder::replace_regex_in_paragraph(paragraph, re, replacement);
});
for (rel_id, is_header) in self.header_footer_rel_ids() {
let mut hf = self.load_header_footer(&rel_id, is_header).ok_or_else(|| {
Error::Other(format!("required header/footer {rel_id} cannot be read"))
})?;
let n = placeholder::replace_regex_in_header_footer(&mut hf, re, replacement);
if n > 0 {
self.save_header_footer(&rel_id, &hf, is_header)?;
count += n;
}
}
self.flush_to_package()?;
for (part_name, _) in self.text_bearing_part_names() {
let xml = self
.package
.get_part(&part_name)
.ok_or_else(|| Error::Other(format!("required text owner {part_name} is missing")))?
.to_vec();
let (changed, n) = placeholder::replace_regex_in_xml_part(&xml, re, replacement)?;
if changed != xml {
if part_name == self.doc_part_name {
self.document = CT_Document::from_xml(&changed)?;
}
self.package.set_part(&part_name, changed);
}
count += n;
}
for part_name in self.note_part_names() {
let xml = self
.package
.get_part(&part_name)
.ok_or_else(|| Error::Other(format!("required note owner {part_name} is missing")))?
.to_vec();
let (changed, n) = placeholder::replace_regex_in_notes_part(&xml, re, replacement)?;
if changed != xml {
if self.footnotes_part_name.as_deref() == Some(part_name.as_str()) {
self.footnotes = rdocx_oxml::footnotes::CT_Footnotes::from_xml(&changed)?;
}
self.package.set_part(&part_name, changed);
}
count += n;
}
Ok(count)
}
/// Internal: replace using a pre-compiled regex.
fn replace_regex_compiled(&mut self, re: ®ex::Regex, replacement: &str) -> Result<usize> {
use rdocx_oxml::placeholder;
let mut count = 0;
// Replace in body paragraphs, tables and content controls
visit_body_paragraphs_mut(&mut self.document.body.content, &mut |paragraph| {
count += placeholder::replace_regex_in_paragraph(paragraph, re, replacement);
});
// Replace in headers and footers
for (rel_id, is_header) in self.header_footer_rel_ids() {
let Some(mut hf) = self.load_header_footer(&rel_id, is_header) else {
continue;
};
let n = placeholder::replace_regex_in_header_footer(&mut hf, re, replacement);
if n > 0 {
self.save_header_footer(&rel_id, &hf, is_header)?;
count += n;
}
}
// Text boxes and shapes live in raw markup the typed model does not
// reach. `replace_text` has always covered them; do the same here so
// the two entry points search the same places.
self.flush_to_package()?;
count += self.replace_regex_in_xml_parts(re, replacement);
Ok(count)
}
/// Apply a regex replacement to the text-box content of the raw XML parts,
/// then to the notes of the footnotes and endnotes parts.
fn replace_regex_in_xml_parts(&mut self, re: ®ex::Regex, replacement: &str) -> usize {
let mut count = 0;
let mut changed = false;
for (part_name, _) in self.text_bearing_part_names() {
let Some(xml) = self.package.get_part(&part_name).map(<[u8]>::to_vec) else {
continue;
};
// A part can change without a count, when a replacement reaches
// only the uncounted copy of a text box that Word writes twice.
if let Ok((new_xml, n)) =
rdocx_oxml::placeholder::replace_regex_in_xml_part(&xml, re, replacement)
&& (n > 0 || new_xml != xml)
{
self.package.set_part(&part_name, new_xml);
count += n;
changed = true;
}
}
// Re-parse so the in-memory model reflects the edited markup; otherwise
// the next flush would write the pre-replacement document back out.
if changed
&& let Some(doc_xml) = self.package.get_part(&self.doc_part_name)
&& let Ok(doc) = CT_Document::from_xml(doc_xml)
{
self.document = doc;
}
count
+ self.rewrite_note_parts(|xml| {
rdocx_oxml::placeholder::replace_regex_in_notes_part(xml, re, replacement)
})
}
/// The main document part plus every header and footer part: everywhere
/// text boxes and shapes with replaceable text can appear.
fn text_bearing_part_names(&self) -> Vec<(String, Option<bool>)> {
let mut names = vec![(self.doc_part_name.clone(), None)];
for (rel_id, is_header) in self.header_footer_rel_ids() {
if let Some(part_name) = self.header_footer_part_name(&rel_id, is_header) {
names.push((part_name, Some(is_header)));
}
}
names
}
fn raw_text_bearing_part_names(&self) -> Vec<(String, Option<bool>)> {
let mut names = vec![(self.doc_part_name.clone(), None)];
for (rel_id, is_header) in self.header_footer_rel_ids() {
if let Some(part_name) = self.header_footer_part_name(&rel_id, is_header) {
names.push((part_name, Some(is_header)));
}
}
names
}
/// The footnotes and endnotes parts of the main document, each once.
fn note_part_names(&self) -> Vec<String> {
let mut names = Vec::new();
for relationship in self
.package
.get_part_rels(&self.doc_part_name)
.map(|relationships| relationships.items.as_slice())
.unwrap_or_default()
{
if matches!(
relationship.rel_type.as_str(),
rel_types::FOOTNOTES | rel_types::ENDNOTES
) && relationship_is_internal(relationship)
{
let name =
OpcPackage::resolve_rel_target(&self.doc_part_name, &relationship.target);
if !names.contains(&name) {
names.push(name);
}
}
}
names
}
/// Apply `rewrite` to every footnotes and endnotes part and keep each
/// part it changes. The typed footnotes are read again from the new
/// part, since the next flush writes only what they add.
fn rewrite_note_parts(
&mut self,
mut rewrite: impl FnMut(&[u8]) -> rdocx_oxml::error::Result<(Vec<u8>, usize)>,
) -> usize {
let mut count = 0;
for part_name in self.note_part_names() {
let Some(Ok((xml, n))) = self.package.get_part(&part_name).map(&mut rewrite) else {
continue;
};
if n == 0 {
continue;
}
if self.footnotes_part_name.as_deref() == Some(part_name.as_str()) {
self.footnotes =
rdocx_oxml::footnotes::CT_Footnotes::from_xml(&xml).unwrap_or_default();
}
self.package.set_part(&part_name, xml);
count += n;
}
count
}
fn chart_part_names(&self) -> Vec<String> {
self.package
.get_part_rels(&self.doc_part_name)
.map(|relationships| {
relationships
.get_all_by_type(rel_types::CHART)
.iter()
.filter(|relationship| relationship_is_internal(relationship))
.map(|relationship| {
OpcPackage::resolve_rel_target(&self.doc_part_name, &relationship.target)
})
.collect()
})
.unwrap_or_default()
}
/// Load a header/footer part by its relationship ID.
fn header_footer_part_name(&self, rel_id: &str, is_header: bool) -> Option<String> {
let expected_type = if is_header {
rel_types::HEADER
} else {
rel_types::FOOTER
};
let relationship = self
.package
.get_part_rels(&self.doc_part_name)?
.get_by_id(rel_id)
.filter(|relationship| {
relationship.rel_type == expected_type && relationship_is_internal(relationship)
})?;
Some(OpcPackage::resolve_rel_target(
&self.doc_part_name,
&relationship.target,
))
}
/// Load a header/footer part by its relationship ID and section-reference kind.
fn load_header_footer(&self, rel_id: &str, is_header: bool) -> Option<CT_HdrFtr> {
let part_name = self.header_footer_part_name(rel_id, is_header)?;
let xml = self.package.get_part(&part_name)?;
if !header_footer_story_has_expected_root(xml, is_header) {
return None;
}
CT_HdrFtr::from_xml(xml).ok()
}
fn first_usable_header_footer_reference<'a>(
&self,
references: &'a [HdrFtrRef],
hdr_type: HdrFtrType,
is_header: bool,
) -> Option<&'a HdrFtrRef> {
references.iter().find(|reference| {
reference.hdr_ftr_type == hdr_type
&& self
.load_header_footer(&reference.rel_id, is_header)
.is_some()
})
}
/// Run raw XML replacement on the text boxes of the body, header and footer
/// parts, on the chart parts, and on the notes of the footnotes and
/// endnotes parts.
///
/// This is called after the typed-model replacement and flush_to_package.
fn replace_in_xml_parts(&mut self, pairs: &[(&str, &str)]) -> usize {
use rdocx_oxml::placeholder::{replace_many_in_chart_xml, replace_many_in_xml_part};
let mut count = 0;
let mut changed = false;
// Collect part names for XML parts to process (text boxes/shapes)
for (part_name, _) in self.raw_text_bearing_part_names() {
if let Some(xml) = self.package.get_part(&part_name) {
let xml = xml.to_vec();
// A part can change without a count, when a replacement
// reaches only the uncounted copy of a text box that Word
// writes twice.
if let Ok((new_xml, n)) = replace_many_in_xml_part(&xml, pairs)
&& (n > 0 || new_xml != xml)
{
self.package.set_part(&part_name, new_xml);
count += n;
changed = true;
}
}
}
// Collect chart part names
for part_name in self.chart_part_names() {
if let Some(xml) = self.package.get_part(&part_name) {
let xml = xml.to_vec();
if let Ok((new_xml, n)) = replace_many_in_chart_xml(&xml, pairs)
&& n > 0
{
self.package.set_part(&part_name, new_xml);
count += n;
changed = true;
}
}
}
// Re-parse document from the (possibly modified) package XML
if changed
&& let Some(doc_xml) = self.package.get_part(&self.doc_part_name)
&& let Ok(doc) = CT_Document::from_xml(doc_xml)
{
self.document = doc;
}
count
+ self.rewrite_note_parts(|xml| {
rdocx_oxml::placeholder::replace_many_in_notes_part(xml, pairs)
})
}
// ---- Layout and PDF conversion ----
/// Transfer compatible reusable normal-layout work from another document.
///
/// The transfer succeeds only when every retained-work input other than
/// body content matches exactly. A rejected transfer leaves both engines
/// unchanged. Completed layout results remain owned by their documents.
pub fn transfer_reusable_layout_from(&mut self, source: &mut Document) -> bool {
let Ok(input) = self.build_layout_input() else {
return false;
};
let transferred = {
let mut source_engine = source
.normal_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
rdocx_layout::engine::Engine::take_if_compatible(&mut source_engine, &input)
};
let Some(transferred) = transferred else {
return false;
};
let mut receiver_engine = self
.normal_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*receiver_engine = Some(transferred);
true
}
/// Transfer compatible bundled-fallback layout work from another document.
///
/// The exact caller-font slice is part of the compatibility check. A
/// rejected transfer leaves both private engines unchanged. This method
/// never exposes the engine or permits system-font discovery.
pub fn transfer_reusable_bundled_fallback_layout_from(
&mut self,
source: &mut Document,
font_files: &[(&str, &[u8])],
) -> bool {
let Ok(input) = self.build_layout_input_with_fonts(font_files, RenderOptions::default())
else {
return false;
};
let transferred = {
let mut source_engine = source
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
rdocx_layout::engine::Engine::take_if_compatible(&mut source_engine, &input)
};
let Some(transferred) = transferred else {
return false;
};
let mut receiver_engine = self
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*receiver_engine = Some(transferred);
true
}
/// Transfer compatible alias-aware bundled-fallback layout work.
///
/// Caller-font bytes and the exact ordered alias slice are both part of
/// compatibility. A rejected transfer preserves both private engines.
pub fn transfer_reusable_bundled_fallback_layout_from_with_aliases(
&mut self,
source: &mut Document,
font_files: &[(&str, &[u8])],
font_aliases: &[(&str, &str)],
) -> bool {
let Ok(input) = self.build_layout_input_with_fonts(font_files, RenderOptions::default())
else {
return false;
};
let font_aliases = owned_font_aliases(font_aliases);
let transferred = {
let mut source_engine = source
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
rdocx_layout::engine::Engine::take_if_compatible_with_caller_aliases(
&mut source_engine,
&input,
&font_aliases,
)
};
let Some(transferred) = transferred else {
return false;
};
let mut receiver_engine = self
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*receiver_engine = Some(transferred);
true
}
/// Return the cached normal-font layout with its Word source map.
///
/// Repeated calls share the same accepted-view result until the document
/// is mutated.
pub fn layout(&self) -> Result<Arc<rdocx_layout::WordLayoutResult>> {
self.layout_with_options(RenderOptions::default())
}
/// Return a normal-font layout with the selected revision view.
///
/// Accepted-view calls share the normal layout cache. Tracked-view calls
/// remain uncached because they do not replace the accepted-view cache.
pub fn layout_with_options(
&self,
options: RenderOptions,
) -> Result<Arc<rdocx_layout::WordLayoutResult>> {
self.layout_for_options(options, false)
}
/// Return the bundled-font-only layout with its Word source map.
///
/// Repeated accepted-view calls share the deterministic layout cache. This
/// is the same snapshot used by deterministic PDF and raster render helpers.
pub fn layout_deterministic(&self) -> Result<Arc<rdocx_layout::WordLayoutResult>> {
self.layout_deterministic_with_options(RenderOptions::default())
}
/// Return a bundled-font-only layout with the selected revision view.
///
/// Accepted-view calls share the deterministic layout cache. Tracked-view
/// calls remain uncached because they do not replace the accepted-view cache.
pub fn layout_deterministic_with_options(
&self,
options: RenderOptions,
) -> Result<Arc<rdocx_layout::WordLayoutResult>> {
self.layout_for_options(options, true)
}
/// Measure one checked paragraph or table at a positive caller width.
///
/// Measurement uses deterministic bundled fonts and the same layout input,
/// revision projection, and block rules as whole-document layout. It does
/// not mutate the document or either reusable layout cache.
pub fn measure_content(
&self,
location: &ContentLocation,
width: Length,
options: RenderOptions,
) -> Result<ContentMeasurement> {
if width.to_emu() <= 0 {
return Err(Error::Other(
"content measurement width must be positive".to_owned(),
));
}
let (source, item) = self.story_item_source(location)?;
if !matches!(item.kind, StoryItemKind::Paragraph | StoryItemKind::Table) {
return Err(Error::Other(format!(
"story item kind {:?} cannot be measured",
item.kind
)));
}
let namespace_scope = story_namespace_scope_at(source.xml.as_ref(), item.full.start)?;
let fragment = close_content_fragment_namespaces(
&source.xml.as_ref()[item.full.clone()],
&namespace_scope,
)?;
let mut wrapped =
format!(r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body>"#).into_bytes();
wrapped.extend(fragment);
wrapped.extend_from_slice(b"</w:body></w:document>");
let mut parsed = CT_Document::from_xml(&wrapped)?;
let content = parsed
.body
.content
.pop()
.ok_or_else(|| Error::Other("measurable story item did not parse".to_owned()))?;
if !parsed.body.content.is_empty()
|| !matches!(
(&content, item.kind),
(BodyContent::Paragraph(_), StoryItemKind::Paragraph)
| (BodyContent::Table(_), StoryItemKind::Table)
)
{
return Err(Error::Other(
"measurable story item did not parse as one matching block".to_owned(),
));
}
let related_story_scope = self
.package
.get_part_rels(&self.doc_part_name)
.and_then(|relationships| {
relationships.items.iter().find(|relationship| {
relationship_is_internal(relationship)
&& matches!(
relationship.rel_type.as_str(),
rel_types::HEADER | rel_types::FOOTER
)
&& OpcPackage::resolve_rel_target(&self.doc_part_name, &relationship.target)
== location.story.part_name
})
})
.map(|relationship| relationship.id.as_str());
let input = self.build_layout_input_with_fonts(&[], options)?;
let (height_points, diagnostics) = rdocx_layout::measure_content_deterministic(
&input,
&content,
width.to_pt(),
related_story_scope,
)?;
Ok(ContentMeasurement {
height_points,
diagnostics,
})
}
/// Return an uncached layout using user-provided font files.
///
/// User-provided fonts take highest priority in font resolution. The
/// returned owned result retains the exact font bytes and Word source map
/// used by layout.
pub fn layout_with_fonts(
&self,
font_files: &[(&str, &[u8])],
) -> Result<rdocx_layout::WordLayoutResult> {
self.layout_with_fonts_and_options(font_files, RenderOptions::default())
}
/// Return an uncached caller-font layout with the selected revision view.
pub fn layout_with_fonts_and_options(
&self,
font_files: &[(&str, &[u8])],
options: RenderOptions,
) -> Result<rdocx_layout::WordLayoutResult> {
let input = self.build_layout_input_with_fonts(font_files, options)?;
#[cfg(test)]
record_layout_invocation();
Ok(rdocx_layout::layout_document_with_caller_fonts_and_provenance(&input)?)
}
/// Return an uncached layout using caller fonts over bundled fallbacks.
///
/// Caller faces have highest priority. Missing families resolve from the
/// deterministic bundled inventory, never from the system-font snapshot.
/// The returned result owns its source map and shares its heavy page and
/// font payloads internally.
pub fn layout_with_fonts_and_bundled_fallback(
&self,
font_files: &[(&str, &[u8])],
) -> Result<rdocx_layout::WordLayoutResult> {
self.layout_with_fonts_and_bundled_fallback_and_options(
font_files,
RenderOptions::default(),
)
}
/// Return a bundled-fallback caller-font layout for one revision view.
pub fn layout_with_fonts_and_bundled_fallback_and_options(
&self,
font_files: &[(&str, &[u8])],
options: RenderOptions,
) -> Result<rdocx_layout::WordLayoutResult> {
let input = self.build_layout_input_with_fonts(font_files, options)?;
#[cfg(test)]
record_layout_invocation();
let mut engine = self
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let engine = match engine.as_mut() {
Some(engine) => engine,
None => engine.insert(rdocx_layout::engine::Engine::new_deterministic()?),
};
engine.set_caller_font_aliases(&[]);
Ok(rdocx_layout::layout_document_with_reusable_engine(
engine, &input,
)?)
}
/// Return a reusable bundled-fallback layout with byte-free font aliases.
///
/// Exact embedded families retain priority. A caller alias is tried next,
/// before the existing mapped and generic fallbacks.
pub fn layout_with_fonts_aliases_and_bundled_fallback(
&self,
font_files: &[(&str, &[u8])],
font_aliases: &[(&str, &str)],
) -> Result<rdocx_layout::WordLayoutResult> {
self.layout_with_fonts_aliases_and_bundled_fallback_and_options(
font_files,
font_aliases,
RenderOptions::default(),
)
}
/// Return an alias-aware bundled-fallback layout for one revision view.
pub fn layout_with_fonts_aliases_and_bundled_fallback_and_options(
&self,
font_files: &[(&str, &[u8])],
font_aliases: &[(&str, &str)],
options: RenderOptions,
) -> Result<rdocx_layout::WordLayoutResult> {
let input = self.build_layout_input_with_fonts(font_files, options)?;
let font_aliases = owned_font_aliases(font_aliases);
#[cfg(test)]
record_layout_invocation();
let mut engine = self
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let engine = match engine.as_mut() {
Some(engine) => engine,
None => engine.insert(rdocx_layout::engine::Engine::new_deterministic()?),
};
engine.set_caller_font_aliases(&font_aliases);
Ok(rdocx_layout::layout_document_with_reusable_engine(
engine, &input,
)?)
}
fn build_layout_input_with_fonts(
&self,
font_files: &[(&str, &[u8])],
options: RenderOptions,
) -> Result<rdocx_layout::LayoutInput> {
if options.revision_style != rdocx_layout::RevisionStyle::Plain
&& options.revision_view != rdocx_layout::RevisionView::Tracked
{
return Err(Error::Other(
"revision style applies only with tracked view".to_owned(),
));
}
let mut input = self.build_layout_input()?;
input.revision_view = options.revision_view;
input.revision_style = options.revision_style;
input.fonts.extend(
font_files
.iter()
.map(|(family, data)| rdocx_layout::FontFile {
family: (*family).to_owned(),
data: data.to_vec(),
}),
);
Ok(input)
}
/// Render the document to PDF bytes.
///
/// This performs a full layout pass (font shaping, line breaking, pagination)
/// and then renders the result to a PDF document.
///
/// Font resolution order:
/// 1. Fonts embedded in the DOCX file (word/fonts/)
/// 2. System fonts when the default `system-fonts` feature is enabled
/// 3. Always-available bundled metric-compatible fonts
pub fn to_pdf(&self) -> Result<Vec<u8>> {
self.to_pdf_with_options(RenderOptions::default())
}
/// Render the document to PDF bytes with the selected revision view.
pub fn to_pdf_with_options(&self, options: RenderOptions) -> Result<Vec<u8>> {
let layout = self.layout_with_options(options)?;
Ok(oxml_pdf::render_to_pdf_checked(&layout.layout)?)
}
/// Render the document to PDF bytes using bundled fonts without system
/// font discovery.
///
/// The deterministic layout is cached independently from the normal-font
/// layout and is suitable for reproducible render baselines.
pub fn to_pdf_deterministic(&self) -> Result<Vec<u8>> {
self.to_pdf_deterministic_with_options(RenderOptions::default())
}
/// Render the document to the selected archival PDF profile using bundled fonts.
pub fn to_pdfa_deterministic(&self, profile: oxml_pdf::PdfConformance) -> Result<Vec<u8>> {
let layout = self.layout_for_options(RenderOptions::default(), true)?;
Ok(oxml_pdf::render_to_pdf_with_options(
&layout.layout,
oxml_pdf::PdfOptions::new(profile),
)?)
}
/// Encode one bundled-fallback caller-font archival bundle with its owned diagnostics.
pub fn to_pdfa_with_diagnostics(
&self,
profile: oxml_pdf::PdfConformance,
font_files: &[(&str, &[u8])],
options: RenderOptions,
) -> Result<(Vec<u8>, Vec<oxml_layout::Diagnostic>)> {
let layout =
self.layout_with_fonts_and_bundled_fallback_and_options(font_files, options)?;
let bytes = oxml_pdf::render_to_pdf_with_options(
&layout.layout,
oxml_pdf::PdfOptions::new(profile),
)?;
Ok((bytes, layout.layout.diagnostics.clone()))
}
/// Render the selected revision view to deterministic PDF bytes.
pub fn to_pdf_deterministic_with_options(&self, options: RenderOptions) -> Result<Vec<u8>> {
let layout = self.layout_for_options(options, true)?;
Ok(oxml_pdf::render_to_pdf_checked(&layout.layout)?)
}
/// Lay out caller fonts over the normal system and bundled fallback policy.
/// The returned bundle owns the exact face instances used by every renderer.
pub fn layout_with_additional_fonts_and_options(
&self,
font_files: &[(&str, &[u8])],
options: RenderOptions,
) -> Result<rdocx_layout::WordLayoutResult> {
let input = self.build_layout_input_with_fonts(font_files, options)?;
#[cfg(test)]
record_layout_invocation();
Ok(rdocx_layout::layout_document_with_provenance(&input)?)
}
/// Render caller fonts using the bundled fallback policy and selected revision view.
pub fn to_pdf_with_fonts_deterministic_and_options(
&self,
font_files: &[(&str, &[u8])],
options: RenderOptions,
) -> Result<Vec<u8>> {
let layout =
self.layout_with_fonts_and_bundled_fallback_and_options(font_files, options)?;
Ok(oxml_pdf::render_to_pdf_checked(&layout.layout)?)
}
/// Render caller fonts to the selected archival profile using bundled fallback.
pub fn to_pdfa_with_fonts_deterministic(
&self,
font_files: &[(&str, &[u8])],
profile: oxml_pdf::PdfConformance,
) -> Result<Vec<u8>> {
let layout = self.layout_with_fonts_and_bundled_fallback(font_files)?;
Ok(oxml_pdf::render_to_pdf_with_options(
&layout.layout,
oxml_pdf::PdfOptions::new(profile),
)?)
}
/// Render the document to PDF bytes with user-provided font files.
///
/// User-provided fonts take highest priority in font resolution.
///
/// # Arguments
/// * `font_files` - Additional font files to use. Each entry is `(family_name, font_bytes)`.
///
/// Font resolution order:
/// 1. User-provided fonts (this parameter)
/// 2. Fonts embedded in the DOCX file (word/fonts/)
/// 3. System fonts when the default `system-fonts` feature is enabled
/// 4. Always-available bundled metric-compatible fonts
pub fn to_pdf_with_fonts(&self, font_files: &[(&str, &[u8])]) -> Result<Vec<u8>> {
self.to_pdf_with_fonts_and_options(font_files, RenderOptions::default())
}
/// Render the selected revision view to PDF with user-provided fonts.
///
/// The caller fonts are added to the fonts `to_pdf` resolves from, in the
/// order [`Self::to_pdf_with_fonts`] lists. [`Self::layout_with_fonts`] is
/// the layout limited to the caller and embedded fonts.
pub fn to_pdf_with_fonts_and_options(
&self,
font_files: &[(&str, &[u8])],
options: RenderOptions,
) -> Result<Vec<u8>> {
let layout = self.layout_with_additional_fonts_and_options(font_files, options)?;
Ok(oxml_pdf::render_to_pdf_checked(&layout.layout)?)
}
/// Save the document as a PDF file.
pub fn save_pdf<P: AsRef<Path>>(&self, path: P) -> Result<()> {
self.save_pdf_with_options(path, RenderOptions::default())
}
/// Save the selected revision view as a PDF file.
pub fn save_pdf_with_options<P: AsRef<Path>>(
&self,
path: P,
options: RenderOptions,
) -> Result<()> {
let pdf_bytes = self.to_pdf_with_options(options)?;
std::fs::write(path, pdf_bytes)?;
Ok(())
}
/// Convert the document to a complete HTML document string.
pub fn to_html(&self) -> String {
let input = self.build_html_input();
rdocx_html::to_html_document(&input, &rdocx_html::HtmlOptions::default())
}
/// Convert the document to an HTML fragment (body content only, no `<html>` wrapper).
pub fn to_html_fragment(&self) -> String {
let input = self.build_html_input();
rdocx_html::to_html_fragment(&input, &rdocx_html::HtmlOptions::default())
}
/// Convert the document to Markdown.
pub fn to_markdown(&self) -> String {
let input = self.build_html_input();
rdocx_html::to_markdown(&input)
}
/// Build an HtmlInput from the document's current state.
fn build_html_input(&self) -> rdocx_html::HtmlInput {
use oxml_opc::relationship::rel_types;
use std::collections::HashMap;
let mut images: HashMap<String, rdocx_html::ImageData> = HashMap::new();
let mut hyperlink_urls: HashMap<String, String> = HashMap::new();
if let Some(rels) = self.package.get_part_rels(&self.doc_part_name) {
for rel in &rels.items {
match rel.rel_type.as_str() {
t if t == rel_types::IMAGE => {
if !relationship_is_internal(rel) {
continue;
}
let part_name =
OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
if let Some(data) = self.package.get_part(&part_name) {
let content_type = oxml_media::resolve(data, &part_name)
.content_type()
.to_owned();
images.insert(
rel.id.clone(),
rdocx_html::ImageData {
data: data.to_vec(),
content_type,
},
);
}
}
t if t == rel_types::HYPERLINK
&& rel.target_mode.as_ref().is_some_and(|m| m == "External") =>
{
hyperlink_urls.insert(rel.id.clone(), rel.target.clone());
}
_ => {}
}
}
}
rdocx_html::HtmlInput {
document: self.document.clone(),
styles: self.styles.clone(),
numbering: self.numbering.clone(),
images,
hyperlink_urls,
}
}
/// Render a single page of the document to PNG bytes.
///
/// # Arguments
/// * `page_index` - 0-based page index
/// * `dpi` - Resolution (72 = 1:1, 150 = standard, 300 = high quality)
pub fn render_page_to_png(&self, page_index: usize, dpi: f64) -> Result<Option<Vec<u8>>> {
self.render_page_to_png_with_options(page_index, dpi, RenderOptions::default())
}
/// Render one page to PNG with the selected revision view.
pub fn render_page_to_png_with_options(
&self,
page_index: usize,
dpi: f64,
options: RenderOptions,
) -> Result<Option<Vec<u8>>> {
let layout = self.layout_with_options(options)?;
Ok(oxml_pdf::render_page_to_png(
&layout.layout,
page_index,
dpi,
))
}
/// Render a single page to PNG using bundled fonts without system font
/// discovery.
///
/// # Arguments
/// * `page_index` - 0-based page index
/// * `dpi` - Resolution (72 = 1:1, 150 = standard, 300 = high quality)
pub fn render_page_to_png_deterministic(
&self,
page_index: usize,
dpi: f64,
) -> Result<Option<Vec<u8>>> {
self.render_page_to_png_deterministic_with_options(
page_index,
dpi,
RenderOptions::default(),
)
}
/// Render one page to deterministic PNG with the selected revision view.
pub fn render_page_to_png_deterministic_with_options(
&self,
page_index: usize,
dpi: f64,
options: RenderOptions,
) -> Result<Option<Vec<u8>>> {
let layout = self.layout_for_options(options, true)?;
Ok(oxml_pdf::render_page_to_png(
&layout.layout,
page_index,
dpi,
))
}
/// Render one zero-based page as a self-contained searchable SVG document.
///
/// An index beyond the laid-out document returns `None`. Layout diagnostics
/// precede path-specific SVG lowering diagnostics in the returned result.
pub fn render_page_to_svg(&self, page_index: usize) -> Result<Option<crate::SvgRenderResult>> {
self.render_page_to_svg_with_options(page_index, RenderOptions::default())
}
/// Render one page as SVG with the selected revision view.
pub fn render_page_to_svg_with_options(
&self,
page_index: usize,
options: RenderOptions,
) -> Result<Option<crate::SvgRenderResult>> {
let layout = self.layout_with_options(options)?;
Ok(crate::svg::render_page(&layout.layout, page_index))
}
/// Render one page as SVG using bundled fonts without system font discovery.
pub fn render_page_to_svg_deterministic(
&self,
page_index: usize,
) -> Result<Option<crate::SvgRenderResult>> {
self.render_page_to_svg_deterministic_with_options(page_index, RenderOptions::default())
}
/// Render one revision view as deterministic SVG using bundled fonts.
pub fn render_page_to_svg_deterministic_with_options(
&self,
page_index: usize,
options: RenderOptions,
) -> Result<Option<crate::SvgRenderResult>> {
let layout = self.layout_for_options(options, true)?;
Ok(crate::svg::render_page(&layout.layout, page_index))
}
/// Render selected zero-based pages to the requested image format.
pub fn render_pages(
&self,
page_indices: &[usize],
raster_options: oxml_pdf::RasterOptions,
) -> Result<oxml_pdf::RasterOutput> {
self.render_pages_with_options(page_indices, raster_options, RenderOptions::default())
}
/// Render selected zero-based pages with the selected revision view.
pub fn render_pages_with_options(
&self,
page_indices: &[usize],
raster_options: oxml_pdf::RasterOptions,
options: RenderOptions,
) -> Result<oxml_pdf::RasterOutput> {
let layout = self.layout_with_options(options)?;
Ok(oxml_pdf::render_pages(
&layout.layout,
page_indices,
raster_options,
)?)
}
/// Render selected zero-based pages using bundled fonts without system font discovery.
pub fn render_pages_deterministic(
&self,
page_indices: &[usize],
raster_options: oxml_pdf::RasterOptions,
) -> Result<oxml_pdf::RasterOutput> {
self.render_pages_deterministic_with_options(
page_indices,
raster_options,
RenderOptions::default(),
)
}
/// Render selected zero-based pages to deterministic images with the selected revision view.
pub fn render_pages_deterministic_with_options(
&self,
page_indices: &[usize],
raster_options: oxml_pdf::RasterOptions,
options: RenderOptions,
) -> Result<oxml_pdf::RasterOutput> {
let layout = self.layout_for_options(options, true)?;
Ok(oxml_pdf::render_pages(
&layout.layout,
page_indices,
raster_options,
)?)
}
/// Render all pages of the document to PNG bytes.
pub fn render_all_pages(&self, dpi: f64) -> Result<Vec<Vec<u8>>> {
self.render_all_pages_with_options(dpi, RenderOptions::default())
}
/// Render every page to PNG with the selected revision view.
pub fn render_all_pages_with_options(
&self,
dpi: f64,
options: RenderOptions,
) -> Result<Vec<Vec<u8>>> {
let layout = self.layout_with_options(options)?;
Ok(oxml_pdf::render_all_pages(&layout.layout, dpi))
}
/// Encode one normal-fallback bundle and return its owned diagnostics.
pub fn to_pdf_with_diagnostics(
&self,
font_files: &[(&str, &[u8])],
options: RenderOptions,
) -> Result<(Vec<u8>, Vec<oxml_layout::Diagnostic>)> {
let layout = self.layout_with_additional_fonts_and_options(font_files, options)?;
let bytes = oxml_pdf::render_to_pdf_checked(&layout.layout)?;
Ok((bytes, layout.layout.diagnostics.clone()))
}
/// Paint one PNG page from one bundle without rerendering for diagnostics.
pub fn render_page_to_png_with_diagnostics(
&self,
page_index: usize,
dpi: f64,
font_files: &[(&str, &[u8])],
options: RenderOptions,
) -> Result<(Option<Vec<u8>>, Vec<oxml_layout::Diagnostic>)> {
let layout = self.layout_with_additional_fonts_and_options(font_files, options)?;
let bytes = oxml_pdf::render_page_to_png(&layout.layout, page_index, dpi);
Ok((bytes, layout.layout.diagnostics.clone()))
}
/// Paint every PNG page once and return the same bundle's ordered facts.
pub fn render_all_pages_with_diagnostics(
&self,
dpi: f64,
font_files: &[(&str, &[u8])],
options: RenderOptions,
) -> Result<(Vec<Vec<u8>>, Vec<oxml_layout::Diagnostic>)> {
let layout = self.layout_with_additional_fonts_and_options(font_files, options)?;
let bytes = oxml_pdf::render_all_pages(&layout.layout, dpi);
Ok((bytes, layout.layout.diagnostics.clone()))
}
/// Paint explicit or all pages from one configured bundle, preserving TIFF.
pub fn render_pages_with_diagnostics(
&self,
page_indices: Option<&[usize]>,
raster_options: oxml_pdf::RasterOptions,
font_files: &[(&str, &[u8])],
options: RenderOptions,
) -> Result<(oxml_pdf::RasterOutput, Vec<oxml_layout::Diagnostic>)> {
let layout = self.layout_with_additional_fonts_and_options(font_files, options)?;
let all_pages;
let selected = match page_indices {
Some(selected) => selected,
None => {
all_pages = (0..layout.layout.pages.len()).collect::<Vec<_>>();
&all_pages
}
};
let bytes = oxml_pdf::render_pages(&layout.layout, selected, raster_options)?;
Ok((bytes, layout.layout.diagnostics.clone()))
}
/// Render caller-selected normal fonts to one zero-based PNG page.
pub fn render_page_to_png_with_fonts_and_options(
&self,
page_index: usize,
dpi: f64,
font_files: &[(&str, &[u8])],
options: RenderOptions,
) -> Result<Option<Vec<u8>>> {
let layout = self.layout_with_additional_fonts_and_options(font_files, options)?;
Ok(oxml_pdf::render_page_to_png(
&layout.layout,
page_index,
dpi,
))
}
/// Render caller-selected normal fonts to one searchable SVG page.
pub fn render_page_to_svg_with_fonts_and_options(
&self,
page_index: usize,
font_files: &[(&str, &[u8])],
options: RenderOptions,
) -> Result<Option<crate::SvgRenderResult>> {
let layout = self.layout_with_additional_fonts_and_options(font_files, options)?;
Ok(crate::svg::render_page(&layout.layout, page_index))
}
/// Render selected pages from one caller-selected normal-font bundle.
pub fn render_pages_with_fonts_and_options(
&self,
page_indices: &[usize],
raster_options: oxml_pdf::RasterOptions,
font_files: &[(&str, &[u8])],
options: RenderOptions,
) -> Result<oxml_pdf::RasterOutput> {
let layout = self.layout_with_additional_fonts_and_options(font_files, options)?;
Ok(oxml_pdf::render_pages(
&layout.layout,
page_indices,
raster_options,
)?)
}
/// Render every PNG page from one caller-selected normal-font bundle.
pub fn render_all_pages_with_fonts_and_options(
&self,
dpi: f64,
font_files: &[(&str, &[u8])],
options: RenderOptions,
) -> Result<Vec<Vec<u8>>> {
let layout = self.layout_with_additional_fonts_and_options(font_files, options)?;
Ok(oxml_pdf::render_all_pages(&layout.layout, dpi))
}
/// Return a cloned positioned page from the cached normal-font layout.
///
/// `page_index` is zero-based. An index beyond the document returns `None`.
pub fn layout_page(&self, page_index: usize) -> Result<Option<oxml_layout::PageFrame>> {
self.layout_page_with_options(page_index, RenderOptions::default())
}
/// Return one positioned page from the selected revision view.
pub fn layout_page_with_options(
&self,
page_index: usize,
options: RenderOptions,
) -> Result<Option<oxml_layout::PageFrame>> {
let layout = self.layout_with_options(options)?;
Ok(layout
.layout
.pages
.get(page_index)
.map(|page| page.as_ref().clone()))
}
/// Build a LayoutInput from the document's current state.
fn layout_story_body(&self, story: &StoryId) -> Result<rdocx_oxml::document::CT_Body> {
let (source, owner) = self.story_source_and_owner(story)?;
let xml = source.xml.as_ref();
let mut projected = br#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>"#.to_vec();
for item in direct_story_content_items(xml, &owner)? {
let scope = story_namespace_scope_at(xml, item.full.start)?;
projected.extend(close_content_fragment_namespaces(&xml[item.full], &scope)?);
}
projected.extend(b"</w:body></w:document>");
Ok(rdocx_oxml::document::CT_Document::from_xml(&projected)?.body)
}
pub(crate) fn build_layout_input(&self) -> Result<rdocx_layout::LayoutInput> {
let embedded_fonts = self.extract_embedded_fonts()?;
let modern_compatibility = self.settings.as_ref().is_some_and(|settings| {
settings.compatibility_settings().iter().any(|setting| {
setting.name == "compatibilityMode"
&& setting.uri == "http://schemas.microsoft.com/office/word"
&& setting
.value
.trim()
.parse::<u32>()
.is_ok_and(|mode| mode >= 15)
})
});
use oxml_opc::relationship::rel_types;
use rdocx_layout::{ImageData, LayoutInput};
use std::collections::HashMap;
let mut story_part_names = HashMap::from([(
rdocx_layout::WordStory::Document,
self.doc_part_name.clone(),
)]);
let mut headers: HashMap<String, CT_HdrFtr> = HashMap::new();
let mut footers: HashMap<String, CT_HdrFtr> = HashMap::new();
let mut images: HashMap<String, ImageData> = HashMap::new();
let mut charts = HashMap::new();
let mut hyperlink_urls: HashMap<String, String> = HashMap::new();
let mut footnotes = None;
let mut endnotes = None;
let active_header_footer_ids = self.header_footer_rel_ids_for_layout();
let even_headers_enabled = self.even_headers_enabled();
// Extract embedded fonts from the DOCX package
let fonts = Vec::new();
let insert_part_images =
|images: &mut HashMap<String, ImageData>, part_rel_id: &str, part_name: &str| {
let Some(part_relationships) = self.package.get_part_rels(part_name) else {
return;
};
for image_relationship in part_relationships.items.iter().filter(|item| {
item.rel_type == rel_types::IMAGE && relationship_is_internal(item)
}) {
let image_part =
OpcPackage::resolve_rel_target(part_name, &image_relationship.target);
if let Some(data) = self.package.get_part(&image_part) {
images.insert(
format!("{part_rel_id}\0{}", image_relationship.id),
ImageData {
data: data.to_vec(),
content_type: oxml_media::resolve(data, &image_part)
.content_type()
.to_owned(),
},
);
}
}
};
if let Some(rels) = self.package.get_part_rels(&self.doc_part_name) {
for rel in &rels.items {
match rel.rel_type.as_str() {
t if t == rel_types::HEADER => {
if !active_header_footer_ids.contains(&rel.id)
|| !relationship_is_internal(rel)
{
continue;
}
let part_name =
OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
if let Some(xml) = self.package.get_part(&part_name)
&& let Ok(hf) = CT_HdrFtr::from_xml(xml)
{
story_part_names.insert(
rdocx_layout::WordStory::Header {
relationship_id: rel.id.clone(),
},
part_name.clone(),
);
headers.insert(rel.id.clone(), hf);
}
insert_part_images(&mut images, &rel.id, &part_name);
}
t if t == rel_types::FOOTER => {
if !active_header_footer_ids.contains(&rel.id)
|| !relationship_is_internal(rel)
{
continue;
}
let part_name =
OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
if let Some(xml) = self.package.get_part(&part_name)
&& let Ok(hf) = CT_HdrFtr::from_xml(xml)
{
story_part_names.insert(
rdocx_layout::WordStory::Footer {
relationship_id: rel.id.clone(),
},
part_name.clone(),
);
footers.insert(rel.id.clone(), hf);
}
insert_part_images(&mut images, &rel.id, &part_name);
}
t if t == rel_types::IMAGE => {
if !relationship_is_internal(rel) {
continue;
}
let part_name =
OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
if let Some(data) = self.package.get_part(&part_name) {
let content_type = oxml_media::resolve(data, &part_name)
.content_type()
.to_owned();
images.insert(
rel.id.clone(),
ImageData {
data: data.to_vec(),
content_type,
},
);
}
}
t if t == rel_types::CHART => {
let chart = if !relationship_is_internal(rel) {
Err(format!("non-internal target {}", rel.target))
} else {
let part_name =
OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
match self.package.get_part(&part_name) {
Some(xml) => {
CT_ChartSpace::from_xml(xml).map(Box::new).map_err(|error| {
format!("malformed target {part_name}: {error}")
})
}
None => Err(format!("missing target {part_name}")),
}
};
charts.insert(rel.id.clone(), chart);
}
t if t == rel_types::HYPERLINK => {
if rel.target_mode.as_ref().is_some_and(|m| m == "External") {
hyperlink_urls.insert(rel.id.clone(), rel.target.clone());
}
}
t if t == rel_types::FOOTNOTES => {
if !relationship_is_internal(rel) {
continue;
}
let part_name =
OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
if let Some(xml) = self.package.get_part(&part_name) {
footnotes = rdocx_oxml::footnotes::CT_Footnotes::from_xml(xml).ok();
if let Some(stream) = &footnotes {
for note in &stream.footnotes {
story_part_names.insert(
rdocx_layout::WordStory::Footnote { id: note.id },
part_name.clone(),
);
}
}
}
}
t if t == rel_types::ENDNOTES => {
if !relationship_is_internal(rel) {
continue;
}
let part_name =
OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
if let Some(xml) = self.package.get_part(&part_name) {
endnotes = rdocx_oxml::footnotes::CT_Footnotes::from_xml(xml).ok();
if let Some(stream) = &endnotes {
for note in &stream.footnotes {
story_part_names.insert(
rdocx_layout::WordStory::Endnote { id: note.id },
part_name.clone(),
);
}
}
}
}
_ => {}
}
}
}
let chart_theme = if self.theme_dirty {
self.theme.clone()
} else {
self.package
.get_part_rels(&self.doc_part_name)
.and_then(|relationships| {
relationships.items.iter().rev().find(|relationship| {
relationship.rel_type == rel_types::THEME
&& relationship_is_internal(relationship)
})
})
.map(|relationship| {
OpcPackage::resolve_rel_target(&self.doc_part_name, &relationship.target)
})
.as_deref()
.and_then(|part_name| self.package.get_part(part_name))
.and_then(|xml| oxml_drawing::theme::CT_OfficeStyleSheet::from_xml(xml).ok())
.or_else(|| self.theme.clone())
}
.unwrap_or_else(oxml_drawing::theme::CT_OfficeStyleSheet::office_default);
let theme = Some(rdocx_oxml::theme::Theme::from(&chart_theme));
let mut document = self.document.clone();
if !even_headers_enabled {
for content in &mut document.body.content {
if let BodyContent::Paragraph(paragraph) = content
&& let Some(section) = paragraph
.properties
.as_mut()
.and_then(|properties| properties.sect_pr.as_mut())
{
section
.header_refs
.retain(|reference| reference.hdr_ftr_type != HdrFtrType::Even);
section
.footer_refs
.retain(|reference| reference.hdr_ftr_type != HdrFtrType::Even);
}
}
if let Some(section) = document.body.sect_pr.as_mut() {
section
.header_refs
.retain(|reference| reference.hdr_ftr_type != HdrFtrType::Even);
section
.footer_refs
.retain(|reference| reference.hdr_ftr_type != HdrFtrType::Even);
}
}
materialize_header_footer_inheritance(
&mut document,
even_headers_enabled,
&headers.keys().cloned().collect(),
&footers.keys().cloned().collect(),
);
if let Some(story) = self.stories().ok().and_then(|stories| {
stories
.into_iter()
.find(|story| story.kind == StoryKind::Body)
}) && let Ok((source, owner)) = self.story_source_and_owner(&story)
&& bind_text_box_source_owners(
&mut document.body,
source.xml.as_ref(),
source.root_kind,
owner.full,
)
.is_err()
{
// Binding is atomic. Unresolved physical owners retain their caches.
}
let mut story_bodies = HashMap::new();
if let Ok(stories) = self.stories() {
for (identity, part_name) in &story_part_names {
let kind = match identity {
rdocx_layout::WordStory::Header { .. } => StoryKind::Header,
rdocx_layout::WordStory::Footer { .. } => StoryKind::Footer,
rdocx_layout::WordStory::Footnote { .. } => StoryKind::Footnote,
rdocx_layout::WordStory::Endnote { .. } => StoryKind::Endnote,
_ => continue,
};
let story = match identity {
rdocx_layout::WordStory::Footnote { id } => {
self.footnote_story(*id).ok().flatten()
}
rdocx_layout::WordStory::Endnote { id } => {
self.endnote_story(*id).ok().flatten()
}
_ => stories
.iter()
.find(|story| story.kind == kind && &story.part_name == part_name)
.cloned(),
};
if let Some(story) = story
&& let Ok(mut body) = self.layout_story_body(&story)
{
if let Ok((source, owner)) = self.story_source_and_owner(&story)
&& bind_text_box_source_owners(
&mut body,
source.xml.as_ref(),
source.root_kind,
owner.full,
)
.is_err()
{
// The unbound authoritative projection remains renderable.
}
story_bodies.insert(identity.clone(), body);
}
}
}
// Rendering may consume a sole effects-only default without changing
// either authored style part or merging the effects style catalogue.
let mut layout_styles = self.styles.clone();
if layout_styles.get_default(StyleType::Table).is_none() {
let effects = self.package.get_part_rels(&self.doc_part_name).and_then(|rels| {
let mut candidates = rels.items.iter().filter(|rel| {
rel.rel_type == "http://schemas.microsoft.com/office/2007/relationships/stylesWithEffects"
&& relationship_is_internal(rel)
});
let rel = candidates.next()?;
if candidates.next().is_some() {
return None;
}
let target = OpcPackage::resolve_rel_target(&self.doc_part_name, &rel.target);
let xml = self.package.get_part(&target)?;
let parsed = CT_Styles::from_xml(xml).ok()?;
let mut style_ids = std::collections::HashSet::new();
if !parsed.styles.iter().all(|style| style_ids.insert(&style.style_id)) {
return None;
}
// The general projection omits empty style owners. Reject an
// effects catalogue whose physical owners are not all modeled,
// rather than accepting an invisible duplicate or default.
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
let mut depth = 0usize;
let mut root_seen = false;
let mut style_count = 0usize;
loop {
let (namespace, event) = reader.read_resolved_event_into(&mut buffer).ok()?;
let word = matches!(namespace, ResolveResult::Bound(Namespace(uri)) if uri == WORD_NAMESPACE.as_bytes());
let starts_scope = matches!(&event, Event::Start(_));
match event {
Event::Start(element) | Event::Empty(element) => {
if depth == 0 {
if root_seen || !word || element.local_name().as_ref() != b"styles" {
return None;
}
root_seen = true;
} else if depth == 1 && word && element.local_name().as_ref() == b"style" {
style_count += 1;
}
// Empty events do not open an ownership level.
if starts_scope {
depth += 1;
}
}
Event::End(_) => depth = depth.checked_sub(1)?,
Event::DocType(_) => return None,
Event::Text(text) if depth == 0 && !text.iter().all(u8::is_ascii_whitespace) => return None,
Event::Eof => break,
_ => {}
}
buffer.clear();
}
(root_seen && depth == 0 && style_count == parsed.styles.len()).then_some(parsed)
});
if let Some(effects) = effects {
let mut defaults = effects
.styles
.iter()
.filter(|style| style.style_type == StyleType::Table && style.is_default);
if let Some(default) = defaults.next()
&& !default.style_id.is_empty()
&& defaults.next().is_none()
&& default.based_on.is_none()
&& layout_styles.get_by_id(&default.style_id).is_none()
&& effects
.styles
.iter()
.filter(|style| style.style_id == default.style_id)
.count()
== 1
{
layout_styles.styles.push(default.clone());
}
}
}
Ok(LayoutInput {
sequence_snapshot: None,
revision_view: rdocx_layout::RevisionView::Accepted,
revision_style: rdocx_layout::RevisionStyle::Plain,
document,
automatic_hyphenation: self
.settings
.as_ref()
.is_some_and(CT_Settings::automatic_hyphenation),
default_tab_stop: self
.settings
.as_ref()
.and_then(CT_Settings::default_tab_stop),
clamp_tabs_past_margin: modern_compatibility,
legacy_table_positioning: !modern_compatibility,
modern_footnote_layout: modern_compatibility,
footnote_layout_like_word8: self
.settings
.as_ref()
.and_then(|settings| {
settings.compatibility_option(
rdocx_oxml::settings::CompatibilityOption::FootnoteLayoutLikeWW8,
)
})
.unwrap_or(false),
mirror_margins: self
.settings
.as_ref()
.and_then(CT_Settings::mirror_margins)
.unwrap_or(false),
gutter_at_top: self
.settings
.as_ref()
.and_then(CT_Settings::gutter_at_top)
.unwrap_or(false),
do_not_use_html_paragraph_auto_spacing: self
.settings
.as_ref()
.and_then(|settings| {
settings
.compatibility_option(CompatibilityOption::DoNotUseHTMLParagraphAutoSpacing)
})
.unwrap_or(false),
math_properties: self
.settings
.as_ref()
.and_then(CT_Settings::math_properties)
.cloned(),
note_defaults: [
self.settings
.as_ref()
.and_then(CT_Settings::footnote_properties)
.cloned(),
self.settings
.as_ref()
.and_then(CT_Settings::endnote_properties)
.cloned(),
],
styles: layout_styles,
numbering: self.numbering.clone(),
story_part_names,
story_bodies,
headers,
footers,
images,
charts,
chart_theme,
chart_color_map: oxml_drawing::color::ColorMap::default(),
core_properties: self.core_properties.as_deref().cloned(),
hyperlink_urls,
footnotes,
endnotes,
theme,
fonts,
embedded_fonts,
})
}
/// Prepare distinct owned font bodies and all aliases before layout allocation.
fn extract_embedded_fonts(&self) -> Result<Vec<rdocx_layout::input::EmbeddedFontSource>> {
if self.embed_true_type_fonts()? == Some(false) {
return Ok(Vec::new());
}
let Some(table) = &self.font_table else {
if self.font_table_part_name.is_some() {
return Err(Error::Other("font table is unmodeled".to_owned()));
}
return Ok(Vec::new());
};
let mut groups: Vec<(String, String, Vec<String>)> = Vec::new();
let mut by_part = HashMap::new();
let mut total = 0usize;
let mut alias_count = 0usize;
let mut alias_bytes = 0usize;
for font in table.fonts() {
for reference in &font.embedded_fonts {
let (part, body) = self.checked_embedded_font_source(reference)?;
if body.len() > 64 * 1024 * 1024
|| font.name.len() > 4096
|| reference.font_key.len() > 4096
{
return Err(Error::Other(
"embedded font input exceeds its bound".to_owned(),
));
}
let identity = part.to_ascii_lowercase();
let index = if let Some(&index) = by_part.get(&identity) {
index
} else {
total = total
.checked_add(body.len())
.ok_or_else(|| Error::Other("embedded font size overflow".to_owned()))?;
if total > 256 * 1024 * 1024 || groups.len() >= 4096 {
return Err(Error::Other(
"embedded font input exceeds its aggregate bound".to_owned(),
));
}
let index = groups.len();
groups.push((part, reference.font_key.clone(), Vec::new()));
by_part.insert(identity, index);
index
};
let group = &mut groups[index];
if group.1 != reference.font_key {
return Err(Error::Other(
"shared font part has conflicting obfuscation keys".to_owned(),
));
}
if !group.2.contains(&font.name) {
alias_count += 1;
alias_bytes = alias_bytes.checked_add(font.name.len()).ok_or_else(|| {
Error::Other("embedded font alias size overflow".to_owned())
})?;
if alias_count > 16384 || alias_bytes > 4 * 1024 * 1024 {
return Err(Error::Other(
"embedded font aliases exceed their bound".to_owned(),
));
}
group.2.push(font.name.clone());
}
}
}
let mut sources = Vec::new();
for (part, key, aliases) in groups {
let body = self.package.get_part(&part).expect("preflighted font part");
// Unsupported producer bytes remain in the original package and use fallback.
if let Some(data) = deobfuscate_odttf_with_key(body, &key) {
sources.push((aliases, Arc::from(data)));
}
}
Ok(sources)
}
/// Load font files from a directory and return them as FontFile entries.
///
/// This is useful for CLI tools that accept a `--font-dir` argument.
/// Supports `.ttf`, `.otf`, and `.ttc` files.
pub fn load_fonts_from_dir<P: AsRef<Path>>(dir: P) -> Vec<rdocx_layout::FontFile> {
let mut fonts = Vec::new();
let dir = dir.as_ref();
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if (ext == "ttf" || ext == "otf" || ext == "ttc")
&& let Ok(data) = std::fs::read(&path)
{
let family = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown")
.to_string();
fonts.push(rdocx_layout::FontFile { family, data });
}
}
}
fonts
}
/// Save a header/footer part back to the OPC package.
fn save_header_footer(&mut self, rel_id: &str, hf: &CT_HdrFtr, is_header: bool) -> Result<()> {
#[cfg(test)]
if FAIL_NEXT_HEADER_FOOTER_SERIALIZATION.replace(false) {
return Err(Error::Other(
"injected header or footer serialization failure".to_owned(),
));
}
let part_name = self
.header_footer_part_name(rel_id, is_header)
.ok_or_else(|| {
Error::Other(format!(
"header or footer relationship {rel_id} is missing, non-internal, or has the wrong type"
))
})?;
let xml = Self::serialize_hdr_ftr(hf, is_header)?;
self.package.set_part(&part_name, xml);
Ok(())
}
// ---- Document Intelligence API ----
/// Get all headings in the document as (level, text) pairs.
///
/// Detects heading paragraphs by their style ID (e.g. "Heading1", "Heading2")
/// among the body paragraphs, including those that body-level content
/// controls wrap. Table cells are not searched.
pub fn headings(&self) -> Vec<(u32, String)> {
let mut result = Vec::new();
for p in block_paragraphs(&self.document.body.content) {
if let Some(level) = Self::detect_heading_level_for_toc(p) {
result.push((level, p.text()));
}
}
result
}
/// Get a hierarchical outline of the document headings.
///
/// Returns a tree structure where each node contains the heading level,
/// text, and children (sub-headings).
pub fn document_outline(&self) -> Vec<OutlineNode> {
let headings = self.headings();
build_outline_tree(&headings)
}
/// Get information about all images in the document.
///
/// Returns metadata for each inline and anchored image found in body
/// paragraphs, table cells and content controls, in document order.
pub fn images(&self) -> Vec<ImageInfo> {
let mut result = Vec::new();
visit_all_drawings(&self.document.body.content, &mut |drawing| {
if let Some(inline) = &drawing.inline {
result.push(ImageInfo {
embed_id: inline.embed_id.clone(),
name: inline.name.clone(),
description: inline.description.clone(),
width_emu: inline.extent_cx.0,
height_emu: inline.extent_cy.0,
is_anchor: false,
});
}
if let Some(anchor) = &drawing.anchor {
result.push(ImageInfo {
embed_id: anchor.embed_id.clone(),
name: anchor.name.clone(),
description: anchor.description.clone(),
width_emu: anchor.extent_cx.0,
height_emu: anchor.extent_cy.0,
is_anchor: true,
});
}
});
result
}
/// Get information about all hyperlinks in the document.
///
/// Reads the body paragraphs, including those that body-level content
/// controls wrap. Table cells are not searched.
/// Resolves hyperlink relationship IDs to their target URLs where possible.
pub fn links(&self) -> Vec<LinkInfo> {
use oxml_opc::relationship::rel_types;
// Build a map of hyperlink rel_id -> target URL
let mut url_map = std::collections::HashMap::new();
if let Some(rels) = self.package.get_part_rels(&self.doc_part_name) {
for rel in &rels.items {
if rel.rel_type == rel_types::HYPERLINK
&& rel.target_mode.as_ref().is_some_and(|m| m == "External")
{
url_map.insert(rel.id.clone(), rel.target.clone());
}
}
}
let mut result = Vec::new();
for p in block_paragraphs(&self.document.body.content) {
for hl in &p.hyperlinks {
// `HyperlinkSpan`'s bounds are public and can be set by
// hand, so clamp rather than slice-panic on a bad range.
let start = hl.run_start.min(p.runs.len());
let end = hl.run_end.clamp(start, p.runs.len());
let text: String = p.runs[start..end].iter().map(|r| r.text()).collect();
let url = hl.rel_id.as_ref().and_then(|id| url_map.get(id)).cloned();
result.push(LinkInfo {
text,
url,
anchor: hl.anchor.clone(),
rel_id: hl.rel_id.clone(),
});
}
for field in p.complex_field_hyperlinks() {
let start = field.run_start.min(p.runs.len());
let end = field.run_end.clamp(start, p.runs.len());
let text: String = p.runs[start..end].iter().map(|run| run.text()).collect();
result.push(LinkInfo {
text,
url: Some(field.target),
anchor: None,
rel_id: None,
});
}
}
result
}
/// Count the number of words in the document.
///
/// Counts whitespace-separated tokens across all paragraphs (including
/// paragraphs inside table cells and content controls), leaving out the
/// table rows that accepting every tracked change removes, as
/// [`Self::text`] does.
pub fn word_count(&self) -> usize {
let mut count = 0;
visit_body_paragraphs(&self.document.body.content, true, &mut |paragraph| {
count += paragraph.text().split_whitespace().count();
});
count
}
/// Audit the document for accessibility issues.
///
/// Checks for common problems: missing image alt text, heading level gaps,
/// empty paragraphs, missing document metadata.
pub fn audit_accessibility(&self) -> Vec<AccessibilityIssue> {
let mut issues = Vec::new();
// Check: missing document title
if self.title().is_none() {
issues.push(AccessibilityIssue {
severity: IssueSeverity::Warning,
message: "Document has no title".to_string(),
});
}
// Check: missing document language (author as a proxy for basic metadata)
if self.author().is_none() {
issues.push(AccessibilityIssue {
severity: IssueSeverity::Info,
message: "Document has no author".to_string(),
});
}
// Check: images without alt text
let images = self.images();
for img in &images {
let has_alt = img
.description
.as_ref()
.is_some_and(|d| !d.is_empty() && d != "Background");
if !has_alt {
let name = img
.name
.as_deref()
.or(Some(&img.embed_id))
.unwrap_or("unknown");
issues.push(AccessibilityIssue {
severity: IssueSeverity::Error,
message: format!("Image \"{name}\" has no alt text"),
});
}
}
// Check: heading level gaps
let headings = self.headings();
let mut prev_level: Option<u32> = None;
for (level, text) in &headings {
if let Some(prev) = prev_level
&& *level > prev + 1
{
issues.push(AccessibilityIssue {
severity: IssueSeverity::Warning,
message: format!(
"Heading level gap: h{prev} -> h{level} (\"{}\")",
truncate_str(text, 40)
),
});
}
prev_level = Some(*level);
}
// Check direct body streaks only. An uncertain source omits this whole check.
let visibility = (|| {
let mut candidate = self.clone_for_staging();
candidate.flush_document_to_package()?;
let source = candidate
.package
.get_part(&candidate.doc_part_name)
.ok_or(Error::NoDocumentPart)?;
paragraph_visibility_from_main_source(
&candidate,
source,
ParagraphVisibilityScope::DirectBody,
)
})();
match visibility {
Ok(visibility) => {
let mut flags = visibility.into_iter();
let mut consecutive_empty = 0u32;
for content in &self.document.body.content {
if let BodyContent::Paragraph(_) = content {
if flags.next() == Some(false) {
consecutive_empty += 1;
if consecutive_empty >= 3 {
issues.push(AccessibilityIssue { severity: IssueSeverity::Info,
message: format!("{consecutive_empty} consecutive empty paragraphs (consider using spacing instead)") });
}
} else {
consecutive_empty = 0;
}
} else {
consecutive_empty = 0;
}
}
}
Err(error) => issues.push(AccessibilityIssue {
severity: IssueSeverity::Warning,
message: format!("Paragraph visibility could not be determined: {error}"),
}),
}
issues
}
}
impl Default for Document {
fn default() -> Self {
Self::new()
}
}
fn next_toc_bookmark_suffix(occupied: &HashSet<u64>, after: u64) -> Option<u64> {
after
.checked_add(1)
.filter(|candidate| !occupied.contains(candidate))
.or_else(|| (1..=u64::MAX).find(|candidate| !occupied.contains(candidate)))
}
#[derive(Clone)]
struct WordChartReference {
relationship_id: String,
value_span: Range<usize>,
owned: bool,
}
fn word_chart_references(xml: &[u8]) -> Result<Vec<WordChartReference>> {
let mut reader = NsReader::from_reader(xml);
let mut references = Vec::new();
let mut parents: Vec<(Vec<u8>, Vec<u8>, bool)> = Vec::new();
loop {
let before = reader.buffer_position() as usize;
let (namespace, event) = reader
.read_resolved_event()
.map_err(|e| Error::Other(e.to_string()))?;
let namespace = match namespace {
ResolveResult::Bound(Namespace(uri)) => uri.to_vec(),
_ => Vec::new(),
};
let after = reader.buffer_position() as usize;
let empty = matches!(event, Event::Empty(_));
match event {
Event::Start(element) | Event::Empty(element) => {
let local = element.local_name().as_ref().to_vec();
// Only the supported direct paragraph/run/drawing owner grants
// authority. A qualified lookalike under any opaque ancestor does not.
let owned = namespace == oxml_chart::C_NS.as_bytes()
&& local == b"chart"
&& parents.len() == 6
&& parents[0].0 == W_NS.as_bytes()
&& parents[0].1 == b"p"
&& parents[1].0 == W_NS.as_bytes()
&& parents[1].1 == b"r"
&& parents[2].0 == W_NS.as_bytes()
&& parents[2].1 == b"drawing"
&& parents[3].0 == drawing_ns::WP.as_bytes()
&& matches!(parents[3].1.as_slice(), b"inline" | b"anchor")
&& parents[4].0 == oxml_chart::A_NS.as_bytes()
&& parents[4].1 == b"graphic"
&& parents[5].0 == oxml_chart::A_NS.as_bytes()
&& parents[5].1 == b"graphicData"
&& parents[5].2;
let mut chart_data = false;
for attribute in element.attributes() {
let attribute = attribute.map_err(|e| Error::Other(e.to_string()))?;
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, element.decoder())
.map_err(|e| Error::Other(e.to_string()))?;
if attribute.key.as_ref() == b"uri" && value == oxml_chart::C_NS {
chart_data = true;
}
let (ns, name) = reader.resolver().resolve_attribute(attribute.key);
if matches!(ns,ResolveResult::Bound(Namespace(uri)) if uri==rdocx_oxml::namespace::R_NS.as_bytes())
{
let (start, end) =
story_attribute_value_span(&xml[before..after], attribute.key.as_ref())
.ok_or_else(|| {
Error::Other("chart relationship source span is absent".into())
})?;
references.push(WordChartReference {
relationship_id: value.into_owned(),
value_span: before + start..before + end,
owned: owned && name.as_ref() == b"id",
});
}
}
if !empty {
parents.push((namespace, local, chart_data));
}
}
Event::End(_) => {
parents.pop();
}
Event::Eof => return Ok(references),
_ => {}
}
}
}
fn chart_with_workbook_relationship(
chart: &CT_ChartSpace,
relationship_id: &str,
) -> Result<Vec<u8>> {
const END: &[u8] = b"</c:chartSpace>";
let mut xml = chart
.to_xml()
.map_err(|error| Error::Other(format!("invalid chart part: {error}")))?;
if chart_has_external_data(&xml)? {
return Err(Error::Other(
"chart already contains an external workbook relationship".to_owned(),
));
}
if !xml.ends_with(END) {
return Err(Error::Other(
"serialized chart lacks the chartSpace closing element".to_owned(),
));
}
xml.truncate(xml.len() - END.len());
xml.extend_from_slice(
format!(
r#"<c:externalData r:id="{relationship_id}"><c:autoUpdate val="0"/></c:externalData>"#
)
.as_bytes(),
);
xml.extend_from_slice(END);
CT_ChartSpace::from_xml(&xml)
.and_then(|validated| validated.to_xml())
.map_err(|error| Error::Other(format!("invalid chart part: {error}")))
}
fn chart_has_external_data(xml: &[u8]) -> Result<bool> {
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
let mut depth = 0usize;
let mut chart_space_depth = None;
loop {
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(format!("invalid chart part XML: {error}")))?;
match event {
Event::Start(ref element) => {
if chart_space_depth.is_none()
&& chart_namespace_matches(&namespace)
&& matches_local_name(element.name().as_ref(), b"chartSpace")
{
chart_space_depth = Some(depth);
} else if chart_space_depth.is_some_and(|root| depth == root + 1)
&& chart_namespace_matches(&namespace)
&& matches_local_name(element.name().as_ref(), b"externalData")
{
return Ok(true);
}
depth = depth
.checked_add(1)
.ok_or_else(|| Error::Other("chart part XML depth overflow".to_owned()))?;
}
Event::Empty(ref element)
if chart_space_depth.is_some_and(|root| depth == root + 1)
&& chart_namespace_matches(&namespace)
&& matches_local_name(element.name().as_ref(), b"externalData") =>
{
return Ok(true);
}
Event::End(_) => depth = depth.saturating_sub(1),
Event::Eof => return Ok(false),
_ => {}
}
buffer.clear();
}
}
fn chart_namespace_matches(namespace: &ResolveResult<'_>) -> bool {
match namespace {
ResolveResult::Bound(Namespace(uri)) => *uri == oxml_chart::C_NS.as_bytes(),
ResolveResult::Unknown(prefix) => *prefix == b"c",
ResolveResult::Unbound => false,
}
}
fn header_type_index(hdr_type: HdrFtrType) -> usize {
match hdr_type {
HdrFtrType::Default => 0,
HdrFtrType::First => 1,
HdrFtrType::Even => 2,
}
}
fn header_footer_story_has_expected_root(xml: &[u8], is_header: bool) -> bool {
let expected_local_name = if is_header {
b"hdr".as_slice()
} else {
b"ftr".as_slice()
};
let mut reader = NsReader::from_reader(xml);
reader.config_mut().trim_text(true);
let mut buffer = Vec::new();
loop {
let Ok((namespace, event)) = reader.read_resolved_event_into(&mut buffer) else {
return false;
};
match event {
Event::Start(element) | Event::Empty(element) => {
return matches!(
namespace,
ResolveResult::Bound(Namespace(uri)) if uri == WORD_NAMESPACE.as_bytes()
) && matches_local_name(element.name().as_ref(), expected_local_name);
}
Event::Decl(_) | Event::Comment(_) | Event::PI(_) => {}
Event::Eof
| Event::Text(_)
| Event::CData(_)
| Event::DocType(_)
| Event::GeneralRef(_)
| Event::End(_) => return false,
}
buffer.clear();
}
}
fn materialize_header_footer_inheritance(
document: &mut CT_Document,
even_headers_enabled: bool,
available_headers: &HashSet<String>,
available_footers: &HashSet<String>,
) {
let mut effective_headers: [Option<HdrFtrRef>; 3] = [None, None, None];
let mut effective_footers: [Option<HdrFtrRef>; 3] = [None, None, None];
let inherit = |references: &mut Vec<HdrFtrRef>,
effective: &mut [Option<HdrFtrRef>; 3],
available: &HashSet<String>,
materialize_blank_even: bool| {
for hdr_type in [HdrFtrType::Default, HdrFtrType::First, HdrFtrType::Even] {
let index = header_type_index(hdr_type);
if let Some(reference) = references
.iter()
.find(|reference| {
reference.hdr_ftr_type == hdr_type && available.contains(&reference.rel_id)
})
.cloned()
{
effective[index] = Some(reference);
} else if let Some(reference) = effective[index].clone() {
references.push(reference);
} else if hdr_type == HdrFtrType::Even && materialize_blank_even {
references.push(HdrFtrRef {
hdr_ftr_type: HdrFtrType::Even,
rel_id: String::new(),
});
}
}
};
let mut apply = |section: &mut CT_SectPr| {
inherit(
&mut section.header_refs,
&mut effective_headers,
available_headers,
even_headers_enabled,
);
inherit(
&mut section.footer_refs,
&mut effective_footers,
available_footers,
false,
);
};
// Match document_sections: only main-story paragraphs and block controls
// end sections. A paragraph in a table cell does not end a body section.
fn control_sections(control: &mut CT_Sdt) -> Vec<&mut CT_SectPr> {
let mut sections = Vec::new();
for content in &mut control.content {
match content {
SdtContent::Paragraph(paragraph) => {
if let Some(section) = paragraph
.properties
.as_mut()
.and_then(|properties| properties.sect_pr.as_mut())
{
sections.push(section);
}
}
SdtContent::ContentControl(control) => sections.extend(control_sections(control)),
_ => {}
}
}
sections
}
for content in &mut document.body.content {
match content {
BodyContent::Paragraph(paragraph) => {
if let Some(section) = paragraph
.properties
.as_mut()
.and_then(|properties| properties.sect_pr.as_mut())
{
apply(section);
}
}
BodyContent::ContentControl(control) => {
for section in control_sections(control) {
apply(section);
}
}
BodyContent::Table(_) | BodyContent::RawXml(_) => {}
}
}
if let Some(section) = document.body.sect_pr.as_mut() {
apply(section);
}
}
/// Express `target_part` relative to the directory holding `source_part`.
///
/// Falls back to the absolute part name when the two live in different
/// directories, which OPC also permits.
fn relative_target(source_part: &str, target_part: &str) -> String {
let dir = match source_part.rfind('/') {
Some(pos) => &source_part[..=pos],
None => "/",
};
match target_part.strip_prefix(dir) {
Some(rest) if !rest.contains('/') => rest.to_string(),
_ => target_part.to_string(),
}
}
fn relative_descendant_target(source_part: &str, target_part: &str) -> String {
let directory = source_part
.rfind('/')
.map_or("/", |position| &source_part[..=position]);
target_part
.strip_prefix(directory)
.unwrap_or(target_part)
.to_owned()
}
fn section_has_layout(properties: &CT_SectPr) -> bool {
properties.page_width.is_some()
|| properties.page_height.is_some()
|| properties.orientation.is_some()
|| properties.margin_top.is_some()
|| properties.margin_right.is_some()
|| properties.margin_bottom.is_some()
|| properties.margin_left.is_some()
|| properties.gutter.is_some()
|| properties.header_distance.is_some()
|| properties.footer_distance.is_some()
|| properties.section_type.is_some()
|| properties.columns.is_some()
|| properties.page_borders.is_some()
|| properties.line_numbers.is_some()
|| properties.vertical_alignment.is_some()
|| properties.title_pg.is_some()
}
/// Numbering format for one level of a custom list definition.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ListNumberFormat {
Decimal,
UpperRoman,
LowerRoman,
UpperLetter,
LowerLetter,
Ordinal,
CardinalText,
OrdinalText,
Hex,
Chicago,
IdeographDigital,
JapaneseCounting,
Aiueo,
Iroha,
DecimalFullWidth,
DecimalHalfWidth,
JapaneseLegal,
JapaneseDigitalTenThousand,
DecimalEnclosedCircle,
DecimalFullWidth2,
AiueoFullWidth,
IrohaFullWidth,
DecimalZero,
Bullet,
Ganada,
Chosung,
DecimalEnclosedFullstop,
DecimalEnclosedParen,
DecimalEnclosedCircleChinese,
IdeographEnclosedCircle,
IdeographTraditional,
IdeographZodiac,
IdeographZodiacTraditional,
TaiwaneseCounting,
IdeographLegalTraditional,
TaiwaneseCountingThousand,
TaiwaneseDigital,
ChineseCounting,
ChineseLegalSimplified,
ChineseCountingThousand,
KoreanDigital,
KoreanCounting,
KoreanLegal,
KoreanDigital2,
Hebrew1,
ArabicAlpha,
Hebrew2,
ArabicAbjad,
HindiVowels,
HindiConsonants,
HindiNumbers,
HindiCounting,
ThaiLetters,
ThaiNumbers,
ThaiCounting,
VietnameseCounting,
NumberInDash,
RussianLower,
RussianUpper,
None,
/// A producer-defined format retained across inspection and mutation.
Other(String),
}
impl ListNumberFormat {
/// The format an OOXML `w:numFmt` name selects, such as `decimal` or
/// `lowerLetter`. A name outside the standard set is `Other`.
pub fn from_name(name: &str) -> Self {
list_number_format_from_st(ST_NumberFormat::from_str(name))
}
fn to_st(&self) -> ST_NumberFormat {
match self {
Self::Decimal => ST_NumberFormat::Decimal,
Self::UpperRoman => ST_NumberFormat::UpperRoman,
Self::LowerRoman => ST_NumberFormat::LowerRoman,
Self::UpperLetter => ST_NumberFormat::UpperLetter,
Self::LowerLetter => ST_NumberFormat::LowerLetter,
Self::Ordinal => ST_NumberFormat::Ordinal,
Self::CardinalText => ST_NumberFormat::CardinalText,
Self::OrdinalText => ST_NumberFormat::OrdinalText,
Self::Hex => ST_NumberFormat::Hex,
Self::Chicago => ST_NumberFormat::Chicago,
Self::IdeographDigital => ST_NumberFormat::IdeographDigital,
Self::JapaneseCounting => ST_NumberFormat::JapaneseCounting,
Self::Aiueo => ST_NumberFormat::Aiueo,
Self::Iroha => ST_NumberFormat::Iroha,
Self::DecimalFullWidth => ST_NumberFormat::DecimalFullWidth,
Self::DecimalHalfWidth => ST_NumberFormat::DecimalHalfWidth,
Self::JapaneseLegal => ST_NumberFormat::JapaneseLegal,
Self::JapaneseDigitalTenThousand => ST_NumberFormat::JapaneseDigitalTenThousand,
Self::DecimalEnclosedCircle => ST_NumberFormat::DecimalEnclosedCircle,
Self::DecimalFullWidth2 => ST_NumberFormat::DecimalFullWidth2,
Self::AiueoFullWidth => ST_NumberFormat::AiueoFullWidth,
Self::IrohaFullWidth => ST_NumberFormat::IrohaFullWidth,
Self::DecimalZero => ST_NumberFormat::DecimalZero,
Self::Bullet => ST_NumberFormat::Bullet,
Self::Ganada => ST_NumberFormat::Ganada,
Self::Chosung => ST_NumberFormat::Chosung,
Self::DecimalEnclosedFullstop => ST_NumberFormat::DecimalEnclosedFullstop,
Self::DecimalEnclosedParen => ST_NumberFormat::DecimalEnclosedParen,
Self::DecimalEnclosedCircleChinese => ST_NumberFormat::DecimalEnclosedCircleChinese,
Self::IdeographEnclosedCircle => ST_NumberFormat::IdeographEnclosedCircle,
Self::IdeographTraditional => ST_NumberFormat::IdeographTraditional,
Self::IdeographZodiac => ST_NumberFormat::IdeographZodiac,
Self::IdeographZodiacTraditional => ST_NumberFormat::IdeographZodiacTraditional,
Self::TaiwaneseCounting => ST_NumberFormat::TaiwaneseCounting,
Self::IdeographLegalTraditional => ST_NumberFormat::IdeographLegalTraditional,
Self::TaiwaneseCountingThousand => ST_NumberFormat::TaiwaneseCountingThousand,
Self::TaiwaneseDigital => ST_NumberFormat::TaiwaneseDigital,
Self::ChineseCounting => ST_NumberFormat::ChineseCounting,
Self::ChineseLegalSimplified => ST_NumberFormat::ChineseLegalSimplified,
Self::ChineseCountingThousand => ST_NumberFormat::ChineseCountingThousand,
Self::KoreanDigital => ST_NumberFormat::KoreanDigital,
Self::KoreanCounting => ST_NumberFormat::KoreanCounting,
Self::KoreanLegal => ST_NumberFormat::KoreanLegal,
Self::KoreanDigital2 => ST_NumberFormat::KoreanDigital2,
Self::Hebrew1 => ST_NumberFormat::Hebrew1,
Self::ArabicAlpha => ST_NumberFormat::ArabicAlpha,
Self::Hebrew2 => ST_NumberFormat::Hebrew2,
Self::ArabicAbjad => ST_NumberFormat::ArabicAbjad,
Self::HindiVowels => ST_NumberFormat::HindiVowels,
Self::HindiConsonants => ST_NumberFormat::HindiConsonants,
Self::HindiNumbers => ST_NumberFormat::HindiNumbers,
Self::HindiCounting => ST_NumberFormat::HindiCounting,
Self::ThaiLetters => ST_NumberFormat::ThaiLetters,
Self::ThaiNumbers => ST_NumberFormat::ThaiNumbers,
Self::ThaiCounting => ST_NumberFormat::ThaiCounting,
Self::VietnameseCounting => ST_NumberFormat::VietnameseCounting,
Self::NumberInDash => ST_NumberFormat::NumberInDash,
Self::RussianLower => ST_NumberFormat::RussianLower,
Self::RussianUpper => ST_NumberFormat::RussianUpper,
Self::None => ST_NumberFormat::None,
Self::Other(value) => ST_NumberFormat::Other(value.clone()),
}
}
}
/// The numbering format reported by the read-only numbering projection.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NumberingFormat<'a> {
Decimal,
UpperRoman,
LowerRoman,
UpperLetter,
LowerLetter,
Ordinal,
CardinalText,
OrdinalText,
Hex,
Chicago,
IdeographDigital,
JapaneseCounting,
Aiueo,
Iroha,
DecimalFullWidth,
DecimalHalfWidth,
JapaneseLegal,
JapaneseDigitalTenThousand,
DecimalEnclosedCircle,
DecimalFullWidth2,
AiueoFullWidth,
IrohaFullWidth,
DecimalZero,
Bullet,
Ganada,
Chosung,
DecimalEnclosedFullstop,
DecimalEnclosedParen,
DecimalEnclosedCircleChinese,
IdeographEnclosedCircle,
IdeographTraditional,
IdeographZodiac,
IdeographZodiacTraditional,
TaiwaneseCounting,
IdeographLegalTraditional,
TaiwaneseCountingThousand,
TaiwaneseDigital,
ChineseCounting,
ChineseLegalSimplified,
ChineseCountingThousand,
KoreanDigital,
KoreanCounting,
KoreanLegal,
KoreanDigital2,
Hebrew1,
ArabicAlpha,
Hebrew2,
ArabicAbjad,
HindiVowels,
HindiConsonants,
HindiNumbers,
HindiCounting,
ThaiLetters,
ThaiNumbers,
ThaiCounting,
VietnameseCounting,
NumberInDash,
RussianLower,
RussianUpper,
None,
/// A producer-defined format value retained by rdocx.
Other(&'a str),
}
impl<'a> NumberingFormat<'a> {
fn from_st(value: &'a ST_NumberFormat) -> Self {
match value {
ST_NumberFormat::Decimal => Self::Decimal,
ST_NumberFormat::UpperRoman => Self::UpperRoman,
ST_NumberFormat::LowerRoman => Self::LowerRoman,
ST_NumberFormat::UpperLetter => Self::UpperLetter,
ST_NumberFormat::LowerLetter => Self::LowerLetter,
ST_NumberFormat::Ordinal => Self::Ordinal,
ST_NumberFormat::CardinalText => Self::CardinalText,
ST_NumberFormat::OrdinalText => Self::OrdinalText,
ST_NumberFormat::Hex => Self::Hex,
ST_NumberFormat::Chicago => Self::Chicago,
ST_NumberFormat::IdeographDigital => Self::IdeographDigital,
ST_NumberFormat::JapaneseCounting => Self::JapaneseCounting,
ST_NumberFormat::Aiueo => Self::Aiueo,
ST_NumberFormat::Iroha => Self::Iroha,
ST_NumberFormat::DecimalFullWidth => Self::DecimalFullWidth,
ST_NumberFormat::DecimalHalfWidth => Self::DecimalHalfWidth,
ST_NumberFormat::JapaneseLegal => Self::JapaneseLegal,
ST_NumberFormat::JapaneseDigitalTenThousand => Self::JapaneseDigitalTenThousand,
ST_NumberFormat::DecimalEnclosedCircle => Self::DecimalEnclosedCircle,
ST_NumberFormat::DecimalFullWidth2 => Self::DecimalFullWidth2,
ST_NumberFormat::AiueoFullWidth => Self::AiueoFullWidth,
ST_NumberFormat::IrohaFullWidth => Self::IrohaFullWidth,
ST_NumberFormat::DecimalZero => Self::DecimalZero,
ST_NumberFormat::Bullet => Self::Bullet,
ST_NumberFormat::Ganada => Self::Ganada,
ST_NumberFormat::Chosung => Self::Chosung,
ST_NumberFormat::DecimalEnclosedFullstop => Self::DecimalEnclosedFullstop,
ST_NumberFormat::DecimalEnclosedParen => Self::DecimalEnclosedParen,
ST_NumberFormat::DecimalEnclosedCircleChinese => Self::DecimalEnclosedCircleChinese,
ST_NumberFormat::IdeographEnclosedCircle => Self::IdeographEnclosedCircle,
ST_NumberFormat::IdeographTraditional => Self::IdeographTraditional,
ST_NumberFormat::IdeographZodiac => Self::IdeographZodiac,
ST_NumberFormat::IdeographZodiacTraditional => Self::IdeographZodiacTraditional,
ST_NumberFormat::TaiwaneseCounting => Self::TaiwaneseCounting,
ST_NumberFormat::IdeographLegalTraditional => Self::IdeographLegalTraditional,
ST_NumberFormat::TaiwaneseCountingThousand => Self::TaiwaneseCountingThousand,
ST_NumberFormat::TaiwaneseDigital => Self::TaiwaneseDigital,
ST_NumberFormat::ChineseCounting => Self::ChineseCounting,
ST_NumberFormat::ChineseLegalSimplified => Self::ChineseLegalSimplified,
ST_NumberFormat::ChineseCountingThousand => Self::ChineseCountingThousand,
ST_NumberFormat::KoreanDigital => Self::KoreanDigital,
ST_NumberFormat::KoreanCounting => Self::KoreanCounting,
ST_NumberFormat::KoreanLegal => Self::KoreanLegal,
ST_NumberFormat::KoreanDigital2 => Self::KoreanDigital2,
ST_NumberFormat::Hebrew1 => Self::Hebrew1,
ST_NumberFormat::ArabicAlpha => Self::ArabicAlpha,
ST_NumberFormat::Hebrew2 => Self::Hebrew2,
ST_NumberFormat::ArabicAbjad => Self::ArabicAbjad,
ST_NumberFormat::HindiVowels => Self::HindiVowels,
ST_NumberFormat::HindiConsonants => Self::HindiConsonants,
ST_NumberFormat::HindiNumbers => Self::HindiNumbers,
ST_NumberFormat::HindiCounting => Self::HindiCounting,
ST_NumberFormat::ThaiLetters => Self::ThaiLetters,
ST_NumberFormat::ThaiNumbers => Self::ThaiNumbers,
ST_NumberFormat::ThaiCounting => Self::ThaiCounting,
ST_NumberFormat::VietnameseCounting => Self::VietnameseCounting,
ST_NumberFormat::NumberInDash => Self::NumberInDash,
ST_NumberFormat::RussianLower => Self::RussianLower,
ST_NumberFormat::RussianUpper => Self::RussianUpper,
ST_NumberFormat::None => Self::None,
ST_NumberFormat::Other(value) => Self::Other(value),
}
}
}
/// The layout item emitted between a list marker and paragraph content.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListLevelSuffix {
Tab,
Space,
Nothing,
}
/// Explicit restart behavior for a numbering level.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ListLevelRestart {
/// Continue across every more-significant level.
Never,
/// Restart after the given zero-based, more-significant level.
After(u32),
}
impl ListLevelRestart {
fn from_st(value: u32) -> Self {
if value == 0 {
Self::Never
} else {
Self::After(value - 1)
}
}
}
impl ListLevelSuffix {
fn from_st(value: ST_LvlSuffix) -> Self {
match value {
ST_LvlSuffix::Tab => Self::Tab,
ST_LvlSuffix::Space => Self::Space,
ST_LvlSuffix::Nothing => Self::Nothing,
}
}
}
/// Resolved reader metadata for one numbering definition level.
#[derive(Debug, Clone, PartialEq)]
pub struct NumberingLevel<'a> {
/// The zero-based list level.
pub level: u32,
/// The marker format at this level.
pub format: NumberingFormat<'a>,
/// The exact OOXML format name, including producer-defined values.
pub format_name: &'a str,
/// The first marker value, defaulting to one when Word omits it.
pub start: u32,
/// The item emitted between the marker and paragraph content.
pub suffix: ListLevelSuffix,
/// The Word level-text template or bullet glyph.
pub level_text: Option<&'a str>,
/// The marker alignment, when explicitly configured.
pub alignment: Option<crate::paragraph::Alignment>,
/// The paragraph style associated with this numbering level.
pub paragraph_style: Option<&'a str>,
/// Explicit restart behavior. Absence means the OOXML default.
pub restart: Option<ListLevelRestart>,
/// Whether inherited placeholders use decimal legal numbering.
pub legal_numbering: Option<bool>,
/// Left indentation in twips.
pub indent_left: Option<oxml_core::Twips>,
/// Hanging indentation in twips.
pub indent_hanging: Option<oxml_core::Twips>,
/// First-line indentation in twips.
pub indent_first_line: Option<oxml_core::Twips>,
/// Typed properties applied only to the numbering marker.
pub marker_properties: Option<&'a CT_RPr>,
/// Producer template code from `w:tplc`.
pub template_code: Option<&'a str>,
/// Whether the producer marked this level tentative.
pub tentative: Option<bool>,
/// Whether this level retains semantic facts this projection does not model.
pub has_unmodeled_properties: bool,
/// Whether the list item has nonstandard paragraph-level presentation.
pub has_paragraph_presentation: bool,
/// Whether the marker has run-level presentation properties.
pub has_marker_presentation: bool,
}
fn list_level_alignment(value: ST_Jc) -> crate::paragraph::Alignment {
match value {
ST_Jc::Center => crate::paragraph::Alignment::Center,
ST_Jc::Right | ST_Jc::End => crate::paragraph::Alignment::Right,
ST_Jc::Both | ST_Jc::Distribute => crate::paragraph::Alignment::Justify,
_ => crate::paragraph::Alignment::Left,
}
}
/// One level of a custom list definition for [`Document::add_list_definition`].
#[derive(Debug, Clone)]
pub struct ListLevel {
/// Numbering format for this level.
pub format: ListNumberFormat,
/// Starting number (defaults to 1; ignored for bullet levels).
pub start: Option<u32>,
level_text: Option<String>,
suffix: Option<ListLevelSuffix>,
alignment: Option<crate::paragraph::Alignment>,
indent_left: Option<oxml_core::Twips>,
indent_hanging: Option<oxml_core::Twips>,
indent_first_line: Option<oxml_core::Twips>,
marker_properties: Option<CT_RPr>,
legal_numbering: Option<bool>,
restart: Option<ListLevelRestart>,
template_code: Option<String>,
tentative: Option<bool>,
source_level: Option<Box<CT_Lvl>>,
}
impl PartialEq for ListLevel {
fn eq(&self, other: &Self) -> bool {
self.format == other.format
&& self.start == other.start
&& self.level_text == other.level_text
&& self.suffix == other.suffix
&& self.alignment == other.alignment
&& self.indent_left == other.indent_left
&& self.indent_hanging == other.indent_hanging
&& self.indent_first_line == other.indent_first_line
&& self.marker_properties == other.marker_properties
&& self.legal_numbering == other.legal_numbering
&& self.restart == other.restart
&& self.template_code == other.template_code
&& self.tentative == other.tentative
}
}
impl ListLevel {
/// A level with the given format, starting at 1.
pub fn new(format: ListNumberFormat) -> Self {
ListLevel {
format,
start: None,
level_text: None,
suffix: None,
alignment: None,
indent_left: None,
indent_hanging: None,
indent_first_line: None,
marker_properties: None,
legal_numbering: None,
restart: None,
template_code: None,
tentative: None,
source_level: None,
}
}
/// A bullet level.
pub fn bullet() -> Self {
Self::new(ListNumberFormat::Bullet)
}
/// A decimal-numbered level.
pub fn decimal() -> Self {
Self::new(ListNumberFormat::Decimal)
}
/// Override the starting number for this level.
pub fn start(mut self, start: u32) -> Self {
self.start = Some(start);
self
}
/// Set the level-text template or bullet glyph.
pub fn level_text(mut self, value: impl Into<String>) -> Self {
self.level_text = Some(value.into());
self
}
/// Select the content emitted after the marker.
pub fn suffix(mut self, value: ListLevelSuffix) -> Self {
self.suffix = Some(value);
self
}
/// Set marker alignment.
pub fn alignment(mut self, value: crate::paragraph::Alignment) -> Self {
self.alignment = Some(value);
self
}
/// Set left, hanging, and first-line indentation in twips.
pub fn indentation(
mut self,
left: Option<oxml_core::Twips>,
hanging: Option<oxml_core::Twips>,
first_line: Option<oxml_core::Twips>,
) -> Self {
self.indent_left = left;
self.indent_hanging = hanging;
self.indent_first_line = first_line;
self
}
/// Set properties applied only to the numbering marker.
pub fn marker_properties(mut self, value: CT_RPr) -> Self {
self.marker_properties = Some(value);
self
}
/// Select legal-numbering placeholder behavior.
pub fn legal_numbering(mut self, value: bool) -> Self {
self.legal_numbering = Some(value);
self
}
/// Set explicit restart behavior.
pub fn restart(mut self, value: ListLevelRestart) -> Self {
self.restart = Some(value);
self
}
/// Set the eight-hex-digit producer template code.
pub fn template_code(mut self, value: impl Into<String>) -> Self {
self.template_code = Some(value.into());
self
}
/// Set the producer tentative flag.
pub fn tentative(mut self, value: bool) -> Self {
self.tentative = Some(value);
self
}
/// Return the configured level-text template, when one was supplied.
pub fn level_text_value(&self) -> Option<&str> {
self.level_text.as_deref()
}
/// Return the configured marker suffix, when one was supplied.
pub fn suffix_value(&self) -> Option<ListLevelSuffix> {
self.suffix
}
/// Return the configured marker alignment, when one was supplied.
pub fn alignment_value(&self) -> Option<crate::paragraph::Alignment> {
self.alignment
}
/// Return the configured left, hanging, and first-line indentation.
pub fn indentation_value(
&self,
) -> (
Option<oxml_core::Twips>,
Option<oxml_core::Twips>,
Option<oxml_core::Twips>,
) {
(
self.indent_left,
self.indent_hanging,
self.indent_first_line,
)
}
/// Return the configured marker run properties.
pub fn marker_properties_value(&self) -> Option<&CT_RPr> {
self.marker_properties.as_ref()
}
/// Return the configured legal-numbering flag.
pub fn legal_numbering_value(&self) -> Option<bool> {
self.legal_numbering
}
/// Return the configured restart behavior.
pub fn restart_value(&self) -> Option<ListLevelRestart> {
self.restart
}
/// Return the configured producer template code.
pub fn template_code_value(&self) -> Option<&str> {
self.template_code.as_deref()
}
/// Return the configured tentative flag.
pub fn tentative_value(&self) -> Option<bool> {
self.tentative
}
}
/// Owned public projection of an abstract numbering definition.
#[derive(Debug, Clone, PartialEq)]
pub struct NumberingDefinition {
/// The abstract numbering definition identifier.
pub id: u32,
/// The definition's ordered levels.
pub levels: Vec<NumberingDefinitionLevel>,
/// Imported style links, indexed in parallel with `levels`.
pub paragraph_style_links: Vec<Option<String>>,
/// Whether the definition contains imported properties outside this projection.
pub has_unmodeled_properties: bool,
}
/// One explicitly identified level in an inspected numbering definition.
#[derive(Debug, Clone, PartialEq)]
pub struct NumberingDefinitionLevel {
/// The zero-based `w:ilvl` identifier.
pub level: u32,
/// The modeled properties owned by this level.
pub properties: ListLevel,
}
/// One typed override owned by a numbering instance.
#[derive(Debug, Clone, PartialEq)]
pub struct NumberingLevelOverride {
/// The zero-based level receiving the override.
pub level: u32,
/// An optional starting-value override.
pub start: Option<u32>,
/// An optional replacement level definition.
pub replacement: Option<ListLevel>,
/// Imported style link for the replacement level.
pub paragraph_style_link: Option<String>,
/// Whether the override contains imported properties outside this projection.
pub has_unmodeled_properties: bool,
}
impl NumberingLevelOverride {
/// Create an empty override for a zero-based level.
pub fn new(level: u32) -> Self {
Self {
level,
start: None,
replacement: None,
paragraph_style_link: None,
has_unmodeled_properties: false,
}
}
/// Set the override's starting value.
pub fn start(mut self, value: u32) -> Self {
self.start = Some(value);
self
}
/// Replace the level definition for this instance.
pub fn replacement(mut self, value: ListLevel) -> Self {
self.replacement = Some(value);
self
}
}
/// Owned public projection of one numbering instance.
#[derive(Debug, Clone, PartialEq)]
pub struct NumberingInstance {
/// The numbering instance identifier used by document paragraphs.
pub id: u32,
/// The referenced abstract numbering definition identifier.
pub definition_id: u32,
/// Typed overrides applied by this instance.
pub level_overrides: Vec<NumberingLevelOverride>,
/// Whether the instance contains imported properties outside this projection.
pub has_unmodeled_properties: bool,
}
fn list_alignment_to_st(value: crate::paragraph::Alignment) -> ST_Jc {
match value {
crate::paragraph::Alignment::Left => ST_Jc::Left,
crate::paragraph::Alignment::Center => ST_Jc::Center,
crate::paragraph::Alignment::Right => ST_Jc::Right,
crate::paragraph::Alignment::Justify => ST_Jc::Both,
}
}
fn list_level_restart_to_st(value: ListLevelRestart) -> Option<u32> {
match value {
ListLevelRestart::Never => Some(0),
ListLevelRestart::After(level) => level.checked_add(1),
}
}
fn list_level_from_ct(level: &CT_Lvl) -> ListLevel {
let mut value = list_level_values_from_ct(level);
value.source_level = Some(Box::new(level.clone()));
value
}
fn list_level_values_from_ct(level: &CT_Lvl) -> ListLevel {
ListLevel {
format: level
.num_fmt
.clone()
.map(list_number_format_from_st)
.unwrap_or(ListNumberFormat::Decimal),
start: level.start,
level_text: level.lvl_text.clone(),
suffix: level.suffix.map(ListLevelSuffix::from_st),
alignment: level.lvl_jc.map(list_level_alignment),
indent_left: level.ppr.as_ref().and_then(|value| value.ind_left),
indent_hanging: level.ppr.as_ref().and_then(|value| value.ind_hanging),
indent_first_line: level.ppr.as_ref().and_then(|value| value.ind_first_line),
marker_properties: level.rpr.clone(),
legal_numbering: level.legal,
restart: level.restart.map(ListLevelRestart::from_st),
template_code: level.template_code.clone(),
tentative: level.tentative,
source_level: None,
}
}
pub(crate) fn list_number_format_from_st(value: ST_NumberFormat) -> ListNumberFormat {
// Both enums intentionally have the same standard token set.
match value.to_str() {
"decimal" => ListNumberFormat::Decimal,
"upperRoman" => ListNumberFormat::UpperRoman,
"lowerRoman" => ListNumberFormat::LowerRoman,
"upperLetter" => ListNumberFormat::UpperLetter,
"lowerLetter" => ListNumberFormat::LowerLetter,
"ordinal" => ListNumberFormat::Ordinal,
"cardinalText" => ListNumberFormat::CardinalText,
"ordinalText" => ListNumberFormat::OrdinalText,
"hex" => ListNumberFormat::Hex,
"chicago" => ListNumberFormat::Chicago,
"ideographDigital" => ListNumberFormat::IdeographDigital,
"japaneseCounting" => ListNumberFormat::JapaneseCounting,
"Aiueo" => ListNumberFormat::Aiueo,
"Iroha" => ListNumberFormat::Iroha,
"decimalFullWidth" => ListNumberFormat::DecimalFullWidth,
"decimalHalfWidth" => ListNumberFormat::DecimalHalfWidth,
"japaneseLegal" => ListNumberFormat::JapaneseLegal,
"japaneseDigitalTenThousand" => ListNumberFormat::JapaneseDigitalTenThousand,
"decimalEnclosedCircle" => ListNumberFormat::DecimalEnclosedCircle,
"decimalFullWidth2" => ListNumberFormat::DecimalFullWidth2,
"aiueoFullWidth" => ListNumberFormat::AiueoFullWidth,
"irohaFullWidth" => ListNumberFormat::IrohaFullWidth,
"decimalZero" => ListNumberFormat::DecimalZero,
"bullet" => ListNumberFormat::Bullet,
"ganada" => ListNumberFormat::Ganada,
"chosung" => ListNumberFormat::Chosung,
"decimalEnclosedFullstop" => ListNumberFormat::DecimalEnclosedFullstop,
"decimalEnclosedParen" => ListNumberFormat::DecimalEnclosedParen,
"decimalEnclosedCircleChinese" => ListNumberFormat::DecimalEnclosedCircleChinese,
"ideographEnclosedCircle" => ListNumberFormat::IdeographEnclosedCircle,
"ideographTraditional" => ListNumberFormat::IdeographTraditional,
"ideographZodiac" => ListNumberFormat::IdeographZodiac,
"ideographZodiacTraditional" => ListNumberFormat::IdeographZodiacTraditional,
"taiwaneseCounting" => ListNumberFormat::TaiwaneseCounting,
"ideographLegalTraditional" => ListNumberFormat::IdeographLegalTraditional,
"taiwaneseCountingThousand" => ListNumberFormat::TaiwaneseCountingThousand,
"taiwaneseDigital" => ListNumberFormat::TaiwaneseDigital,
"chineseCounting" => ListNumberFormat::ChineseCounting,
"chineseLegalSimplified" => ListNumberFormat::ChineseLegalSimplified,
"chineseCountingThousand" => ListNumberFormat::ChineseCountingThousand,
"koreanDigital" => ListNumberFormat::KoreanDigital,
"koreanCounting" => ListNumberFormat::KoreanCounting,
"koreanLegal" => ListNumberFormat::KoreanLegal,
"koreanDigital2" => ListNumberFormat::KoreanDigital2,
"hebrew1" => ListNumberFormat::Hebrew1,
"arabicAlpha" => ListNumberFormat::ArabicAlpha,
"hebrew2" => ListNumberFormat::Hebrew2,
"arabicAbjad" => ListNumberFormat::ArabicAbjad,
"hindiVowels" => ListNumberFormat::HindiVowels,
"hindiConsonants" => ListNumberFormat::HindiConsonants,
"hindiNumbers" => ListNumberFormat::HindiNumbers,
"hindiCounting" => ListNumberFormat::HindiCounting,
"thaiLetters" => ListNumberFormat::ThaiLetters,
"thaiNumbers" => ListNumberFormat::ThaiNumbers,
"thaiCounting" => ListNumberFormat::ThaiCounting,
"vietnameseCounting" => ListNumberFormat::VietnameseCounting,
"numberInDash" => ListNumberFormat::NumberInDash,
"russianLower" => ListNumberFormat::RussianLower,
"russianUpper" => ListNumberFormat::RussianUpper,
"none" => ListNumberFormat::None,
other => ListNumberFormat::Other(other.to_owned()),
}
}
fn numbering_level_has_unmodeled(level: &CT_Lvl) -> bool {
let unprojected_paragraph_properties = level.ppr.as_ref().is_some_and(|properties| {
let projected = CT_PPr {
ind_left: properties.ind_left,
ind_hanging: properties.ind_hanging,
ind_first_line: properties.ind_first_line,
..CT_PPr::default()
};
properties != &projected
});
!level.extra_xml.is_empty()
|| !level.extra_attributes.is_empty()
|| level
.start_raw
.as_ref()
.is_some_and(|(_, raw, prefixes)| typed_numbering_leaf_has_unmodeled(raw, prefixes))
|| level
.num_fmt_raw
.as_ref()
.is_some_and(|(_, raw, prefixes)| typed_numbering_leaf_has_unmodeled(raw, prefixes))
|| level
.p_style_raw
.as_ref()
.is_some_and(|(_, raw, prefixes)| typed_numbering_leaf_has_unmodeled(raw, prefixes))
|| level
.restart_raw
.as_ref()
.is_some_and(|(_, raw, prefixes)| typed_numbering_leaf_has_unmodeled(raw, prefixes))
|| level
.legal_raw
.as_ref()
.is_some_and(|(_, raw, prefixes)| typed_numbering_leaf_has_unmodeled(raw, prefixes))
|| level
.suffix_raw
.as_ref()
.is_some_and(|(_, raw, prefixes)| typed_numbering_leaf_has_unmodeled(raw, prefixes))
|| level
.lvl_text_raw
.as_ref()
.is_some_and(|(_, raw, prefixes)| typed_numbering_leaf_has_unmodeled(raw, prefixes))
|| level
.lvl_jc_raw
.as_ref()
.is_some_and(|(_, raw, prefixes)| typed_numbering_leaf_has_unmodeled(raw, prefixes))
|| level.ppr_raw.is_some()
|| level.rpr_raw.is_some()
|| unprojected_paragraph_properties
|| matches!(level.num_fmt.as_ref(), Some(ST_NumberFormat::Other(_)))
}
fn numbering_override_has_unmodeled(value: &CT_NumLvl) -> bool {
!value.extra_xml.is_empty()
|| !value.extra_attributes.is_empty()
|| value
.start_override_raw
.as_ref()
.is_some_and(|(_, raw, prefixes)| typed_numbering_leaf_has_unmodeled(raw, prefixes))
|| value
.level
.as_ref()
.is_some_and(numbering_level_has_unmodeled)
}
fn validate_list_level(level: u32, value: &ListLevel) -> Result<()> {
if level > 8 {
return Err(Error::Other(format!(
"numbering level {level} is outside the supported range 0 through 8"
)));
}
if value.indent_hanging.is_some() && value.indent_first_line.is_some() {
return Err(Error::Other(format!(
"numbering level {level} cannot have both hanging and first-line indentation"
)));
}
if let Some(ListLevelRestart::After(owner)) = value.restart
&& owner >= level
{
return Err(Error::Other(format!(
"numbering level {level} cannot restart after level {owner}"
)));
}
if let Some(template_code) = &value.template_code
&& (template_code.len() != 8 || !template_code.bytes().all(|byte| byte.is_ascii_hexdigit()))
{
return Err(Error::Other(format!(
"numbering level {level} template code must contain eight hexadecimal digits"
)));
}
if let Some(text) = &value.level_text {
let bytes = text.as_bytes();
let mut position = 0usize;
while position < bytes.len() {
if bytes[position] != b'%' {
position += 1;
continue;
}
let Some(next) = bytes.get(position + 1).copied() else {
return Err(Error::Other(format!(
"numbering level {level} has an incomplete placeholder"
)));
};
if next == b'%' {
position += 2;
continue;
}
if !(b'1'..=b'9').contains(&next) || u32::from(next - b'1') > level {
return Err(Error::Other(format!(
"numbering level {level} has an invalid placeholder %{}",
char::from(next)
)));
}
position += 2;
}
}
Ok(())
}
fn default_list_level_text(level: u32, format: &ListNumberFormat) -> String {
const BULLETS: [&str; 3] = ["\u{2022}", "\u{25E6}", "\u{25AA}"];
match format {
ListNumberFormat::Bullet => BULLETS[level as usize % BULLETS.len()].to_owned(),
ListNumberFormat::None => String::new(),
_ => format!("%{}.", level + 1),
}
}
fn ct_level_from_list(level: u32, value: &ListLevel) -> Result<CT_Lvl> {
validate_list_level(level, value)?;
let mut result = CT_Lvl::new(level);
result.template_code.clone_from(&value.template_code);
result.tentative = value.tentative;
result.start = Some(value.start.unwrap_or(1));
result.num_fmt = Some(value.format.to_st());
result.restart = value.restart.and_then(list_level_restart_to_st);
result.legal = value.legal_numbering;
result.suffix = value.suffix.map(|suffix| match suffix {
ListLevelSuffix::Tab => ST_LvlSuffix::Tab,
ListLevelSuffix::Space => ST_LvlSuffix::Space,
ListLevelSuffix::Nothing => ST_LvlSuffix::Nothing,
});
result.lvl_text = Some(
value
.level_text
.clone()
.unwrap_or_else(|| default_list_level_text(level, &value.format)),
);
result.lvl_jc = Some(
value
.alignment
.map(list_alignment_to_st)
.unwrap_or(ST_Jc::Left),
);
let default_left = (level + 1)
.checked_mul(720)
.and_then(|value| i32::try_from(value).ok())
.map(oxml_core::Twips)
.ok_or_else(|| Error::Other(format!("numbering level {level} indentation overflow")))?;
result.ppr = Some(CT_PPr {
ind_left: Some(value.indent_left.unwrap_or(default_left)),
ind_hanging: Some(value.indent_hanging.unwrap_or(oxml_core::Twips(360))),
ind_first_line: value.indent_first_line,
..CT_PPr::default()
});
if value.indent_first_line.is_some() && value.indent_hanging.is_none() {
result
.ppr
.as_mut()
.expect("created paragraph properties")
.ind_hanging = None;
}
result.rpr.clone_from(&value.marker_properties);
Ok(result)
}
fn merge_list_level(existing: &CT_Lvl, value: &ListLevel) -> Result<CT_Lvl> {
if value.source_level.as_deref() == Some(existing) {
let source = list_level_values_from_ct(existing);
let mut merged = existing.clone();
if value.format != source.format {
merged.num_fmt = Some(value.format.to_st());
}
if value.start != source.start {
merged.start = value.start;
}
if value.level_text != source.level_text {
merged.lvl_text.clone_from(&value.level_text);
}
if value.suffix != source.suffix {
merged.suffix = value.suffix.map(|suffix| match suffix {
ListLevelSuffix::Tab => ST_LvlSuffix::Tab,
ListLevelSuffix::Space => ST_LvlSuffix::Space,
ListLevelSuffix::Nothing => ST_LvlSuffix::Nothing,
});
}
if value.alignment != source.alignment {
merged.lvl_jc = value.alignment.map(list_alignment_to_st);
}
if value.restart != source.restart {
merged.restart = value.restart.and_then(list_level_restart_to_st);
}
if value.legal_numbering != source.legal_numbering {
merged.legal = value.legal_numbering;
}
if value.template_code != source.template_code {
merged.template_code.clone_from(&value.template_code);
}
if value.tentative != source.tentative {
merged.tentative = value.tentative;
}
if value.indentation_value() != source.indentation_value() {
let properties = merged.ppr.get_or_insert_with(CT_PPr::default);
properties.ind_left = value.indent_left;
properties.ind_hanging = value.indent_hanging;
properties.ind_first_line = value.indent_first_line;
}
if value.marker_properties != source.marker_properties {
merged.rpr.clone_from(&value.marker_properties);
}
return Ok(merged);
}
let mut authored = ct_level_from_list(existing.ilvl, value)?;
authored.start_raw.clone_from(&existing.start_raw);
authored.num_fmt_raw.clone_from(&existing.num_fmt_raw);
authored.p_style.clone_from(&existing.p_style);
authored.p_style_raw.clone_from(&existing.p_style_raw);
authored.restart_raw.clone_from(&existing.restart_raw);
authored.legal_raw.clone_from(&existing.legal_raw);
authored.suffix_raw.clone_from(&existing.suffix_raw);
authored.lvl_text_raw.clone_from(&existing.lvl_text_raw);
authored.lvl_jc_raw.clone_from(&existing.lvl_jc_raw);
authored.extra_xml.clone_from(&existing.extra_xml);
authored
.extra_attributes
.clone_from(&existing.extra_attributes);
authored.ppr_raw.clone_from(&existing.ppr_raw);
authored.rpr_raw.clone_from(&existing.rpr_raw);
Ok(authored)
}
fn reject_conflicting_raw_level_attribute(level: &CT_Lvl, local_name: &[u8]) -> Result<()> {
if level.extra_attributes.iter().any(|(name, _)| {
name.as_bytes()
.rsplit(|byte| *byte == b':')
.next()
.is_some_and(|local| local == local_name)
}) {
return Err(Error::Other(format!(
"cannot replace unmodeled numbering level attribute '{}'",
String::from_utf8_lossy(local_name)
)));
}
Ok(())
}
fn validate_numbering_level_slice(levels: &[ListLevel]) -> Result<()> {
if levels.is_empty() || levels.len() > 9 {
return Err(Error::Other(format!(
"numbering definitions require 1 through 9 levels, received {}",
levels.len()
)));
}
for (level, value) in levels.iter().enumerate() {
validate_list_level(level as u32, value)?;
}
Ok(())
}
fn validate_numbering_definition_levels(levels: &[NumberingDefinitionLevel]) -> Result<()> {
if levels.is_empty() || levels.len() > 9 {
return Err(Error::Other(format!(
"numbering definitions require 1 through 9 levels, received {}",
levels.len()
)));
}
let mut seen = HashSet::new();
for value in levels {
if !seen.insert(value.level) {
return Err(Error::Other(format!(
"numbering definition contains duplicate level {}",
value.level
)));
}
validate_list_level(value.level, &value.properties)?;
}
Ok(())
}
fn validate_ct_numbering_level(level: &CT_Lvl) -> Result<()> {
if level.template_code.is_some() {
reject_conflicting_raw_level_attribute(level, b"tplc")?;
}
if level.tentative.is_some() {
reject_conflicting_raw_level_attribute(level, b"tentative")?;
}
validate_list_level(level.ilvl, &list_level_from_ct(level))
}
fn numbering_override_from_public(value: &NumberingLevelOverride) -> Result<CT_NumLvl> {
if value.level > 8 {
return Err(Error::Other(format!(
"numbering override level {} is outside the supported range 0 through 8",
value.level
)));
}
if value.paragraph_style_link.is_some() {
return Err(Error::Other(
"standalone numbering style-link mutation is deferred to F-248".to_owned(),
));
}
if value.has_unmodeled_properties {
return Err(Error::Other(
"a new numbering override cannot claim imported unmodeled properties".to_owned(),
));
}
let mut result = CT_NumLvl::new(value.level);
result.start_override = value.start;
result.level = value
.replacement
.as_ref()
.map(|level| ct_level_from_list(value.level, level))
.transpose()?;
Ok(result)
}
fn numbering_override_for_update(
existing: Option<&CT_NumLvl>,
value: &NumberingLevelOverride,
) -> Result<CT_NumLvl> {
let Some(existing) = existing else {
return numbering_override_from_public(value);
};
let existing_style = existing
.level
.as_ref()
.and_then(|level| level.p_style.as_deref());
if value.paragraph_style_link.as_deref() != existing_style {
return Err(Error::Other(
"standalone numbering style-link mutation is deferred to F-248".to_owned(),
));
}
let mut result = existing.clone();
result.start_override = value.start;
result.level = match (&existing.level, &value.replacement) {
(Some(existing), Some(replacement)) => Some(merge_list_level(existing, replacement)?),
(None, Some(replacement)) => Some(ct_level_from_list(value.level, replacement)?),
(_, None) => None,
};
Ok(result)
}
fn xml_paragraph_numbering_properties(xml: &[u8]) -> Result<Vec<CT_PPr>> {
struct ParagraphState {
depth: usize,
ppr_depth: Option<usize>,
numpr_depth: Option<usize>,
properties: CT_PPr,
}
fn word_value(reader: &NsReader<&[u8]>, element: &BytesStart<'_>) -> Result<Option<String>> {
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| Error::Other(error.to_string()))?;
let (namespace, local) = reader.resolver().resolve_attribute(attribute.key);
if matches!(
namespace,
ResolveResult::Bound(value) if value.as_ref() == WORD_NAMESPACE.as_bytes()
) && local.as_ref() == b"val"
{
return attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map(|value| Some(value.into_owned()))
.map_err(|error| Error::Other(error.to_string()));
}
}
Ok(None)
}
fn apply_leaf(
reader: &NsReader<&[u8]>,
state: &mut ParagraphState,
depth: usize,
local_name: &[u8],
element: &BytesStart<'_>,
) -> Result<()> {
if local_name == b"pStyle" && state.ppr_depth == depth.checked_sub(1) {
state.properties.style_id = word_value(reader, element)?;
} else if state.numpr_depth == depth.checked_sub(1) {
if local_name == b"numId" {
state.properties.num_id =
word_value(reader, element)?.and_then(|value| value.parse::<u32>().ok());
} else if local_name == b"ilvl" {
state.properties.num_ilvl =
word_value(reader, element)?.and_then(|value| value.parse::<u32>().ok());
}
}
Ok(())
}
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
let mut depth = 0_usize;
let mut paragraphs = Vec::new();
let mut result = Vec::new();
loop {
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(error.to_string()))?;
match event {
Event::Start(ref element) => {
let is_word = matches!(
namespace,
ResolveResult::Bound(value)
if value.as_ref() == WORD_NAMESPACE.as_bytes()
);
let local_name = element.local_name();
if is_word && local_name.as_ref() == b"p" {
paragraphs.push(ParagraphState {
depth,
ppr_depth: None,
numpr_depth: None,
properties: CT_PPr::default(),
});
} else if is_word && let Some(state) = paragraphs.last_mut() {
if local_name.as_ref() == b"pPr" && depth == state.depth + 1 {
state.ppr_depth = Some(depth);
} else if local_name.as_ref() == b"numPr"
&& state.ppr_depth == depth.checked_sub(1)
{
state.numpr_depth = Some(depth);
} else {
apply_leaf(&reader, state, depth, local_name.as_ref(), element)?;
}
}
depth += 1;
}
Event::Empty(ref element) => {
let is_word = matches!(
namespace,
ResolveResult::Bound(value)
if value.as_ref() == WORD_NAMESPACE.as_bytes()
);
let local_name = element.local_name();
if is_word && local_name.as_ref() == b"p" {
result.push(CT_PPr::default());
} else if is_word && let Some(state) = paragraphs.last_mut() {
apply_leaf(&reader, state, depth, local_name.as_ref(), element)?;
}
}
Event::End(ref element) => {
depth = depth.saturating_sub(1);
let is_word = matches!(
namespace,
ResolveResult::Bound(value)
if value.as_ref() == WORD_NAMESPACE.as_bytes()
);
if is_word && element.local_name().as_ref() == b"p" {
if let Some(paragraph) = paragraphs.pop_if(|state| state.depth == depth) {
result.push(paragraph.properties);
}
} else if let Some(state) = paragraphs.last_mut() {
if state.numpr_depth == Some(depth) {
state.numpr_depth = None;
}
if state.ppr_depth == Some(depth) {
state.ppr_depth = None;
state.numpr_depth = None;
}
}
}
Event::Eof => return Ok(result),
_ => {}
}
buffer.clear();
}
}
fn xml_references_numbering_instance(xml: &[u8], id: u32) -> Result<bool> {
let mut reader = NsReader::from_reader(xml);
let mut buffer = Vec::new();
loop {
let (namespace, event) = reader
.read_resolved_event_into(&mut buffer)
.map_err(|error| Error::Other(error.to_string()))?;
match event {
Event::Start(ref element) | Event::Empty(ref element)
if matches!(
namespace,
ResolveResult::Bound(value)
if value.as_ref() == WORD_NAMESPACE.as_bytes()
) && element.local_name().as_ref() == b"numId" =>
{
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| Error::Other(error.to_string()))?;
let (namespace, local) = reader.resolver().resolve_attribute(attribute.key);
if !matches!(
namespace,
ResolveResult::Bound(value)
if value.as_ref() == WORD_NAMESPACE.as_bytes()
) || local.as_ref() != b"val"
{
continue;
}
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|error| Error::Other(error.to_string()))?;
if value.parse::<u32>().ok() == Some(id) {
return Ok(true);
}
}
}
Event::Eof => return Ok(false),
_ => {}
}
buffer.clear();
}
}
/// A node in the document outline tree.
#[derive(Debug, Clone, PartialEq)]
pub struct OutlineNode {
/// The heading level (1-9).
pub level: u32,
/// The heading text.
pub text: String,
/// Child headings (sub-headings).
pub children: Vec<OutlineNode>,
}
/// Information about an image in the document.
#[derive(Debug, Clone, PartialEq)]
pub struct ImageInfo {
/// The relationship ID for the embedded image.
pub embed_id: String,
/// Optional name attribute.
pub name: Option<String>,
/// Optional description (alt text).
pub description: Option<String>,
/// Width in EMUs (English Metric Units, 914400 EMU = 1 inch).
pub width_emu: i64,
/// Height in EMUs.
pub height_emu: i64,
/// Whether this is an anchored (floating) image vs inline.
pub is_anchor: bool,
}
/// Information about a hyperlink in the document.
#[derive(Debug, Clone, PartialEq)]
pub struct LinkInfo {
/// The display text of the hyperlink.
pub text: String,
/// The resolved target URL (if external).
pub url: Option<String>,
/// Internal document anchor (if any).
pub anchor: Option<String>,
/// The relationship ID.
pub rel_id: Option<String>,
}
/// Severity level for accessibility issues.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IssueSeverity {
/// Informational suggestion.
Info,
/// Potential problem.
Warning,
/// Definite accessibility barrier.
Error,
}
/// An accessibility issue found during audit.
#[derive(Debug, Clone, PartialEq)]
pub struct AccessibilityIssue {
/// How severe the issue is.
pub severity: IssueSeverity,
/// Human-readable description of the issue.
pub message: String,
}
/// Build a hierarchical outline tree from a flat list of (level, text) headings.
fn build_outline_tree(headings: &[(u32, String)]) -> Vec<OutlineNode> {
let mut root: Vec<OutlineNode> = Vec::new();
let mut stack: Vec<(u32, usize)> = Vec::new(); // (level, index in parent's children)
for (level, text) in headings {
let node = OutlineNode {
level: *level,
text: text.clone(),
children: Vec::new(),
};
// Pop stack until we find a parent with a lower level
while let Some(&(stack_level, _)) = stack.last() {
if stack_level >= *level {
stack.pop();
} else {
break;
}
}
if stack.is_empty() {
root.push(node);
let idx = root.len() - 1;
stack.push((*level, idx));
} else {
// Navigate to the correct parent in the tree
let target = get_outline_parent_mut(&mut root, &stack);
target.children.push(node);
let idx = target.children.len() - 1;
stack.push((*level, idx));
}
}
root
}
/// Navigate to the parent node indicated by the stack.
fn get_outline_parent_mut<'a>(
root: &'a mut [OutlineNode],
stack: &[(u32, usize)],
) -> &'a mut OutlineNode {
let mut current = &mut root[stack[0].1];
for &(_, idx) in &stack[1..] {
current = &mut current.children[idx];
}
current
}
/// Truncate a string to at most `max_len` characters, appending "..." if it
/// was cut short.
///
/// Both the comparison and the cut are in characters; mixing byte length with
/// character counts would truncate non-ASCII text earlier than asked.
fn truncate_str(s: &str, max_len: usize) -> String {
if s.chars().count() <= max_len {
return s.to_string();
}
let truncated: String = s.chars().take(max_len.saturating_sub(3)).collect();
format!("{truncated}...")
}
/// Deobfuscate an ODTTF (obfuscated TrueType) font file.
///
/// Word embeds fonts as `.odttf` files whose first 32 bytes are XOR'd with a
/// 16-byte key derived from the GUID in the part name (ECMA-376 Part 1,
/// "Embedded Font Obfuscation"). The GUID hex is read into the key *backwards*,
/// but implementations differ in whether they reverse the raw hex string or the
/// mixed-endian layout .NET's `Guid.ToByteArray` produces — the two agree on
/// the first eight key bytes and disagree on the rest.
///
/// Rather than pick one and hope, both orders are tried and the result is only
/// accepted if it starts with a recognised sfnt version. A wrong key yields
/// bytes that no font parser can use, so validating here means a bad guess
/// degrades to "font not embedded" instead of feeding garbage downstream.
#[cfg(test)]
fn deobfuscate_odttf(data: &[u8], file_name: &str) -> Option<Vec<u8>> {
if data.len() < 32 {
return None;
}
// Extract GUID from file name: "00112233-4455-6677-8899-AABBCCDDEEFF.odttf"
// or "{00112233-4455-6677-8899-AABBCCDDEEFF}.odttf"
let name = file_name
.split('.')
.next()
.unwrap_or("")
.trim_start_matches('{')
.trim_end_matches('}');
let hex = name
.chars()
.filter(|character| *character != '-')
.collect::<String>()
.to_ascii_uppercase();
if hex.len() != 32 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return None;
}
let key = format!(
"{{{}-{}-{}-{}-{}}}",
&hex[0..8],
&hex[8..12],
&hex[12..16],
&hex[16..20],
&hex[20..32]
);
deobfuscate_odttf_with_key(data, &key)
}
fn font_key_bytes(value: &str) -> Option<[u8; 16]> {
let value_bytes = value.as_bytes();
if value_bytes.len() != 38
|| value_bytes.first() != Some(&b'{')
|| value_bytes.last() != Some(&b'}')
|| ![9, 14, 19, 24]
.into_iter()
.all(|index| value_bytes.get(index) == Some(&b'-'))
|| !value_bytes.iter().enumerate().all(|(index, byte)| {
matches!(index, 0 | 9 | 14 | 19 | 24 | 37) || matches!(byte, b'0'..=b'9' | b'A'..=b'F')
})
{
return None;
}
let hex: String = value[1..37]
.chars()
.filter(|character| *character != '-')
.collect();
let mut guid = [0u8; 16];
for (index, byte) in guid.iter_mut().enumerate() {
*byte = u8::from_str_radix(&hex[index * 2..index * 2 + 2], 16).ok()?;
}
Some(guid)
}
fn obfuscate_odttf_with_key(data: &[u8], guid: &[u8; 16]) -> Vec<u8> {
let key = odttf_key_candidates(guid)[0];
let mut result = data.to_vec();
for (index, byte) in result.iter_mut().take(32).enumerate() {
*byte ^= key[index % 16];
}
result
}
fn deobfuscate_odttf_with_key(data: &[u8], font_key: &str) -> Option<Vec<u8>> {
if data.len() < 32 || data.len() > 64 * 1024 * 1024 {
return None;
}
let guid = font_key_bytes(font_key)?;
let candidates = odttf_key_candidates(&guid);
let prefixes = candidates.map(|key| {
let mut prefix = *data.first_chunk::<32>().unwrap();
for (index, byte) in prefix.iter_mut().enumerate() {
*byte ^= key[index % 16];
}
prefix
});
// Producer subset headers may omit the derived binary-search hints.
let index = prefixes
.iter()
.position(|prefix| has_consistent_sfnt_header(prefix))
.or_else(|| prefixes.iter().position(|prefix| looks_like_sfnt(prefix)))?;
let mut result = Vec::new();
result.try_reserve_exact(data.len()).ok()?;
result.extend_from_slice(data);
result[..32].copy_from_slice(&prefixes[index]);
Some(result)
}
/// Bound and check every authored single-face sfnt table before obfuscation.
fn valid_authored_sfnt(data: &[u8]) -> bool {
if data.len() > 64 * 1024 * 1024 || !looks_like_sfnt(data) || data.starts_with(b"ttcf") {
return false;
}
let count = u16::from_be_bytes([data[4], data[5]]) as usize;
let Some(directory) = data.get(12..12 + count * 16) else {
return false;
};
let mut tags = std::collections::HashSet::new();
for record in directory.chunks_exact(16) {
let offset = u32::from_be_bytes(record[8..12].try_into().unwrap()) as usize;
let length = u32::from_be_bytes(record[12..16].try_into().unwrap()) as usize;
if !tags.insert(&record[..4])
|| offset
.checked_add(length)
.is_none_or(|end| end > data.len())
|| (length > 0 && offset < 12 + count * 16)
{
return false;
}
}
tags.contains(b"name".as_slice())
}
/// The two candidate XOR keys for ODTTF deobfuscation, most likely first.
fn odttf_key_candidates(guid: &[u8; 16]) -> [[u8; 16]; 2] {
// Read the hex string end-first, as the spec prose describes.
let mut plain_reversed = *guid;
plain_reversed.reverse();
// The .NET route: `Guid.ToByteArray` byte-swaps the first three groups,
// and the whole array is then reversed.
let dotnet = [
guid[3], guid[2], guid[1], guid[0], guid[5], guid[4], guid[7], guid[6], guid[8], guid[9],
guid[10], guid[11], guid[12], guid[13], guid[14], guid[15],
];
let mut dotnet_reversed = dotnet;
dotnet_reversed.reverse();
[plain_reversed, dotnet_reversed]
}
/// Check that `data` opens with a plausible sfnt (TrueType/OpenType) header.
///
/// This is the weak test: signature plus printable table tags. It cannot always
/// tell the two ODTTF key conventions apart, since they produce identical
/// output for the first eight bytes.
fn looks_like_sfnt(data: &[u8]) -> bool {
let Some(signature) = data.first_chunk::<4>() else {
return false;
};
match signature {
b"\x00\x01\x00\x00" | b"OTTO" | b"true" => {}
// A collection header has a different layout; take it on signature.
b"ttcf" => return true,
_ => return false,
}
if data.len() < 32 {
return false;
}
let num_tables = u16::from_be_bytes([data[4], data[5]]);
if num_tables == 0 || num_tables > 512 {
return false;
}
// Table records begin at offset 12 and are 16 bytes each, so the first
// record's tag is at 12..16 and the second record's tag at 28..32 — both
// inside the 32 bytes the obfuscation touches.
let is_tag = |tag: &[u8]| tag.iter().all(|b| (0x20..=0x7E).contains(b));
is_tag(&data[12..16]) && (num_tables < 2 || is_tag(&data[28..32]))
}
/// The strong test: the sfnt header's binary-search hints must agree with the
/// table count.
///
/// `searchRange`, `entrySelector` and `rangeShift` are all derived from
/// `numTables`, and `entrySelector`/`rangeShift` sit in the byte range where
/// the two ODTTF key conventions differ — so this identifies the right key
/// outright whenever the font's header is spec-conformant.
fn has_consistent_sfnt_header(data: &[u8]) -> bool {
if !looks_like_sfnt(data) || data.len() < 12 {
return false;
}
if data.first_chunk::<4>() == Some(b"ttcf") {
return false; // no table directory at this offset
}
let num_tables = u16::from_be_bytes([data[4], data[5]]);
let search_range = u16::from_be_bytes([data[6], data[7]]);
let entry_selector = u16::from_be_bytes([data[8], data[9]]);
let range_shift = u16::from_be_bytes([data[10], data[11]]);
let expected_selector = num_tables.ilog2() as u16;
let expected_search_range = (1u16 << expected_selector) * 16;
let expected_range_shift = num_tables
.wrapping_mul(16)
.wrapping_sub(expected_search_range);
search_range == expected_search_range
&& entry_selector == expected_selector
&& range_shift == expected_range_shift
}
#[cfg(test)]
mod tests {
#[test]
fn fx251_native_equal_position_tab_insertion_stays_supported() {
let mut document = Document::new();
let mut paragraph = document.add_paragraph("native contract");
assert_eq!(
paragraph
.try_add_tab_stop(crate::TabAlignment::Left, Length::twips(10), None)
.unwrap(),
0
);
assert_eq!(
paragraph
.try_add_tab_stop(crate::TabAlignment::Right, Length::twips(10), None)
.unwrap(),
1
);
assert!(paragraph.tab_stop(0).is_some() && paragraph.tab_stop(1).is_some());
assert!(paragraph.tab_stop(2).is_none());
let reopened = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
assert_eq!(reopened.paragraph(0).unwrap().tab_stop_count(), 2);
}
#[test]
#[ignore = "Owned release-mode same-host performance qualification"]
fn fx222_release_scaling_and_output_equivalence() {
use std::time::Instant;
let output = std::path::PathBuf::from(std::env::var("FX222_BENCH_OUTPUT_DIR").unwrap());
std::fs::create_dir_all(&output).unwrap();
for scale in [1usize, 2, 4] {
let mut seed = Document::new();
for section in 0..20 * scale {
seed.add_paragraph(&format!("Section {}", section + 1))
.style("Heading1");
for paragraph in 0..5 {
seed.add_paragraph(&format!(
"Paragraph {} of section {}.",
paragraph + 1,
section + 1
));
}
}
let mut table = seed.add_table(100 * scale, 4);
for row in 0..100 * scale {
for column in 0..4 {
table
.cell(row, column)
.unwrap()
.set_text(&format!("r{row} c{column}"));
}
}
seed.insert_toc(0, 3).unwrap();
let input = seed.to_bytes().unwrap();
std::fs::write(output.join(format!("input-scale{scale}.docx")), &input).unwrap();
for operation in [
"rebuild_toc",
"update_page_fields",
"update_fields",
"story_ranges",
"comments",
] {
for sample in 0..4 {
let mut document = Document::from_bytes(&input).unwrap();
let started = Instant::now();
match operation {
"rebuild_toc" => {
document.rebuild_toc().unwrap();
}
"update_page_fields" => {
document.update_page_fields().unwrap();
}
"update_fields" => {
document.update_fields(&Default::default()).unwrap();
document.update_layout_backed_fields().unwrap();
}
"story_ranges" => {
document.story_ranges().unwrap();
}
"comments" => {
document.comment_anchor_snapshots().unwrap();
document.story_item_snapshots().unwrap();
}
_ => unreachable!(),
}
let elapsed = started.elapsed().as_secs_f64();
println!(
"FX222_BENCH scale={scale} operation={operation} sample={sample} seconds={elapsed:.9}"
);
if sample == 3 {
std::fs::write(
output.join(format!("output-scale{scale}-{operation}.docx")),
document.to_bytes().unwrap(),
)
.unwrap();
}
}
}
}
}
fn fx237_assert_combined_inventory(document: &Document) {
let legacy = (|| -> Result<_> {
Ok((
document.story_item_snapshots()?,
document.story_table_cell_snapshots()?,
document.story_range_paragraphs()?,
document.story_link_snapshots()?,
))
})();
let combined = document.story_owner_snapshots();
match (legacy, combined) {
(Ok(legacy), Ok(combined)) => assert_eq!(legacy, combined),
(Err(_), Err(_)) => {}
(legacy, combined) => {
panic!("inventory admission changed: legacy={legacy:?} combined={combined:?}")
}
}
}
#[test]
fn fx237_namespace_scope_boundaries_match_legacy() {
for xml in [
"<r xmlns='urn:base' xmlns:p='urn:one'><a plain='1'><b xmlns:p='urn:two'><p:c/><d xmlns='' xmlns:p=''><e/></d><p:c/></b><p:c/></a></r>",
"<r xmlns:p='urn:base'><a><b><c xmlns:q='urn:&escaped'><q:d/></c></b><p:e/></a><p:f/></r>",
"<r><bad xmlns:p='&unknown;' duplicate='1' duplicate='2'><later/></bad></r>",
"<r><bad duplicate='1' duplicate='2'><later/></bad></r>",
"<r><bad xmlns:p='one' xmlns:p='two'><later/></bad></r>",
"<r><bad duplicate='1' duplicate='2'/><later/></r>",
"<r><bad xmlns:p='&unknown;'/><later/></r>",
"<r><a></r>",
"<r><a",
"</r>",
"<r><a/></r><tail",
"<r xmlns='urn:base'><a xmlns=''><leaf/></a><sibling/></r>",
] {
let xml = xml.as_bytes();
let mut reader = quick_xml::Reader::from_reader(xml);
let mut offsets = Vec::new();
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
match reader.read_event_into(&mut buffer) {
Ok(Event::Start(_) | Event::Empty(_)) => offsets.push(before),
Ok(Event::Eof) | Err(_) => break,
_ => {}
}
buffer.clear();
}
for offset in offsets.iter().copied().chain([xml.len() + 1]) {
assert_eq!(
story_namespace_scope_at(xml, offset).map_err(|error| error.to_string()),
fx237_legacy_namespace_scope_at(xml, offset).map_err(|error| error.to_string()),
"scalar boundary {offset} in {}",
String::from_utf8_lossy(xml),
);
assert_eq!(
story_namespace_scopes_at(xml, [offset, offset])
.map_err(|error| error.to_string()),
fx237_legacy_namespace_scopes_at(xml, [offset, offset])
.map_err(|error| error.to_string()),
"duplicate requested boundary {offset} in {}",
String::from_utf8_lossy(xml),
);
}
assert_eq!(
story_namespace_scopes_at(xml, offsets.clone()).map_err(|error| error.to_string()),
fx237_legacy_namespace_scopes_at(xml, offsets).map_err(|error| error.to_string()),
"complete consumed boundaries in {}",
String::from_utf8_lossy(xml),
);
assert!(story_namespace_scopes_at(xml, []).unwrap().is_empty());
assert!(
fx237_legacy_namespace_scopes_at(xml, [])
.unwrap()
.is_empty()
);
}
let xml = b"<r xmlns='urn:base' xmlns:p='urn:one'><a xmlns='' xmlns:p=''><leaf/></a><sibling/></r>";
let at = |needle: &[u8]| {
xml.windows(needle.len())
.position(|bytes| bytes == needle)
.unwrap()
};
let leaf = story_namespace_scope_at(xml, at(b"<leaf")).unwrap();
assert!(!leaf.contains_key(""));
assert!(!leaf.contains_key("p"));
let restored = story_namespace_scope_at(xml, at(b"<sibling")).unwrap();
assert_eq!(restored.get("").map(String::as_str), Some("urn:base"));
assert_eq!(restored.get("p").map(String::as_str), Some("urn:one"));
let inherited = story_namespace_scopes_at(xml, [at(b"<a ")]).unwrap();
assert_eq!(inherited[&at(b"<a ")], restored);
}
#[test]
fn fx237_namespace_scope_frames_only_copy_declarations() {
for cells in [60, 120, 240] {
let mut document = Document::new();
document.add_table(cells / 6, 6);
let xml = document.document.to_xml().unwrap();
let mut reader = quick_xml::Reader::from_reader(xml.as_slice());
let mut offsets = Vec::new();
let mut buffer = Vec::new();
loop {
let before = reader.buffer_position() as usize;
match reader.read_event_into(&mut buffer).unwrap() {
Event::Start(_) | Event::Empty(_) => offsets.push(before),
Event::Eof => break,
_ => {}
}
buffer.clear();
}
FX237_NAMESPACE_FRAME_COPIES.set(0);
story_namespace_scopes_at(&xml, offsets.clone()).unwrap();
story_namespace_scope_at(&xml, *offsets.last().unwrap()).unwrap();
let copies = FX237_NAMESPACE_FRAME_COPIES.get();
eprintln!(
"FX237 namespaceframes cells={cells} copies={copies} requested={}",
offsets.len()
);
assert!(
copies <= 2,
"only the root introduces namespace declarations: {copies}"
);
}
}
#[test]
fn fx237_combined_inventory_keeps_projection_and_relationship_refusals() {
for content in [
"<w:p><w:hyperlink r:id='missing'><w:r><w:t>link</w:t></w:r></w:hyperlink></w:p>",
"<w:p><w:r><w:t>text</w:t></w:r></w:p>",
"<w:p><f:hyperlink xmlns:f='urn:foreign'><w:r><w:t>opaque</w:t></w:r></f:hyperlink></w:p>",
"<w:p><w:sdt><w:sdtContent><w:r><w:t>inline</w:t></w:r></w:sdtContent></w:sdt></w:p>",
] {
let xml = format!(
"<w:document xmlns:w='{WORD_NAMESPACE}' xmlns:r='http://schemas.openxmlformats.org/officeDocument/2006/relationships'><w:body>{content}<w:sectPr/></w:body></w:document>"
);
let mut document = Document::new();
document.document = CT_Document::from_xml(xml.as_bytes()).unwrap();
fx237_assert_combined_inventory(&document);
if content.contains("r:id='missing'") {
assert!(document.story_link_snapshots().is_err());
assert!(document.story_owner_snapshots().is_err());
}
}
}
#[test]
fn fx237_batched_cell_routes_equal_checked_single_cell_routes() {
let table = |content: &str| {
format!(
"<w:tbl><w:tblPr/><w:tblGrid><w:gridCol w:w='1000'/><w:gridCol w:w='1000'/></w:tblGrid>{content}</w:tbl>"
)
};
let cell = "<w:tc><w:tcPr/><w:p><w:r><w:t>cell</w:t></w:r></w:p></w:tc>";
let row = format!("<w:tr>{cell}{cell}</w:tr>");
let merged = "<w:tr><w:trPr><w:gridBefore w:val='1'/></w:trPr><w:tc><w:tcPr><w:gridSpan w:val='2'/><w:vMerge w:val='restart'/></w:tcPr><w:p/></w:tc></w:tr>";
let controlled =
format!("<w:sdt><w:sdtPr/><w:sdtContent>{row}</w:sdtContent></w:sdt>{row}");
let cell_control = format!(
"<w:tr><w:sdt><w:sdtPr/><w:sdtContent>{cell}</w:sdtContent></w:sdt>{cell}</w:tr>"
);
for content in [
table(&row),
table(merged),
table(&controlled),
table(&cell_control),
format!(
"<w:sdt><w:sdtPr/><w:sdtContent>{}</w:sdtContent></w:sdt>",
table(&row)
),
table(
"<w:tr><f:tc xmlns:f='urn:foreign'><w:p/></f:tc><w:tc><w:tcPr><x:keep xmlns:x='urn:opaque'/></w:tcPr><w:p/></w:tc></w:tr>",
),
] {
let mut document = Document::new();
let xml = format!(
"<w:document xmlns:w='{WORD_NAMESPACE}'><w:body>{content}<w:sectPr/></w:body></w:document>"
);
document.document = CT_Document::from_xml(xml.as_bytes()).unwrap();
fx237_assert_combined_inventory(&document);
let actual = document.story_table_cell_snapshots().unwrap();
let mut expected = HashMap::new();
for snapshot in document.story_item_snapshots().unwrap() {
if snapshot.location().item_kind() != StoryItemKind::Table {
continue;
}
let location = snapshot.location();
for (row, cell) in document
.read_story_table(location, |table| {
(0..table.row_count())
.flat_map(|row| {
(0..table.row(row).unwrap().cell_count())
.map(move |cell| (row, cell))
})
.collect::<Vec<_>>()
})
.unwrap()
{
assert_eq!(
document.story_table_cell(location, row, cell).unwrap(),
document
.fx237_legacy_story_table_cell(location, row, cell)
.unwrap()
);
expected.insert(
(location.clone(), row, cell),
document
.fx237_legacy_story_table_cell(location, row, cell)
.unwrap(),
);
}
}
assert_eq!(
actual
.into_iter()
.map(|(table, row, cell, story)| ((table, row, cell), story))
.collect::<HashMap<_, _>>(),
expected
);
}
let mut related = all_story_range_fixture();
let roots = related
.stories()
.unwrap()
.into_iter()
.filter(|story| story.kind() != StoryKind::TableCell)
.collect::<Vec<_>>();
let mut seed = Document::new();
let model = seed.add_table(1, 1).inner.clone();
for root in roots {
let current = related
.stories()
.unwrap()
.into_iter()
.find(|story| {
story.kind() == root.kind()
&& story.part_name() == root.part_name()
&& story.owner_index() == root.owner_index()
})
.unwrap();
if current.kind() == StoryKind::TextBox {
let (source, owner) = related.story_source_and_owner(¤t).unwrap();
if scan_story_owners(&source.xml, source.root_kind)
.unwrap()
.iter()
.any(|parent| {
parent.kind == StoryKind::TableCell
&& parent.full.start < owner.full.start
&& owner.full.end < parent.full.end
})
{
// The scalar contract refuses cells hidden in paragraph
// drawing XML inside an enclosing modeled table.
continue;
}
}
related
.insert_content(
&ContentLocation::end(current),
ContentFragment::table(model.clone()).unwrap(),
)
.unwrap();
}
fx237_assert_combined_inventory(&related);
let mut kinds = HashSet::new();
let mut expected = HashMap::new();
for snapshot in related.story_item_snapshots().unwrap() {
if snapshot.location().item_kind() != StoryItemKind::Table {
continue;
}
let location = snapshot.location();
kinds.insert(location.story().kind());
for (row, cell) in related
.read_story_table(location, |table| {
(0..table.row_count())
.flat_map(|row| {
(0..table.row(row).unwrap().cell_count()).map(move |cell| (row, cell))
})
.collect::<Vec<_>>()
})
.unwrap()
{
let legacy = related
.fx237_legacy_story_table_cell(location, row, cell)
.unwrap();
assert_eq!(
related.story_table_cell(location, row, cell).unwrap(),
legacy
);
expected.insert((location.clone(), row, cell), legacy);
}
}
for kind in [
StoryKind::Body,
StoryKind::Header,
StoryKind::Footer,
StoryKind::Footnote,
StoryKind::Endnote,
StoryKind::Comment,
StoryKind::TextBox,
] {
assert!(
kinds.contains(&kind),
"missing qualified related table {kind:?}"
);
}
assert_eq!(
related.story_table_cell_snapshots().unwrap().len(),
expected.len(),
"physical alias routes must not duplicate owners"
);
assert_eq!(
related
.story_table_cell_snapshots()
.unwrap()
.into_iter()
.map(|(table, row, cell, story)| ((table, row, cell), story))
.collect::<HashMap<_, _>>(),
expected
);
for rows in [1, 2] {
let mut document = Document::new();
document
.add_table(rows, 2)
.cell(0, 0)
.unwrap()
.add_table(1, 2);
document.add_table(1, 1);
document.set_header("related");
let mut expected = HashMap::new();
for snapshot in document.story_item_snapshots().unwrap() {
if snapshot.location().item_kind() != StoryItemKind::Table {
continue;
}
let location = snapshot.location();
let coordinates = document
.read_story_table(location, |table| {
(0..table.row_count())
.flat_map(|row| {
(0..table.row(row).unwrap().cell_count())
.map(move |cell| (row, cell))
})
.collect::<Vec<_>>()
})
.unwrap();
for (row, cell) in coordinates {
assert_eq!(
document.story_table_cell(location, row, cell).unwrap(),
document
.fx237_legacy_story_table_cell(location, row, cell)
.unwrap()
);
expected.insert(
(location.clone(), row, cell),
document
.fx237_legacy_story_table_cell(location, row, cell)
.unwrap(),
);
}
}
STORY_SOURCE_BUILDS.set(0);
FX237_TABLE_VISITS.set(0);
FX237_CELL_VISITS.set(0);
let actual = document
.story_table_cell_snapshots()
.unwrap()
.into_iter()
.map(|(table, row, cell, story)| ((table, row, cell), story))
.collect::<HashMap<_, _>>();
assert_eq!(actual, expected);
assert_eq!(STORY_SOURCE_BUILDS.get(), 1);
assert_eq!(FX237_TABLE_VISITS.get(), 3);
assert_eq!(FX237_CELL_VISITS.get(), rows * 2 + 3);
let reopened = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
assert_eq!(
reopened.story_table_cell_snapshots().unwrap().len(),
actual.len()
);
}
}
#[test]
fn fx237_table_cell_snapshots_scan_each_source_and_visit_each_cell_once() {
for cells in [60, 120, 240] {
let mut document = Document::new();
document.add_table(cells / 6, 6);
STORY_SOURCE_BUILDS.set(0);
STORY_OWNER_SCANS.set(0);
STORY_NAMESPACE_SCANS.set(0);
FX237_ITEM_SCANS.set(0);
FX237_PREFIX_NAMESPACE_BYTES.set(0);
FX237_TABLE_VISITS.set(0);
FX237_CELL_VISITS.set(0);
let routes = document.story_owner_snapshots().unwrap().1;
assert_eq!(FX237_ITEM_SCANS.get(), cells + 1);
let source_bytes = document.document.to_xml().unwrap().len();
assert!(
FX237_PREFIX_NAMESPACE_BYTES.get() <= source_bytes,
"prefix namespace work must remain bounded per registry"
);
eprintln!(
"FX237 fullregistry cells={cells} source={} owners={} namespace={} items={} prefix_bytes={} source_bytes={source_bytes}",
STORY_SOURCE_BUILDS.get(),
STORY_OWNER_SCANS.get(),
STORY_NAMESPACE_SCANS.get(),
FX237_ITEM_SCANS.get(),
FX237_PREFIX_NAMESPACE_BYTES.get()
);
assert_eq!(FX237_TABLE_VISITS.get(), 1);
assert_eq!(FX237_CELL_VISITS.get(), cells);
assert_eq!(STORY_NAMESPACE_SCANS.get(), 3);
assert_eq!(routes.len(), cells);
assert_eq!(
STORY_SOURCE_BUILDS.get(),
1,
"complete cell inventory must materialize sources once"
);
assert_eq!(
STORY_OWNER_SCANS.get(),
1,
"complete cell inventory must scan each source once"
);
STORY_SOURCE_BUILDS.set(0);
STORY_OWNER_SCANS.set(0);
FX237_ITEM_SCANS.set(0);
FX237_PREFIX_NAMESPACE_BYTES.set(0);
FX237_LEGACY_CELL_STEPS.set(0);
FX237_CELL_VISITS.set(0);
FX237_TABLE_VISITS.set(0);
document
.story_table_cell(&routes[0].0, cells / 6 - 1, 5)
.unwrap();
assert_eq!(
FX237_CELL_VISITS.get(),
cells,
"public scalar provider visits each cell once"
);
assert_eq!(
STORY_SOURCE_BUILDS.get(),
1,
"scalar source must be materialized once"
);
assert_eq!(
STORY_OWNER_SCANS.get(),
2,
"scalar qualification and physical correspondence scans"
);
assert_eq!(FX237_ITEM_SCANS.get(), 1);
assert!(FX237_PREFIX_NAMESPACE_BYTES.get() <= source_bytes);
assert_eq!(FX237_TABLE_VISITS.get(), 1);
assert!(
FX237_LEGACY_CELL_STEPS.get() <= cells,
"scalar lookup repeats physical cell walks: {}",
FX237_LEGACY_CELL_STEPS.get()
);
}
}
#[test]
fn fx222_owner_and_namespace_scans_are_per_source() {
for rows in [32, 64] {
let mut document = Document::new();
document.add_paragraph("before");
let mut table = document.add_table(rows, 8);
for row in 0..rows {
for column in 0..8 {
table.cell(row, column).unwrap().set_text("cell");
}
}
document.set_header("header");
let document = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
let source_count = document.story_sources().unwrap().len();
STORY_OWNER_SCANS.set(0);
STORY_NAMESPACE_SCANS.set(0);
let paragraphs = document.story_range_paragraphs().unwrap();
assert_eq!(
paragraphs
.iter()
.filter(|(location, _)| location.story().kind() == StoryKind::TableCell)
.count(),
rows * 8
);
assert_eq!(STORY_OWNER_SCANS.get(), source_count);
assert_eq!(STORY_NAMESPACE_SCANS.get(), source_count * 2);
}
}
#[test]
#[ignore = "Owned release-mode separate doubled-TOC cost qualification"]
fn fx222_multiple_toc_costs() {
let output = std::path::PathBuf::from(std::env::var("FX222_BENCH_OUTPUT_DIR").unwrap());
std::fs::create_dir_all(&output).unwrap();
for toc_count in [1usize, 2] {
let mut seed = Document::new();
for section in 0..40 {
seed.add_paragraph(&format!("Section {}", section + 1))
.style("Heading1");
for paragraph in 0..5 {
seed.add_paragraph(&format!("Paragraph {paragraph} of section {section}."));
}
}
let mut table = seed.add_table(200, 4);
for row in 0..200 {
for column in 0..4 {
table
.cell(row, column)
.unwrap()
.set_text(&format!("r{row} c{column}"));
}
}
for _ in 0..toc_count {
seed.insert_toc(0, 3).unwrap();
}
let input = seed.to_bytes().unwrap();
for sample in 0..4 {
let mut document = Document::from_bytes(&input).unwrap();
let started = std::time::Instant::now();
let report = document.rebuild_toc().unwrap();
let elapsed = started.elapsed().as_secs_f64();
assert_eq!(report.entry_count, 40 * toc_count);
println!("FX222_TOC_COST count={toc_count} sample={sample} seconds={elapsed:.9}");
if sample == 3 {
std::fs::write(output.join(format!("input-tocs{toc_count}.docx")), &input)
.unwrap();
std::fs::write(
output.join(format!("output-tocs{toc_count}.docx")),
document.to_bytes().unwrap(),
)
.unwrap();
}
}
}
}
#[test]
fn fx222_empty_owners_and_invalid_scopes() {
let xml = format!(
r#"<q:comments xmlns:q="{WORD_NAMESPACE}" xmlns:x="urn:foreign"><q:comment q:id="7" x:opaque="keep"/></q:comments>"#
);
let owner = scan_story_owners(xml.as_bytes(), StoryKind::Comment)
.unwrap()
.remove(0);
let story = StoryId {
kind: owner.kind,
part_name: "/word/comments.xml".into(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
};
let result =
story_range_paragraph_spans(xml.as_bytes(), &[(story.clone(), owner.clone())]).unwrap();
assert_eq!(result.len(), 1);
assert!(result[0].is_empty());
let foreign = xml.replace(WORD_NAMESPACE, "urn:foreign-comments");
assert!(
scan_story_owners(foreign.as_bytes(), StoryKind::Comment)
.unwrap()
.is_empty()
);
let mut invalid = owner.clone();
invalid.full.end = xml.len() + 1;
assert!(story_range_paragraph_spans(xml.as_bytes(), &[(story.clone(), invalid)]).is_err());
let mut invalid = owner;
invalid.full.start = xml.len() + 1;
invalid.full.end = xml.len() + 2;
assert!(story_range_paragraph_spans(xml.as_bytes(), &[(story, invalid)]).is_err());
}
#[test]
fn fx222_inventory_builds_each_source_once() {
let mut document = Document::new();
document.add_paragraph("before");
let mut table = document.add_table(32, 8);
for row in 0..32 {
for column in 0..8 {
table
.cell(row, column)
.unwrap()
.set_text(&format!("cell {row} {column}"));
}
}
document.add_paragraph("after");
document.set_header("header");
let document = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
STORY_SOURCE_BUILDS.set(0);
let paragraphs = document.story_range_paragraphs().unwrap();
assert_eq!(
paragraphs
.iter()
.filter(|(location, _)| location.story().kind() == StoryKind::TableCell)
.count(),
256
);
assert_eq!(STORY_SOURCE_BUILDS.get(), 1);
STORY_SOURCE_BUILDS.set(0);
assert!(document.story_ranges().unwrap().is_empty());
assert_eq!(STORY_SOURCE_BUILDS.get(), 1);
STORY_SOURCE_BUILDS.set(0);
assert!(document.text_box_cache_paragraphs().unwrap().is_empty());
assert_eq!(STORY_SOURCE_BUILDS.get(), 1);
}
#[test]
fn fx222_self_closing_owner_lists_without_failure() {
let w = WORD_NAMESPACE;
let mut seed = Document::new();
seed.add_paragraph("Commented text");
let id = seed
.add_comment_on_text("Commented", 0, "Ada", None, "note", None)
.unwrap();
let mut package = OpcPackage::from_reader(Cursor::new(seed.to_bytes().unwrap())).unwrap();
let part = seed.comments_part_name.clone().unwrap();
let comments = String::from_utf8(package.get_part(&part).unwrap().to_vec()).unwrap();
let root = comments.find("<w:comments").unwrap();
let first = root + comments[root..].find('>').unwrap() + 1;
let comments = format!(
r#"{}<w:comment xmlns:w="{w}" w:id="{}" w:author="Ada"/>{}"#,
&comments[..first],
id + 1,
&comments[first..]
);
package.set_part(&part, comments.into_bytes());
let original_comments = package.get_part(&part).unwrap().to_vec();
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
let mut document = Document::from_bytes(bytes.get_ref()).unwrap();
let paragraphs = document.story_range_paragraphs().unwrap();
assert_eq!(
paragraphs
.iter()
.filter(|(location, _)| location.story().kind() == StoryKind::Comment)
.count(),
1
);
assert_eq!(document.story_ranges().unwrap().len(), 1);
assert_eq!(document.comment_anchor_snapshots().unwrap().len(), 2);
document.update_page_fields().unwrap();
let saved = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&saved).unwrap();
assert_eq!(reopened.comment_anchor_snapshots().unwrap().len(), 2);
assert_eq!(
reopened
.story_range_paragraphs()
.unwrap()
.iter()
.filter(|(location, _)| location.story().kind() == StoryKind::Comment)
.count(),
1
);
let saved_package = OpcPackage::from_reader(Cursor::new(saved)).unwrap();
assert_eq!(saved_package.get_part(&part).unwrap(), original_comments);
}
#[test]
fn fx211_loaded_owner_refusal_precedes_staging_and_file_replacement() {
for owner in 0..4 {
let source = format!(
"<w:document xmlns:w=\"{WORD_NAMESPACE}\"><w:body><w:p><w:sdt><w:sdtContent><w:r><w:t>leaf</w:t></w:r></w:sdtContent></w:sdt></w:p></w:body></w:document>"
);
let mut model = CT_Document::from_xml(source.as_bytes()).unwrap();
let BodyContent::Paragraph(mut paragraph) = model.body.content.remove(0) else {
panic!("expected paragraph");
};
let template = paragraph.content_controls[0].3.clone();
let mut control = template.clone();
for _ in 1..257 {
let mut outer = template.clone();
outer.content = vec![rdocx_oxml::content_control::SdtContent::ContentControl(
control,
)];
control = outer;
}
paragraph.content_controls[0].3 = control;
let mut document = Document::new();
document.add_paragraph("unrelated source");
match owner {
0 => document.document.body.content = vec![BodyContent::Paragraph(paragraph)],
1 => document
.footnotes
.footnotes
.push(rdocx_oxml::footnotes::CT_Footnote {
id: 1,
note_type: rdocx_oxml::footnotes::NoteType::Normal,
paragraphs: vec![paragraph],
}),
2 => {
let source = format!(
"<w:comments xmlns:w=\"{WORD_NAMESPACE}\"><w:comment w:id=\"1\"><w:p/></w:comment></w:comments>"
);
let mut comments =
rdocx_oxml::comments::CT_Comments::from_xml(source.as_bytes()).unwrap();
comments.comments[0].paragraphs = vec![paragraph];
document.comments = Some(comments);
}
3 => {
let source = format!(
"<w:glossaryDocument xmlns:w=\"{WORD_NAMESPACE}\"><w:docParts><w:docPart><w:docPartPr/><w:docPartBody><w:p/></w:docPartBody></w:docPart></w:docParts></w:glossaryDocument>"
);
let mut glossary =
rdocx_oxml::glossary::CT_GlossaryDocument::from_xml(source.as_bytes())
.unwrap();
glossary.doc_parts[0].body.content = vec![BodyContent::Paragraph(paragraph)];
document.glossary = Some(glossary);
}
_ => unreachable!(),
}
let mut before = std::io::Cursor::new(Vec::new());
document.package.write_to(&mut before).unwrap();
let directory = std::env::temp_dir().join(format!(
"rdocx-fx211-save-atomic-{}-{owner}",
std::process::id()
));
assert!(!directory.exists());
std::fs::create_dir(&directory).unwrap();
let path = directory.join("existing.docx");
std::fs::write(&path, b"retained destination").unwrap();
for error in [
document.to_bytes().unwrap_err(),
document
.to_bytes_as(WordPackageClass::Document)
.unwrap_err(),
document.save(&path).unwrap_err(),
] {
assert!(
error.to_string().contains("weighted limit 256"),
"owner {owner}: {error}"
);
}
assert_eq!(std::fs::read(&path).unwrap(), b"retained destination");
let mut after = std::io::Cursor::new(Vec::new());
document.package.write_to(&mut after).unwrap();
assert_eq!(
after.into_inner(),
before.into_inner(),
"owner {owner} package unchanged"
);
std::fs::remove_dir_all(&directory).unwrap();
}
}
#[test]
fn fx211_authored_table_refusal_precedes_facade_clone_and_file_replacement() {
let mut table = CT_Tbl::new();
for _ in 1..33 {
let mut outer = CT_Tbl::new();
let mut row = rdocx_oxml::table::CT_Row::new();
let mut cell = rdocx_oxml::table::CT_Tc::new();
cell.content = vec![rdocx_oxml::table::CellContent::Table(table)];
row.cells = vec![cell];
outer.rows = vec![row];
table = outer;
}
let mut document = Document::new();
document.document.body.content = vec![BodyContent::Table(table)];
assert!(document.validate_model_resource_limits().is_err());
let mut before = std::io::Cursor::new(Vec::new());
document.package.write_to(&mut before).unwrap();
let directory = std::env::temp_dir().join(format!(
"rdocx-fx211-table-save-atomic-{}",
std::process::id()
));
assert!(!directory.exists());
std::fs::create_dir(&directory).unwrap();
let path = directory.join("existing.docx");
std::fs::write(&path, b"retained table destination").unwrap();
for error in [
document.to_bytes().unwrap_err(),
document
.to_bytes_as(WordPackageClass::Document)
.unwrap_err(),
document.save(&path).unwrap_err(),
] {
assert!(error.to_string().contains("table limit"));
}
assert_eq!(std::fs::read(&path).unwrap(), b"retained table destination");
let mut after = std::io::Cursor::new(Vec::new());
document.package.write_to(&mut after).unwrap();
assert_eq!(before.into_inner(), after.into_inner());
std::fs::remove_dir_all(directory).unwrap();
}
#[test]
fn fx211_drawing_marker_closure_preserves_owner_identity() {
let wp = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
let source = format!(
r#"<w:r xmlns:w="{WORD_NAMESPACE}" xmlns:wp="{wp}"><w:drawing xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture"><wp:inline><wp:extent cx="1" cy="1"/><wp:docPr id="31" name="Inherited picture"/><a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:blipFill><a:blip xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships" r:embed="bodyImage"/></pic:blipFill></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>"#
);
let run = CT_R::from_xml_fragment_checked(source.as_bytes()).unwrap();
let mut writer = quick_xml::Writer::new(Vec::new());
run.to_xml(&mut writer).unwrap();
let candidate = writer.into_inner();
let declarations = vec![("xmlns:wp".into(), wp.into())];
let inherited = vec![("xmlns:w".into(), WORD_NAMESPACE.into())];
let expected =
namespace_owner_markers(source.as_bytes(), &declarations, &inherited, false).unwrap();
assert!(!expected.is_empty());
let matched =
candidate_namespace_owner_markers(&candidate, &declarations, &inherited, &expected)
.unwrap();
assert!(marker_multiset_matches(&expected, &matched, true));
let candidate = String::from_utf8(candidate).unwrap();
for hostile in [
candidate.replace(
"xmlns:a=\"http://schemas.openxmlformats.org/drawingml/2006/main\"",
"xmlns:a=\"urn:foreign\"",
),
candidate.replace(
" xmlns:pic=\"http://schemas.openxmlformats.org/drawingml/2006/picture\"",
"",
),
candidate.replace("<wp:inline ", "<wp:inline xmlns=\"urn:foreign-default\" "),
candidate.replace("<w:drawing>", "<w:drawing xmlns:w=\"urn:foreign-word\">"),
] {
let matched = candidate_namespace_owner_markers(
hostile.as_bytes(),
&declarations,
&inherited,
&expected,
)
.unwrap();
assert!(
!marker_multiset_matches(&expected, &matched, true),
"hostile namespace closure: {hostile}"
);
}
for payload in [
" keep=\"a:Producer\"",
" xmlns:unused=\"urn:producer\" keep=\"unchanged\"",
] {
let source = source.replace("<w:drawing ", &format!("<w:drawing{payload} "));
let candidate = candidate.replace("<w:drawing>", &format!("<w:drawing{payload}>"));
let expected =
namespace_owner_markers(source.as_bytes(), &declarations, &inherited, false)
.unwrap();
let matched = candidate_namespace_owner_markers(
candidate.as_bytes(),
&declarations,
&inherited,
&expected,
)
.unwrap();
assert!(
!marker_multiset_matches(&expected, &matched, true),
"opaque wrapper attributes"
);
}
for payload in [
"<x:opaque xmlns:x=\"urn:producer\">a:QName</x:opaque>",
"a:P",
] {
let source = source.replace("</w:drawing>", &format!("{payload}</w:drawing>"));
let candidate = candidate.replace("</w:drawing>", &format!("{payload}</w:drawing>"));
let expected =
namespace_owner_markers(source.as_bytes(), &declarations, &inherited, false)
.unwrap();
let matched = candidate_namespace_owner_markers(
candidate.as_bytes(),
&declarations,
&inherited,
&expected,
)
.unwrap();
assert!(
!marker_multiset_matches(&expected, &matched, true),
"opaque wrapper payload {payload}"
);
}
}
#[test]
fn fx211_checked_table_cell_foreign_w_replacements_survive_modeled_save() {
let mut document = Document::new();
document.add_table(1, 1);
let table = document
.story_item_snapshots()
.unwrap()
.into_iter()
.find(|item| item.location().item_kind == StoryItemKind::Table)
.unwrap()
.location()
.clone();
let xml = format!(
r#"<q:tbl xmlns:q="{WORD_NAMESPACE}" xmlns:w="urn:producer" w:keep="table"><q:tblGrid><q:gridCol q:w="1440"/></q:tblGrid><q:tr><q:tc><q:p><q:r><q:t>table replacement</q:t></q:r></q:p></q:tc></q:tr></q:tbl>"#
);
document.replace_table_xml(&table, &xml).unwrap();
let table = document
.story_item_snapshots()
.unwrap()
.into_iter()
.find(|item| item.location().item_kind == StoryItemKind::Table)
.unwrap()
.location()
.clone();
let cell = format!(
r#"<q:tc xmlns:q="{WORD_NAMESPACE}" xmlns:w="urn:cell-producer" w:keep="cell"><q:p><q:r><q:t>cell replacement</q:t></q:r></q:p></q:tc>"#
);
document.replace_cell_xml(&table, 0, 0, &cell).unwrap();
let owner = document
.story_item_snapshots()
.unwrap()
.into_iter()
.find(|item| item.location().item_kind == StoryItemKind::Table)
.unwrap()
.location()
.clone();
let before = document.to_bytes().unwrap();
let unsupported = xml.replace(
"w:keep=\"table\"",
&format!(
"w:keep=\"table\" xmlns:mc=\"{}\" mc:Ignorable=\"w\"",
rdocx_oxml::namespace::MC_NS
),
);
let error = document
.replace_table_xml(&owner, &unsupported)
.unwrap_err();
assert!(error.to_string().contains("namespace-valued"), "{error}");
assert_eq!(document.to_bytes().unwrap(), before);
assert!(
document.read_story_table(&owner, |_| ()).is_ok(),
"failed replacement retired the held owner"
);
document.add_paragraph("force modeled document serialization");
let mut reopened = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
assert_eq!(
reopened.tables().len(),
1,
"checked replacement lost typed table"
);
assert_eq!(
reopened.tables()[0]
.cell(0, 0)
.unwrap()
.paragraphs()
.next()
.unwrap()
.text(),
"cell replacement"
);
let twice = Document::from_bytes(&reopened.to_bytes().unwrap()).unwrap();
assert_eq!(twice.tables().len(), 1);
let serialized = String::from_utf8(twice.document.to_xml().unwrap()).unwrap();
assert!(
serialized.contains("urn:producer") && serialized.contains("urn:cell-producer"),
"{serialized}"
);
}
#[test]
fn fx211_section_xml_refuses_bad_references_and_preserves_owned_source() {
let mut document = Document::new();
document.add_paragraph("unrelated");
document.try_set_header("retained header").unwrap();
let reference = document.section(0).unwrap().properties().header_refs[0]
.rel_id
.clone();
let xml = format!(
r#"<q:sectPr xmlns:q="{WORD_NAMESPACE}" xmlns:r="{}" xmlns:x="urn:producer" x:root="retained"><q:headerReference q:type="default" r:id="{reference}"/><q:pgSz q:w="12240" q:h="15840"/><x:payload xmlns:x="urn:producer" keep="exact"><x:opaque/></x:payload><q:titlePg/></q:sectPr>"#,
rdocx_oxml::namespace::R_NS
);
let before = document.to_bytes().unwrap();
for invalid in [
xml.replace(&reference, "absent"),
xml.replace("q:type=\"default\"", "q:type=\"invalid\""),
xml.replace("<q:titlePg/>", "<q:pgSz q:w=\"100\"/>"),
xml.replace("<q:titlePg/>", "<q:titlePg q:val=\"invalid\"/>"),
xml.replace("<q:titlePg/>", "<q:titlePg><q:p/></q:titlePg>"),
xml.replace("<q:headerReference", "<q:footerReference"),
] {
assert!(
document
.replace_section_properties_xml(0, &invalid)
.is_err(),
"accepted {invalid}"
);
assert_eq!(document.to_bytes().unwrap(), before);
}
document.replace_section_properties_xml(0, &xml).unwrap();
assert_eq!(document.paragraphs()[0].text(), "unrelated");
assert_eq!(document.header_text().as_deref(), Some("retained header"));
let mut output = Vec::new();
document
.section(0)
.unwrap()
.properties()
.to_xml(&mut Writer::new(&mut output))
.unwrap();
let output = String::from_utf8(output).unwrap();
assert!(output.contains("x:root=\"retained\""), "{output}");
assert!(
output.contains(
"<x:payload xmlns:x=\"urn:producer\" keep=\"exact\"><x:opaque/></x:payload>"
),
"{output}"
);
let reopened = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
assert_eq!(
reopened.section(0).unwrap().properties().page_width,
Some(Twips(12240))
);
assert_eq!(reopened.header_text().as_deref(), Some("retained header"));
assert!(document.replace_section_properties_xml(1, &xml).is_err());
}
#[test]
fn fx211_physical_table_and_merged_cell_xml_are_atomic() {
let mut document = Document::new();
document.add_paragraph("unrelated");
document.add_table(1, 2);
let location = document
.story_item_snapshots()
.unwrap()
.into_iter()
.find(|snapshot| {
snapshot.location().story.kind == StoryKind::Body
&& snapshot.location().item_kind == StoryItemKind::Table
})
.unwrap()
.location()
.clone();
let table = format!(
r#"<a:tbl xmlns:a="{WORD_NAMESPACE}" xmlns:x="urn:producer" x:table="retained"><a:tblGrid><a:gridCol a:w="1440"/><a:gridCol a:w="1440"/></a:tblGrid><a:tr><a:tc xmlns:y="urn:cell" y:cell="retained"><a:tcPr><a:gridSpan a:val="2"/></a:tcPr><a:p><a:r><a:t>merged</a:t></a:r></a:p></a:tc></a:tr></a:tbl>"#
);
document.replace_table_xml(&location, &table).unwrap();
assert!(document.read_story_table(&location, |_| ()).is_err());
let location = document
.story_item_snapshots()
.unwrap()
.into_iter()
.find(|snapshot| {
snapshot.location().story.kind == StoryKind::Body
&& snapshot.location().item_kind == StoryItemKind::Table
})
.unwrap()
.location()
.clone();
assert_eq!(
document
.read_story_table(&location, |table| table.cell(0, 0).unwrap().text())
.unwrap(),
"merged"
);
assert!(document.story_table_cell(&location, 0, 1).is_err());
assert_eq!(
document
.read_story_table(&location, |table| table.cell(0, 0).unwrap().grid_span())
.unwrap(),
Some(2)
);
let before = document.to_bytes().unwrap();
for invalid in [
format!(r#"<w:tbl xmlns:w="{WORD_NAMESPACE}"><w:tblGrid/><w:tblGrid/></w:tbl>"#),
format!(r#"<w:tbl xmlns:w="{WORD_NAMESPACE}"><w:p/></w:tbl>"#),
] {
assert!(document.replace_table_xml(&location, &invalid).is_err());
assert_eq!(document.to_bytes().unwrap(), before);
}
let bad_cell = format!(
r#"<w:tc xmlns:w="{WORD_NAMESPACE}"><w:tcPr><w:gridSpan w:val="3"/></w:tcPr><w:p/></w:tc>"#
);
assert!(
document
.replace_cell_xml(&location, 0, 0, &bad_cell)
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), before);
let cell = format!(
r#"<w:tc xmlns:w="{WORD_NAMESPACE}" xmlns:y="urn:cell" y:cell="retained"><w:tcPr><w:gridSpan w:val="2"/></w:tcPr><w:p><w:r><w:t>replacement</w:t></w:r><x:payload xmlns:x="urn:producer" x:keep='exact'><x:child/></x:payload></w:p></w:tc>"#
);
document.replace_cell_xml(&location, 0, 0, &cell).unwrap();
assert_eq!(document.paragraph(0).unwrap().text(), "unrelated");
let saved = document.to_bytes().unwrap();
let mut reopened = Document::from_bytes(&saved).unwrap();
let location = reopened
.story_item_snapshots()
.unwrap()
.into_iter()
.find(|snapshot| {
snapshot.location().story.kind == StoryKind::Body
&& snapshot.location().item_kind == StoryItemKind::Table
})
.unwrap()
.location()
.clone();
assert_eq!(
reopened
.read_story_table(&location, |table| table.cell(0, 0).unwrap().text())
.unwrap(),
"replacement"
);
assert!(reopened.story_table_cell(&location, 0, 1).is_err());
assert_eq!(
reopened
.read_story_table(&location, |table| table.cell(0, 0).unwrap().grid_span())
.unwrap(),
Some(2)
);
let main = String::from_utf8(
reopened
.story_sources()
.unwrap()
.into_iter()
.find(|source| source.part_name == reopened.doc_part_name)
.unwrap()
.xml
.into_owned(),
)
.unwrap();
assert!(main.contains("x:table=\"retained\""));
assert!(main.contains("y:cell=\"retained\""));
assert!(
main.contains(
"<x:payload xmlns:x=\"urn:producer\" x:keep='exact'><x:child/></x:payload>"
)
);
let twice = Document::from_bytes(&reopened.to_bytes().unwrap()).unwrap();
assert_eq!(twice.paragraph(0).unwrap().text(), "unrelated");
}
#[test]
fn fx211_excluded_nested_owners_preserve_later_text_fields_and_raw_spans() {
let field = r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText> PAGE </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>7</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r>"#;
let table_start = r#"<w:tbl><w:tblGrid><w:gridCol w:w="1440"/></w:tblGrid><w:tr><w:tc>"#;
let table_end = "</w:tc></w:tr></w:tbl>";
let leaf = r#"<w:sdt><w:sdtContent><w:p><w:r><w:t>excluded</w:t></w:r></w:p></w:sdtContent></w:sdt>"#;
let owned = format!(
r#"<w:sdt><w:sdtContent><w:p><w:r><w:t>before</w:t></w:r></w:p>{table_start}{table_start}{leaf}{table_end}{table_end}<w:p><w:r><w:t>after</w:t></w:r>{field}</w:p></w:sdtContent></w:sdt>"#
);
let opaque = r#"<x:node x:keep='yes'><w:tc><w:p><w:r><w:t>foreign-phantom</w:t></w:r></w:p></w:tc></x:node>"#;
let rebound = r#"<w:p xmlns:w="urn:foreign"><w:r><w:t>rebound-phantom</w:t></w:r></w:p>"#;
let xml = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}" xmlns:x="urn:producer"><w:body>{owned}{opaque}{rebound}</w:body></w:document>"#
);
let owners = scan_story_owners(xml.as_bytes(), StoryKind::Body).unwrap();
assert_eq!(
owners.iter().map(|owner| owner.kind).collect::<Vec<_>>(),
[StoryKind::Body, StoryKind::TableCell, StoryKind::TableCell]
);
FX211_PROFILE.set([0; 12]);
let body = scan_story_items(xml.as_bytes(), &owners[0]).unwrap();
let selected = body
.iter()
.find(|item| item.kind == StoryItemKind::ContentControl)
.unwrap();
assert_eq!(&xml[selected.full.clone()], owned);
assert_eq!(
story_item_text(xml.as_bytes(), selected, &[], 0..xml.len(), 0)
.unwrap()
.as_deref(),
Some("beforeafter7")
);
assert!(
body.iter()
.any(|item| item.kind == StoryItemKind::PreservedNode
&& &xml[item.full.clone()] == opaque)
);
assert!(
body.iter()
.any(|item| item.kind == StoryItemKind::PreservedNode
&& &xml[item.full.clone()] == rebound)
);
let leaf_items = scan_story_items(xml.as_bytes(), &owners[2]).unwrap();
let leaf_item = leaf_items
.iter()
.find(|item| item.kind == StoryItemKind::ContentControl)
.unwrap();
assert_eq!(&xml[leaf_item.full.clone()], leaf);
assert_eq!(
story_item_text(xml.as_bytes(), leaf_item, &[], 0..xml.len(), 0)
.unwrap()
.as_deref(),
Some("excluded")
);
let counts = FX211_PROFILE.get();
assert_eq!(counts[2], 0);
assert_eq!(counts[10], 0);
}
#[test]
fn fx211_control_paragraph_inventory_refuses_raw_wrapper_authority() {
for raw in [
r#"<f:payload xmlns:f="urn:foreign"><w:sdtContent><w:p><w:r><w:t>phantom</w:t></w:r></w:p></w:sdtContent></f:payload>"#,
r#"<f:payload xmlns:f="urn:foreign"><w:sdt><w:sdtContent><w:p><w:r><w:t>phantom</w:t></w:r></w:p></w:sdtContent></w:sdt></f:payload>"#,
] {
let original = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body><w:sdt>{raw}<w:sdtContent><w:p><w:r><w:t>visible</w:t></w:r></w:p></w:sdtContent></w:sdt></w:body></w:document>"#
);
for xml in [
original.clone(),
original.replace("w:", "q:").replace("xmlns:w=", "xmlns:q="),
original.replace("w:", "").replace("xmlns:w=", "xmlns="),
] {
let typed = CT_Document::from_xml(xml.as_bytes()).unwrap();
assert_eq!(
typed.body.paragraphs().map(CT_P::text).collect::<Vec<_>>(),
["visible"]
);
let owners = scan_story_owners(xml.as_bytes(), StoryKind::Body).unwrap();
let items = scan_story_items(xml.as_bytes(), &owners[0]).unwrap();
let control = items
.iter()
.find(|item| item.kind == StoryItemKind::ContentControl)
.unwrap();
let scope = story_namespace_scope_at(xml.as_bytes(), control.full.start).unwrap();
assert_eq!(
story_item_text_with_scope(xml.as_bytes(), control, &scope, &items)
.unwrap()
.as_deref(),
Some("visible")
);
let paragraphs =
scan_story_control_paragraphs_with_scope(xml.as_bytes(), control, &scope)
.unwrap();
assert_eq!(paragraphs.len(), 1, "actual modeled direct owner: {xml}");
assert!(
std::str::from_utf8(&xml.as_bytes()[paragraphs[0].clone()])
.unwrap()
.contains("visible")
);
assert!(xml[control.full.clone()].contains("phantom"));
}
}
}
#[test]
fn fx211_duplicate_content_projects_only_native_first_typed_slot() {
let raw = "<w:sdtContent><w:p><w:r><w:t>phantom</w:t></w:r></w:p></w:sdtContent>";
let xml = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body><w:sdt><w:sdtContent><w:p><w:r><w:t>visible</w:t></w:r></w:p></w:sdtContent>{raw}</w:sdt><w:p><w:r><w:t>tail</w:t></w:r></w:p></w:body></w:document>"#
);
let typed = CT_Document::from_xml(xml.as_bytes()).unwrap();
let paragraphs = typed.body.paragraphs().map(CT_P::text).collect::<Vec<_>>();
assert_eq!(paragraphs, ["visible", "tail"]);
let owners = scan_story_owners(xml.as_bytes(), StoryKind::Body).unwrap();
let items = scan_story_items(xml.as_bytes(), &owners[0]).unwrap();
let root = items
.iter()
.find(|item| item.kind == StoryItemKind::ContentControl)
.unwrap();
let scope = story_namespace_scope_at(xml.as_bytes(), root.scan.start).unwrap();
assert_eq!(
story_item_text_with_scope(xml.as_bytes(), root, &scope, &items)
.unwrap()
.as_deref(),
Some(paragraphs[0].as_str())
);
assert!(xml[root.full.clone()].contains(raw));
}
#[test]
fn fx211_duplicate_content_namespace_and_first_slot_lifecycle() {
let duplicate = "<w:sdtContent><w:p><w:r><w:t>phantom</w:t></w:r></w:p></w:sdtContent>";
let first = "<w:sdtContent><w:p><w:r><w:t>visible</w:t></w:r></w:p></w:sdtContent>";
for content in [
format!("{first}{duplicate}"),
format!("<w:sdtContent><w:sdt>{first}{duplicate}</w:sdt></w:sdtContent>{duplicate}"),
format!("<w:sdtContent/>{first}{duplicate}"),
format!(
"<f:sdtContent xmlns:f=\"urn:foreign\"><w:p><w:r><w:t>foreign</w:t></w:r></w:p></f:sdtContent>{first}{duplicate}"
),
] {
let original = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body><w:sdt>{content}</w:sdt></w:body></w:document>"#
);
for xml in [
original.clone(),
original.replace("w:", "q:").replace("xmlns:w=", "xmlns:q="),
original.replace("w:", "").replace("xmlns:w=", "xmlns="),
] {
let typed = CT_Document::from_xml(xml.as_bytes()).unwrap();
let native = typed.body.paragraphs().map(CT_P::text).collect::<String>();
assert_eq!(native, "visible");
let owners = scan_story_owners(xml.as_bytes(), StoryKind::Body).unwrap();
let items = scan_story_items(xml.as_bytes(), &owners[0]).unwrap();
let root = items
.iter()
.find(|item| item.kind == StoryItemKind::ContentControl)
.unwrap();
let scope = story_namespace_scope_at(xml.as_bytes(), root.scan.start).unwrap();
assert_eq!(
story_item_text_with_scope(xml.as_bytes(), root, &scope, &items)
.unwrap()
.as_deref(),
Some(native.as_str())
);
// This inventory deliberately owns only direct paragraphs of the selected control.
let direct_paragraphs = usize::from(!content.starts_with("<w:sdtContent><w:sdt>"));
assert_eq!(
scan_story_control_paragraphs_with_scope(xml.as_bytes(), root, &scope)
.unwrap()
.len(),
direct_paragraphs
);
let mut document = Document::new();
document
.package
.set_part(&document.doc_part_name.clone(), xml.as_bytes().to_vec());
let mut document = Document::from_package(document.package).unwrap();
let saved = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&saved).unwrap();
let output = std::str::from_utf8(
reopened.package.get_part(&reopened.doc_part_name).unwrap(),
)
.unwrap();
let raw = if xml.contains("<q:document") {
duplicate.replace("w:", "q:")
} else if xml.contains("<document") {
duplicate.replace("w:", "")
} else {
duplicate.to_owned()
};
assert!(output.contains(&raw));
assert_eq!(
reopened
.document
.body
.paragraphs()
.map(CT_P::text)
.collect::<String>(),
native
);
assert!(xml[root.full.clone()].contains("phantom"));
}
}
}
#[test]
fn fx211_duplicate_content_consumers_preserve_opaque_payload() {
let phantom = r#"<w:sdtContent><w:p><w:bookmarkStart w:id="7" w:name="raw"/><w:hyperlink w:anchor="raw"><w:r><w:t>phantom</w:t></w:r></w:hyperlink><w:bookmarkEnd w:id="7"/></w:p><w:tbl><w:tr><w:tc><w:p/></w:tc></w:tr></w:tbl></w:sdtContent>"#;
let first = r#"<w:sdtContent><w:p><w:bookmarkStart w:id="6" w:name="visible"/><w:hyperlink w:anchor="visible"><w:r><w:t>visible</w:t></w:r></w:hyperlink><w:bookmarkEnd w:id="6"/></w:p></w:sdtContent>"#;
let xml = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body><w:sdt>{first}{phantom}</w:sdt></w:body></w:document>"#
);
let owners = scan_story_owners(xml.as_bytes(), StoryKind::Body).unwrap();
assert_eq!(owners.len(), 1);
let items = scan_story_items(xml.as_bytes(), &owners[0]).unwrap();
let root = items
.iter()
.find(|item| item.kind == StoryItemKind::ContentControl)
.unwrap();
assert_eq!(
scan_story_control_paragraphs(xml.as_bytes(), root)
.unwrap()
.len(),
1
);
assert_eq!(
scan_story_item_links(xml.as_bytes(), root).unwrap().len(),
1
);
let bookmarks = bookmark_marker_spans(xml.as_bytes()).unwrap();
assert_eq!(bookmarks.len(), 2);
assert!(bookmarks.iter().all(|marker| marker.id == "6"));
let changed = replace_story_item_text(xml.as_bytes(), root, "changed").unwrap();
assert!(std::str::from_utf8(&changed).unwrap().contains(phantom));
assert_eq!(
CT_Document::from_xml(&changed)
.unwrap()
.body
.paragraphs()
.map(CT_P::text)
.collect::<String>(),
"changed"
);
let textbox = format!(
r#"<w:txbxContent xmlns:w="{WORD_NAMESPACE}"><w:sdt>{first}{phantom}</w:sdt></w:txbxContent>"#
);
let projection = paired_text_box_projection(textbox.as_bytes(), 0..textbox.len()).unwrap();
assert!(projection.signature.iter().any(|fact| fact == "visible"));
assert!(
!projection
.signature
.iter()
.any(|fact| fact.contains("phantom"))
);
}
#[test]
fn fx211_duplicate_content_empty_start_and_paired_copy_boundaries() {
let raw = "<w:sdtContent><w:p><w:r><w:t>phantom</w:t></w:r></w:p></w:sdtContent>";
let xml = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body><w:sdt><w:sdtContent></w:sdtContent>{raw}</w:sdt></w:body></w:document>"#
);
assert_eq!(
CT_Document::from_xml(xml.as_bytes())
.unwrap()
.body
.paragraphs()
.count(),
0
);
let owners = scan_story_owners(xml.as_bytes(), StoryKind::Body).unwrap();
let items = scan_story_items(xml.as_bytes(), &owners[0]).unwrap();
let root = items
.iter()
.find(|item| item.kind == StoryItemKind::ContentControl)
.unwrap();
let scope = story_namespace_scope_at(xml.as_bytes(), root.scan.start).unwrap();
assert_eq!(
story_item_text_with_scope(xml.as_bytes(), root, &scope, &items).unwrap(),
None
);
assert_eq!(
scan_story_control_paragraphs(xml.as_bytes(), root)
.unwrap()
.len(),
0
);
assert!(xml[root.full.clone()].contains(raw));
let paired = r#"<w:p><w:r><mc:AlternateContent><mc:Choice Requires="wps"><w:drawing><wp:anchor><a:graphic><a:graphicData><wps:wsp><wps:txbx><w:txbxContent><w:p><w:r><w:t>box</w:t></w:r></w:p></w:txbxContent></wps:txbx></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice><mc:Fallback><w:pict><v:shape><v:textbox><w:txbxContent><w:p><w:r><w:t>box</w:t></w:r></w:p></w:txbxContent></v:textbox></v:shape></w:pict></mc:Fallback></mc:AlternateContent></w:r></w:p>"#;
for (first, expected_copies) in [
("<w:sdtContent></w:sdtContent>".to_owned(), 0),
(format!("<w:sdtContent>{paired}</w:sdtContent>"), 2),
] {
let duplicate = format!("<w:sdtContent>{paired}</w:sdtContent>");
let source = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:v="urn:schemas-microsoft-com:vml"><w:body><w:sdt>{first}{duplicate}</w:sdt></w:body></w:document>"#
);
let copies = scoped_alternate_text_box_copies(source.as_bytes()).unwrap();
assert_eq!(copies.len(), expected_copies);
assert!(
copies
.iter()
.all(|copy| copy.range.end < source.find(&duplicate).unwrap() + first.len())
);
assert!(source.contains(&duplicate));
}
}
#[test]
fn fx211_qualified_inventory_reuse_requires_exact_context_offset_and_scope() {
let nested =
r#"<w:sdt><w:sdtContent><w:p><w:r><w:t>tail</w:t></w:r></w:p></w:sdtContent></w:sdt>"#;
let xml = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body><w:sdt><w:sdtContent><w:p><w:r><w:t>visible</w:t></w:r></w:p>{nested}</w:sdtContent></w:sdt></w:body></w:document>"#
);
let owners = scan_story_owners(xml.as_bytes(), StoryKind::Body).unwrap();
let items = scan_story_items(xml.as_bytes(), &owners[0]).unwrap();
let root = items
.iter()
.find(|item| item.kind == StoryItemKind::ContentControl)
.unwrap();
let scope = story_namespace_scope_at(xml.as_bytes(), root.scan.start).unwrap();
let original = xml.clone();
FX211_PROFILE.set([0; 12]);
let expected = story_item_text_with_scope(xml.as_bytes(), root, &scope, &[]).unwrap();
assert_eq!(expected.as_deref(), Some("visibletail"));
assert_eq!(FX211_PROFILE.get()[8], 1);
FX211_PROFILE.set([0; 12]);
assert_eq!(
story_item_text_with_scope(xml.as_bytes(), root, &scope, &items).unwrap(),
expected
);
assert_eq!(FX211_PROFILE.get()[8], 0);
for mismatch in ["context", "offset", "end"] {
let mut wrong = items.clone();
let child = wrong
.iter_mut()
.find(|item| item.full.start == xml.find(nested).unwrap())
.unwrap();
match mismatch {
"context" => child.sdt_context = Some(StorySdtContext::Inline),
"offset" => {
child.full.start += 1;
child.scan.start += 1;
}
"end" => {
child.full.end = root.scan.end + 1;
child.scan.end = child.full.end;
}
_ => unreachable!(),
}
FX211_PROFILE.set([0; 12]);
assert_eq!(
story_item_text_with_scope(xml.as_bytes(), root, &scope, &wrong).unwrap(),
expected
);
assert_eq!(FX211_PROFILE.get()[8], 1, "{mismatch}");
}
assert_eq!(xml, original);
for aliased in [
xml.replace("w:", "q:").replace("xmlns:w=", "xmlns:q="),
xml.replace("w:", "").replace("xmlns:w=", "xmlns="),
] {
let owners = scan_story_owners(aliased.as_bytes(), StoryKind::Body).unwrap();
let items = scan_story_items(aliased.as_bytes(), &owners[0]).unwrap();
let root = items
.iter()
.find(|item| item.kind == StoryItemKind::ContentControl)
.unwrap();
let scope = story_namespace_scope_at(aliased.as_bytes(), root.scan.start).unwrap();
assert_eq!(
story_item_text_with_scope(aliased.as_bytes(), root, &scope, &items).unwrap(),
expected
);
}
}
#[test]
fn fx211_qualified_parent_does_not_promote_raw_or_shadowed_children() {
for (raw, expected_text) in [
(
r#"<w:r><w:sdt><w:sdtContent><w:r><w:t>phantom</w:t></w:r></w:sdtContent></w:sdt></w:r>"#,
"visibletail",
),
(
r#"<w:sdt><w:sdtContent><w:p><w:r><w:t>phantom</w:t></w:r></w:p></w:sdtContent><w:sdtContent/></w:sdt>"#,
"visiblephantomtail",
),
(
r#"<w:sdt><w:sdtPr><w:id w:val="not-an-integer"/></w:sdtPr><w:sdtContent><w:p><w:r><w:t>phantom</w:t></w:r></w:p></w:sdtContent></w:sdt>"#,
"visibletail",
),
(
r#"<q:sdt xmlns:q="urn:foreign"><w:sdt><w:sdtContent><w:p><w:r><w:t>phantom</w:t></w:r></w:p></w:sdtContent></w:sdt></q:sdt>"#,
"visibletail",
),
] {
let xml = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body><w:sdt><w:sdtContent><w:p><w:r><w:t>visible</w:t></w:r></w:p>{raw}<w:p><w:r><w:t>tail</w:t></w:r></w:p></w:sdtContent></w:sdt></w:body></w:document>"#
);
let original = xml.clone();
let owners = scan_story_owners(xml.as_bytes(), StoryKind::Body).unwrap();
let items = scan_story_items(xml.as_bytes(), &owners[0]).unwrap();
let root = items
.iter()
.find(|item| item.kind == StoryItemKind::ContentControl)
.unwrap();
let scope = story_namespace_scope_at(xml.as_bytes(), root.scan.start).unwrap();
let expected = story_item_text_with_scope(xml.as_bytes(), root, &scope, &[]).unwrap();
assert_eq!(
expected.as_deref(),
Some(expected_text),
"legacy admission: {raw}"
);
assert_eq!(
story_item_text_with_scope(xml.as_bytes(), root, &scope, &items).unwrap(),
expected
);
assert_eq!(xml, original);
assert!(xml[root.full.clone()].contains(raw));
}
let malformed = format!(
r#"<w:sdt xmlns:w="{WORD_NAMESPACE}"><w:sdtContent><w:p></w:sdtContent></w:sdt>"#
);
let item = StoryItemSpan {
kind: StoryItemKind::ContentControl,
full: 0..malformed.len(),
scan: 0..malformed.len(),
direct_owner_child: true,
complex_field: false,
complex_ancestors: Vec::new(),
sdt_context: Some(StorySdtContext::Block),
};
assert!(story_item_text(malformed.as_bytes(), &item, &[], 0..malformed.len(), 0).is_err());
}
#[test]
#[ignore = "manual diagnostic requires owned FX211_TABLES and FX211_CONTROLS runner"]
fn fx211_small_table_control_transaction_profile() {
let tables: usize = std::env::var("FX211_TABLES").unwrap().parse().unwrap();
let controls: usize = std::env::var("FX211_CONTROLS").unwrap().parse().unwrap();
assert!([1, 2, 4].contains(&tables));
assert!([8, 16, 32].contains(&controls));
let mut document = Document::new();
document.add_paragraph("placeholder");
let table_start = r#"<w:tbl><w:tblGrid><w:gridCol w:w="1440"/></w:tblGrid><w:tr><w:tc>"#;
let table_end = "</w:tc></w:tr></w:tbl>";
let source = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body>{}{}<w:p><w:r><w:t>leaf</w:t></w:r></w:p>{}{}<w:sectPr/></w:body></w:document>"#,
table_start.repeat(tables),
"<w:sdt><w:sdtContent>".repeat(controls),
"</w:sdtContent></w:sdt>".repeat(controls),
table_end.repeat(tables)
);
document
.package
.set_part(&document.doc_part_name.clone(), source.into_bytes());
let mut started = std::time::Instant::now();
FX211_PROFILE.set([0; 12]);
let mut document = Document::from_package(document.package).unwrap();
eprintln!(
"FX211_PROFILE load {} {:?}",
started.elapsed().as_micros(),
FX211_PROFILE.get()
);
started = std::time::Instant::now();
FX211_PROFILE.set([0; 12]);
let location = document
.story_item_snapshots()
.unwrap()
.into_iter()
.find(|snapshot| snapshot.location().item_kind == StoryItemKind::Table)
.unwrap()
.location()
.clone();
eprintln!(
"FX211_PROFILE snapshots1 {} {:?}",
started.elapsed().as_micros(),
FX211_PROFILE.get()
);
started = std::time::Instant::now();
FX211_PROFILE.set([0; 12]);
let snapshot = document
.story_item_snapshots()
.unwrap()
.into_iter()
.find(|snapshot| snapshot.location() == &location)
.unwrap();
eprintln!(
"FX211_PROFILE snapshots2 {} {:?}",
started.elapsed().as_micros(),
FX211_PROFILE.get()
);
let raw = std::str::from_utf8(snapshot.xml()).unwrap();
assert!(raw.starts_with("<w:tbl>"));
let xml = raw
.replacen(
"<w:tbl>",
&format!(r#"<w:tbl xmlns:w="{WORD_NAMESPACE}">"#),
1,
)
.replace(">leaf<", ">changed<");
assert_eq!(
xml.split_once('>').unwrap().1,
raw.split_once('>')
.unwrap()
.1
.replace(">leaf<", ">changed<")
);
started = std::time::Instant::now();
FX211_PROFILE.set([0; 12]);
document.replace_table_xml(&location, &xml).unwrap();
eprintln!(
"FX211_PROFILE replace {} {:?}",
started.elapsed().as_micros(),
FX211_PROFILE.get()
);
started = std::time::Instant::now();
FX211_PROFILE.set([0; 12]);
let bytes = document.to_bytes().unwrap();
eprintln!(
"FX211_PROFILE save {} {:?}",
started.elapsed().as_micros(),
FX211_PROFILE.get()
);
started = std::time::Instant::now();
FX211_PROFILE.set([0; 12]);
let reopened = Document::from_bytes(&bytes).unwrap();
eprintln!(
"FX211_PROFILE reopen {} {:?}",
started.elapsed().as_micros(),
FX211_PROFILE.get()
);
let output = reopened.package.get_part(&reopened.doc_part_name).unwrap();
let xml = std::str::from_utf8(output).unwrap();
let BodyContent::Table(table) = &reopened.document.body.content[0] else {
panic!("typed root table")
};
let mut table = table;
for depth in 1..tables {
assert_eq!(table.rows.len(), 1);
assert_eq!(table.rows[0].cells.len(), 1);
let CellContent::Table(next) = &table.rows[0].cells[0].content[0] else {
panic!("typed nested table at {depth}")
};
table = next;
}
let CellContent::ContentControl(control) = &table.rows[0].cells[0].content[0] else {
panic!("typed leaf control")
};
let mut control = control;
for depth in 1..controls {
let SdtContent::ContentControl(next) = &control.content[0] else {
panic!("typed nested control at {depth}")
};
control = next;
}
let SdtContent::Paragraph(paragraph) = &control.content[0] else {
panic!("typed terminal paragraph")
};
assert_eq!(paragraph.text(), "changed");
assert!(xml.contains(">changed<"));
drop(reopened);
drop(document);
}
#[test]
#[ignore = "manual diagnostic requires owned bounded FX211_TABLES and FX211_CONTROLS runner"]
fn fx211_controlled_larger_table_control_transaction_profile() {
let tables: usize = std::env::var("FX211_TABLES").unwrap().parse().unwrap();
let controls: usize = std::env::var("FX211_CONTROLS").unwrap().parse().unwrap();
assert!([(8, 64), (16, 128), (32, 256)].contains(&(tables, controls)));
let mut document = Document::new();
document.add_paragraph("placeholder");
let table_start = r#"<w:tbl><w:tblGrid><w:gridCol w:w="1440"/></w:tblGrid><w:tr><w:tc>"#;
let table_end = "</w:tc></w:tr></w:tbl>";
let source = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body>{}{}<w:p><w:r><w:t>leaf</w:t></w:r></w:p>{}{}<w:sectPr/></w:body></w:document>"#,
table_start.repeat(tables),
"<w:sdt><w:sdtContent>".repeat(controls),
"</w:sdtContent></w:sdt>".repeat(controls),
table_end.repeat(tables)
);
eprintln!("FX211_INPUT {source}");
document
.package
.set_part(&document.doc_part_name.clone(), source.into_bytes());
let mut started = std::time::Instant::now();
FX211_PROFILE.set([0; 12]);
let mut document = Document::from_package(document.package).unwrap();
eprintln!(
"FX211_PROFILE load {} {:?}",
started.elapsed().as_micros(),
FX211_PROFILE.get()
);
started = std::time::Instant::now();
FX211_PROFILE.set([0; 12]);
let location = document
.story_item_snapshots()
.unwrap()
.into_iter()
.find(|snapshot| snapshot.location().item_kind == StoryItemKind::Table)
.unwrap()
.location()
.clone();
eprintln!(
"FX211_PROFILE snapshots1 {} {:?}",
started.elapsed().as_micros(),
FX211_PROFILE.get()
);
started = std::time::Instant::now();
FX211_PROFILE.set([0; 12]);
let snapshot = document
.story_item_snapshots()
.unwrap()
.into_iter()
.find(|snapshot| snapshot.location() == &location)
.unwrap();
eprintln!(
"FX211_PROFILE snapshots2 {} {:?}",
started.elapsed().as_micros(),
FX211_PROFILE.get()
);
let raw = std::str::from_utf8(snapshot.xml()).unwrap();
assert!(raw.starts_with("<w:tbl>"));
let xml = raw
.replacen(
"<w:tbl>",
&format!(r#"<w:tbl xmlns:w="{WORD_NAMESPACE}">"#),
1,
)
.replace(">leaf<", ">changed<");
assert_eq!(
xml.split_once('>').unwrap().1,
raw.split_once('>')
.unwrap()
.1
.replace(">leaf<", ">changed<")
);
started = std::time::Instant::now();
FX211_PROFILE.set([0; 12]);
document.replace_table_xml(&location, &xml).unwrap();
eprintln!(
"FX211_PROFILE replace {} {:?}",
started.elapsed().as_micros(),
FX211_PROFILE.get()
);
started = std::time::Instant::now();
FX211_PROFILE.set([0; 12]);
let bytes = document.to_bytes().unwrap();
eprintln!(
"FX211_PROFILE save {} {:?}",
started.elapsed().as_micros(),
FX211_PROFILE.get()
);
started = std::time::Instant::now();
FX211_PROFILE.set([0; 12]);
let reopened = Document::from_bytes(&bytes).unwrap();
eprintln!(
"FX211_PROFILE reopen {} {:?}",
started.elapsed().as_micros(),
FX211_PROFILE.get()
);
let output = reopened.package.get_part(&reopened.doc_part_name).unwrap();
let xml = std::str::from_utf8(output).unwrap();
eprintln!("FX211_OUTPUT {xml}");
let BodyContent::Table(table) = &reopened.document.body.content[0] else {
panic!("typed root table")
};
let mut table = table;
for depth in 1..tables {
assert_eq!(table.rows.len(), 1);
assert_eq!(table.rows[0].cells.len(), 1);
let CellContent::Table(next) = &table.rows[0].cells[0].content[0] else {
panic!("typed nested table at {depth}")
};
table = next;
}
let CellContent::ContentControl(control) = &table.rows[0].cells[0].content[0] else {
panic!("typed leaf control")
};
let mut control = control;
for depth in 1..controls {
let SdtContent::ContentControl(next) = &control.content[0] else {
panic!("typed nested control at {depth}")
};
control = next;
}
let SdtContent::Paragraph(paragraph) = &control.content[0] else {
panic!("typed terminal paragraph")
};
assert_eq!(paragraph.text(), "changed");
assert!(xml.contains(">changed<"));
drop(reopened);
drop(document);
}
#[test]
#[ignore = "manual diagnostic requires owned FX211_CLONE_MEMBER runner"]
fn fx211_standalone_loaded_maximal_clone_diagnostic() {
let member = std::env::var("FX211_CLONE_MEMBER").unwrap();
assert!(["document", "typed", "equal", "serialize", "save"].contains(&member.as_str()));
let table_start = r#"<w:tbl><w:tblGrid><w:gridCol w:w="1440"/></w:tblGrid><w:tr><w:tc>"#;
let source = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body>{}{}<w:p><w:r><w:t>leaf</w:t></w:r></w:p>{}{}<w:sectPr/></w:body></w:document>"#,
table_start.repeat(32),
"<w:sdt><w:sdtContent>".repeat(256),
"</w:sdtContent></w:sdt>".repeat(256),
"</w:tc></w:tr></w:tbl>".repeat(32)
);
eprintln!("FX211_CLONE_INPUT {source}");
let mut document = Document::new();
document.add_paragraph("placeholder");
document
.package
.set_part(&document.doc_part_name.clone(), source.as_bytes().to_vec());
let mut document = Document::from_package(document.package).unwrap();
eprintln!("FX211_CLONE loaded");
let check = |model: &CT_Document| {
assert_eq!(model.body.content.len(), 1);
let BodyContent::Table(table) = &model.body.content[0] else {
panic!("typed root table")
};
let mut table = table;
for depth in 1..32 {
assert_eq!(table.rows.len(), 1);
assert_eq!(table.rows[0].cells.len(), 1);
assert_eq!(table.grid.as_ref().unwrap().columns.len(), 1);
let CellContent::Table(next) = &table.rows[0].cells[0].content[0] else {
panic!("typed table {depth}")
};
table = next;
}
let CellContent::ContentControl(control) = &table.rows[0].cells[0].content[0] else {
panic!("typed control root")
};
let mut control = control;
for depth in 1..256 {
let SdtContent::ContentControl(next) = &control.content[0] else {
panic!("typed control {depth}")
};
control = next;
}
let SdtContent::Paragraph(paragraph) = &control.content[0] else {
panic!("typed leaf")
};
assert_eq!(paragraph.text(), "leaf");
};
check(&document.document);
match member.as_str() {
"document" => {
eprintln!("FX211_CLONE Document-before");
let candidate = document.clone_for_staging();
eprintln!("FX211_CLONE Document-after");
check(&candidate.document);
assert_eq!(
candidate
.package
.get_part(&candidate.doc_part_name)
.unwrap(),
source.as_bytes()
);
eprintln!("FX211_CLONE Document-drop-before");
drop(candidate);
eprintln!("FX211_CLONE Document-drop-after");
}
"typed" => {
eprintln!("FX211_CLONE CT_Document-before");
let candidate = document.document.clone();
eprintln!("FX211_CLONE CT_Document-after");
check(&candidate);
eprintln!("FX211_CLONE CT_Document-drop-before");
drop(candidate);
eprintln!("FX211_CLONE CT_Document-drop-after");
}
"relationships" => {
eprintln!("FX211_CLONE relationships-before");
let mut relationships = Vec::new();
collect_relationship_ids(&document.document.body.content, &mut relationships);
eprintln!("FX211_CLONE relationships-after");
assert!(relationships.is_empty());
check(&document.document);
}
"equal" => {
eprintln!("FX211_CLONE equal-parse-before");
let parsed = CT_Document::from_xml(source.as_bytes()).unwrap();
eprintln!("FX211_CLONE equal-parse-after");
eprintln!("FX211_CLONE equal-before");
assert!(parsed == document.document);
eprintln!("FX211_CLONE equal-after");
drop(parsed);
eprintln!("FX211_CLONE equal-drop-after");
}
"serialize" => {
eprintln!("FX211_CLONE serialize-before");
let xml = document.document.to_xml().unwrap();
eprintln!("FX211_CLONE serialize-after");
let reopened = CT_Document::from_xml(&xml).unwrap();
check(&reopened);
drop(reopened);
eprintln!("FX211_CLONE serialize-reopen-drop-after");
}
"save" => {
eprintln!("FX211_CLONE save-before");
let bytes = document.to_bytes().unwrap();
eprintln!("FX211_CLONE save-after");
let reopened = Document::from_bytes(&bytes).unwrap();
check(&reopened.document);
drop(reopened);
eprintln!("FX211_CLONE save-reopen-drop-after");
}
_ => unreachable!(),
}
assert_eq!(
document.package.get_part(&document.doc_part_name).unwrap(),
source.as_bytes()
);
eprintln!("FX211_CLONE original-drop-before");
drop(document);
eprintln!("FX211_CLONE original-drop-after");
}
#[test]
fn fx211_imported_local_picture_snapshot_remains_namespace_complete() {
let mut document = Document::new();
let png = super::watermark_tests::PNG;
document.add_picture(png, "owned.png", Length::pt(12.0), Length::pt(12.0));
let relationship = document
.package
.get_part_rels(&document.doc_part_name)
.unwrap()
.items
.iter()
.find(|relationship| relationship.rel_type == rel_types::IMAGE)
.unwrap()
.clone();
let target = OpcPackage::resolve_rel_target(&document.doc_part_name, &relationship.target);
assert_eq!(document.package.get_part(&target).unwrap(), png);
let opaque = r#"<x:payload xmlns:x="urn:producer" x:keep='yes'/>"#;
let source = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body><w:p><w:r><w:t>old</w:t></w:r><w:r><w:drawing xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:pic="http://schemas.openxmlformats.org/drawingml/2006/picture" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><wp:inline><wp:extent cx="152400" cy="152400"/><wp:docPr id="1" name="owned.png"/>{opaque}<a:graphic><a:graphicData uri="http://schemas.openxmlformats.org/drawingml/2006/picture"><pic:pic><pic:nvPicPr><pic:cNvPr id="1" name="owned.png"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="{}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="152400" cy="152400"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r></w:p><w:sectPr/></w:body></w:document>"#,
relationship.id
);
document
.package
.set_part(&document.doc_part_name.clone(), source.into_bytes());
let mut document = Document::from_package(document.package).unwrap();
let before = document.to_bytes().unwrap();
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let snapshot = document.story_range_paragraph_snapshot(&location).unwrap();
CT_P::from_xml_fragment_checked(snapshot.xml()).unwrap();
let xml = std::str::from_utf8(snapshot.xml())
.unwrap()
.replace(">old<", ">changed<");
assert_eq!(document.to_bytes().unwrap(), before, "read is nonmutating");
document.replace_paragraph_xml(&location, &xml).unwrap();
for _ in 0..2 {
document = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
assert_eq!(document.paragraph(0).unwrap().text(), "changed");
assert_eq!(document.package.get_part(&target).unwrap(), png);
let raw = document.package.get_part(&document.doc_part_name).unwrap();
assert!(
raw.windows(opaque.len())
.any(|window| window == opaque.as_bytes())
);
CT_Document::from_xml(raw).unwrap();
assert_eq!(
document
.package
.get_part_rels(&document.doc_part_name)
.unwrap()
.items
.iter()
.filter(|item| item.id == relationship.id && item.target == relationship.target)
.count(),
1
);
}
}
#[test]
fn fx211_drawing_identity_preserves_existing_repetitions_and_refuses_new_collision() {
let drawing = |id: u32| {
format!(
r#"<w:r><w:drawing xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><wp:inline><wp:extent cx="914400" cy="914400"/><wp:docPr id="{id}" name="owned"/><a:graphic><a:graphicData uri="urn:producer"/></a:graphic></wp:inline></w:drawing></w:r>"#
)
};
let mut document = Document::new();
document.add_paragraph("placeholder");
let source = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><w:body><w:p><w:r><w:t>first</w:t></w:r>{}</w:p><w:p><w:r><w:t>last</w:t></w:r>{}</w:p><w:sectPr/></w:body></w:document>"#,
drawing(7),
drawing(7)
);
document
.package
.set_part(&document.doc_part_name.clone(), source.into_bytes());
let mut document = Document::from_package(document.package).unwrap();
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let xml = String::from_utf8(
document
.story_range_paragraph_snapshot(&location)
.unwrap()
.xml()
.to_vec(),
)
.unwrap()
.replace(">first<", ">changed<");
document.replace_paragraph_xml(&location, &xml).unwrap();
let mut document = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
assert_eq!(document.paragraph(0).unwrap().text(), "changed");
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let before = document.to_bytes().unwrap();
let xml = String::from_utf8(
document
.story_range_paragraph_snapshot(&location)
.unwrap()
.xml()
.to_vec(),
)
.unwrap()
.replace("</w:p>", &format!("{}</w:p>", drawing(7)));
assert!(
document
.replace_paragraph_xml(&location, &xml)
.unwrap_err()
.to_string()
.contains("duplicate drawing id 7")
);
assert_eq!(document.to_bytes().unwrap(), before);
assert!(document.story_range_paragraph_snapshot(&location).is_ok());
}
#[test]
fn fx211_nested_section_profiles_are_preserved_or_refused_atomically() {
let mut document = Document::new();
document.add_paragraph("placeholder");
let opaque = r#"<x:payload xmlns:x="urn:producer"><w:sectPr w:keep="opaque"/></x:payload>"#;
let source = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body><w:p><w:pPr><w:sectPr><w:pgSz w:w="0" w:h="1440"/></w:sectPr></w:pPr><w:r><w:t>old</w:t></w:r>{opaque}</w:p><w:tbl><w:tblGrid><w:gridCol w:w="1440"/></w:tblGrid><w:tr><w:tc><w:p/></w:tc></w:tr></w:tbl><w:sectPr/></w:body></w:document>"#
);
document
.package
.set_part(&document.doc_part_name.clone(), source.into_bytes());
let mut document = Document::from_package(document.package).unwrap();
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let exact = document
.story_range_paragraph_snapshot(&location)
.unwrap()
.xml()
.to_vec();
let before = document.to_bytes().unwrap();
document
.replace_paragraph_xml(&location, std::str::from_utf8(&exact).unwrap())
.unwrap();
assert_eq!(document.to_bytes().unwrap(), before);
let changed_text = String::from_utf8(exact.clone())
.unwrap()
.replace(">old<", ">changed<");
document
.replace_paragraph_xml(&location, &changed_text)
.unwrap();
let mut document = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
assert_eq!(document.paragraph(0).unwrap().text(), "changed");
assert!(
document.story_item_snapshots().unwrap()[0]
.xml()
.windows(opaque.len())
.any(|window| window == opaque.as_bytes())
);
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let before = document.to_bytes().unwrap();
let changed_section = String::from_utf8(
document
.story_range_paragraph_snapshot(&location)
.unwrap()
.xml()
.to_vec(),
)
.unwrap()
.replace(r#"w:w="0""#, r#"w:w="1440""#);
assert!(
document
.replace_paragraph_xml(&location, &changed_section)
.unwrap_err()
.to_string()
.contains("checked section owner")
);
assert_eq!(document.to_bytes().unwrap(), before);
let table = document
.story_item_snapshots()
.unwrap()
.into_iter()
.find(|item| item.location().item_kind == StoryItemKind::Table)
.unwrap()
.location()
.clone();
let introduced = format!(
r#"<w:tc xmlns:w="{WORD_NAMESPACE}"><w:p><w:pPr><w:sectPr><w:pgSz w:w="1440" w:h="2880"/></w:sectPr></w:pPr><w:r><w:t>new</w:t></w:r></w:p></w:tc>"#
);
assert!(
document
.replace_cell_xml(&table, 0, 0, &introduced)
.unwrap_err()
.to_string()
.contains("checked section owner")
);
assert_eq!(document.to_bytes().unwrap(), before);
let valid = format!(
r#"<w:sectPr xmlns:w="{WORD_NAMESPACE}"><w:pgSz w:w="1440" w:h="2880"/></w:sectPr>"#
);
document.replace_section_properties_xml(1, &valid).unwrap();
assert_eq!(
document.section(1).unwrap().properties().page_width,
Some(Twips(1440))
);
assert!(
!document.validate_sections().is_valid(),
"unrelated preserved section stays unchanged"
);
}
#[test]
fn fx211_identity_profiles_preserve_cross_owner_bookmarks_and_refuse_collisions() {
let mut document = Document::new();
document.add_paragraph("placeholder");
let source = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body><w:p><w:bookmarkStart w:id="7" w:name="cross"/><w:r><w:t>first</w:t></w:r></w:p><w:p><w:r><w:t>last</w:t></w:r><w:bookmarkEnd w:id="7"/></w:p><w:sectPr/></w:body></w:document>"#
);
document
.package
.set_part(&document.doc_part_name.clone(), source.into_bytes());
let mut document = Document::from_package(document.package).unwrap();
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let xml = format!(
r#"<w:p xmlns:w="{WORD_NAMESPACE}"><w:bookmarkStart w:id="7" w:name="cross"/><w:bookmarkStart w:id="8" w:name="local"/><w:r><w:t>changed</w:t></w:r><w:bookmarkEnd w:id="8"/></w:p>"#
);
document.replace_paragraph_xml(&location, &xml).unwrap();
let mut document = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
assert_eq!(document.bookmarks().len(), 2);
let location = document.story_item_snapshots().unwrap()[1]
.location()
.clone();
let before = document.to_bytes().unwrap();
let duplicate = format!(
r#"<w:p xmlns:w="{WORD_NAMESPACE}"><w:bookmarkStart w:id="8" w:name="duplicate"/><w:r><w:t>last</w:t></w:r><w:bookmarkEnd w:id="8"/><w:bookmarkEnd w:id="7"/></w:p>"#
);
assert!(
document
.replace_paragraph_xml(&location, &duplicate)
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), before);
let bad_order = format!(
r#"<w:p xmlns:w="{WORD_NAMESPACE}"><w:bookmarkEnd w:id="9"/><w:r><w:t>last</w:t></w:r><w:bookmarkStart w:id="9" w:name="reversed"/><w:bookmarkEnd w:id="7"/></w:p>"#
);
assert!(
document
.replace_paragraph_xml(&location, &bad_order)
.unwrap_err()
.to_string()
.contains("precedes")
);
assert_eq!(document.to_bytes().unwrap(), before);
}
#[test]
fn fx211_comment_identity_uses_current_owned_target_and_other_physical_occurrences() {
let mut document = Document::new();
document.add_paragraph("first");
document.add_paragraph("other");
let id = document
.add_comment(
crate::comments::RunRange {
start: crate::comments::RunPosition {
body_index: 0,
run_index: 0,
},
end: crate::comments::RunPosition {
body_index: 0,
run_index: 1,
},
},
"author",
None,
"owned definition",
)
.unwrap();
let bytes = document.to_bytes().unwrap();
let mut package = OpcPackage::from_reader(Cursor::new(bytes)).unwrap();
let source = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body><w:p><w:r><w:t>first</w:t></w:r></w:p><w:p><w:r><w:t>other</w:t></w:r></w:p><w:sectPr/></w:body></w:document>"#
);
package.set_part(&document.doc_part_name, source.into_bytes());
let mut document = Document::from_package(package).unwrap();
document.add_paragraph("dirty unrelated owner");
// Capture the current owner after a legitimate unrelated dirty model change.
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let xml = format!(
r#"<w:p xmlns:w="{WORD_NAMESPACE}"><w:commentRangeStart w:id="{id}"/><w:r><w:t>changed</w:t></w:r><w:commentRangeEnd w:id="{id}"/><w:r><w:commentReference w:id="{id}"/></w:r></w:p>"#
);
document.replace_paragraph_xml(&location, &xml).unwrap();
let mut reopened = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
reopened.validate_comment_ownership().unwrap();
assert_eq!(reopened.comments().len(), 1);
assert_eq!(
reopened.paragraph(2).unwrap().text(),
"dirty unrelated owner"
);
let location = reopened.story_item_snapshots().unwrap()[1]
.location()
.clone();
let before = reopened.to_bytes().unwrap();
assert!(reopened.replace_paragraph_xml(&location, &xml).is_err());
assert_eq!(reopened.to_bytes().unwrap(), before);
}
#[test]
fn fx211_identity_profiles_refuse_new_dangling_markers_atomically() {
let mut failures = Vec::new();
for content in [
r#"<w:bookmarkStart w:id="7" w:name="new"/><w:r><w:t>value</w:t></w:r>"#,
r#"<w:r><w:t>value</w:t></w:r><w:bookmarkEnd w:id="7"/>"#,
r#"<w:r><w:commentReference w:id="7"/></w:r>"#,
r#"<w:commentRangeStart w:id="7"/><w:r><w:t>value</w:t></w:r><w:commentRangeEnd w:id="7"/>"#,
] {
let mut document = Document::new();
document.add_paragraph("retained");
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let before = document.to_bytes().unwrap();
let xml = format!(r#"<w:p xmlns:w="{WORD_NAMESPACE}">{content}</w:p>"#);
if document.replace_paragraph_xml(&location, &xml).is_ok() {
failures.push(format!("introduced dangling identity accepted: {content}"));
} else {
assert_eq!(document.to_bytes().unwrap(), before);
assert_eq!(
document.story_item_snapshots().unwrap()[0].location(),
&location
);
}
}
assert!(failures.is_empty(), "{failures:?}");
}
#[test]
fn fx211_note_and_field_profiles_reuse_existing_qualified_providers() {
let mut document = Document::new();
document.add_paragraph("before");
let id = document.add_footnote("note owner");
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let xml = format!(
r#"<w:p xmlns:w="{WORD_NAMESPACE}"><w:r><w:footnoteReference w:id="{id}"/></w:r><w:fldSimple w:instr="PAGE"><w:r><w:t>7</w:t></w:r></w:fldSimple></w:p>"#
);
document.replace_paragraph_xml(&location, &xml).unwrap();
let bytes = document.to_bytes().unwrap();
let mut reopened = Document::from_bytes(&bytes).unwrap();
assert!(
reopened
.fragment_note_dependency(StoryKind::Footnote, id)
.unwrap()
.1
.windows(b"note owner".len())
.any(|slice| slice == b"note owner")
);
assert_eq!(reopened.paragraph(0).unwrap().text(), "7");
let twice = Document::from_bytes(&reopened.to_bytes().unwrap()).unwrap();
assert_eq!(twice.paragraph(0).unwrap().text(), "7");
}
#[test]
fn fx211_note_field_and_section_profiles_refuse_atomically() {
let mut failures = Vec::new();
for content in [
r#"<w:r><w:footnoteReference w:id="99"/></w:r>"#,
r#"<w:r><w:endnoteReference w:id="99"/></w:r>"#,
r#"<w:fldSimple w:instr="REF "unclosed"><w:r><w:t>cache</w:t></w:r></w:fldSimple>"#,
] {
let mut document = Document::new();
document.add_paragraph("retained");
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let before = document.to_bytes().unwrap();
let xml = format!(r#"<w:p xmlns:w="{WORD_NAMESPACE}">{content}</w:p>"#);
if document.replace_paragraph_xml(&location, &xml).is_ok() {
failures.push(format!("invalid known profile accepted: {content}"));
} else {
assert_eq!(document.to_bytes().unwrap(), before);
assert_eq!(
document.story_item_snapshots().unwrap()[0].location(),
&location
);
}
}
let mut document = Document::new();
document.add_paragraph("retained");
let before = document.to_bytes().unwrap();
let xml = format!(
r#"<w:sectPr xmlns:w="{WORD_NAMESPACE}"><w:pgSz w:w="0" w:h="1440"/></w:sectPr>"#
);
if document.replace_section_properties_xml(0, &xml).is_ok() {
failures.push("invalid known profile accepted: nonpositive section geometry".into());
} else {
assert_eq!(document.to_bytes().unwrap(), before);
}
assert!(failures.is_empty(), "{failures:?}");
}
#[test]
fn fx211_xml_relationship_kind_and_mode_refuse_before_publication() {
for (relationship_type, external) in
[(rel_types::IMAGE, true), (rel_types::HYPERLINK, false)]
{
let mut document = Document::new();
document.add_paragraph("retained");
let part_name = document.doc_part_name.clone();
let mut relationships = document.package.get_part_rels(&part_name).unwrap().clone();
let id = if external {
relationships.add_external(relationship_type, "https://example.com/target")
} else {
relationships.add(relationship_type, "https://example.com/target")
};
document.package.set_part_rels(&part_name, relationships);
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let before = document.to_bytes().unwrap();
let xml = format!(
r#"<w:p xmlns:w="{WORD_NAMESPACE}" xmlns:r="{}"><w:hyperlink r:id="{id}"><w:r><w:t>changed</w:t></w:r></w:hyperlink></w:p>"#,
rdocx_oxml::namespace::R_NS
);
let result = document.replace_paragraph_xml(&location, &xml);
assert!(
result.is_err(),
"wrong typed hyperlink relationship accepted"
);
assert_eq!(document.to_bytes().unwrap(), before);
assert_eq!(document.paragraph(0).unwrap().text(), "retained");
assert_eq!(
document.story_item_snapshots().unwrap()[0].location(),
&location
);
}
}
#[test]
fn fx211_cell_paragraph_location_uses_checked_range_axis() {
let mut document = Document::new();
document.add_table(1, 1);
let table = document
.story_item_snapshots()
.unwrap()
.into_iter()
.find(|item| item.location().item_kind() == StoryItemKind::Table)
.unwrap()
.location()
.clone();
let xml = format!(
r#"<w:tbl xmlns:w="{WORD_NAMESPACE}"><w:tblGrid><w:gridCol w:w="1440"/></w:tblGrid><w:tr><w:tc><w:p><w:fldSimple w:instr="PAGE"><w:r><w:t>7</w:t></w:r></w:fldSimple></w:p><w:p><w:r><w:t>middle</w:t></w:r></w:p><w:p><w:r><w:t>last</w:t></w:r></w:p></w:tc></w:tr></w:tbl>"#
);
document.replace_table_xml(&table, &xml).unwrap();
let modeled = document
.table(0)
.unwrap()
.cell(0, 0)
.unwrap()
.paragraphs()
.map(|paragraph| paragraph.text())
.collect::<Vec<_>>();
assert_eq!(modeled, ["7", "middle", "last"]);
let mut failures = Vec::new();
for (ordinal, expected) in modeled.iter().enumerate() {
match document.cell_paragraph_story_location(0, 0, 0, ordinal) {
Ok(location) => {
let actual = document.story_range_paragraph_snapshot(&location).unwrap();
if actual.text() != Some(expected.as_str()) {
failures.push(format!(
"paragraph {ordinal}: selected {:?}, expected {expected}",
actual.text()
));
}
}
Err(error) => failures.push(format!("paragraph {ordinal}: {error}")),
}
}
assert!(
failures.is_empty(),
"wrong cell paragraph physical owner: {failures:?}"
);
}
#[test]
fn fx211_cell_paragraph_range_preserves_nested_control_and_opaque_scope() {
let mut document = Document::new();
document.add_table(1, 1);
let table = document
.story_item_snapshots()
.unwrap()
.into_iter()
.find(|item| item.location().item_kind() == StoryItemKind::Table)
.unwrap()
.location()
.clone();
let opaque = r#"<p xmlns="urn:producer" keep="exact"><w:r><w:t>phantom</w:t></w:r></p>"#;
let xml = format!(
r#"<w:tbl xmlns:w="{WORD_NAMESPACE}"><w:tblGrid><w:gridCol w:w="1440"/></w:tblGrid><w:tr><w:tc><w:p><w:fldSimple w:instr="PAGE"><w:r><w:t>7</w:t></w:r></w:fldSimple></w:p>{opaque}<w:sdt><w:sdtContent><w:p><w:r><w:t>outer before</w:t></w:r></w:p><w:sdt><w:sdtContent><w:p><w:r><w:t>nested</w:t></w:r></w:p></w:sdtContent></w:sdt><w:p><w:r><w:t>outer after</w:t></w:r></w:p></w:sdtContent></w:sdt><w:p><w:r><w:t>tail</w:t></w:r></w:p></w:tc></w:tr></w:tbl>"#
);
document.replace_table_xml(&table, &xml).unwrap();
let expected = ["7", "outer before", "nested", "outer after", "tail"];
assert_eq!(
document
.table(0)
.unwrap()
.cell(0, 0)
.unwrap()
.paragraphs()
.map(|paragraph| paragraph.text())
.collect::<Vec<_>>(),
expected
);
for (ordinal, text) in expected.iter().enumerate() {
let location = document
.cell_paragraph_story_location(0, 0, 0, ordinal)
.unwrap();
assert_eq!(
document
.story_range_paragraph_snapshot(&location)
.unwrap()
.text(),
Some(*text)
);
}
let before = document.to_bytes().unwrap();
for (table, row, cell, paragraph) in
[(1, 0, 0, 0), (0, 1, 0, 0), (0, 0, 1, 0), (0, 0, 0, 5)]
{
assert!(
document
.cell_paragraph_story_location(table, row, cell, paragraph)
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), before);
}
let location = document.cell_paragraph_story_location(0, 0, 0, 4).unwrap();
assert!(
document
.replace_paragraph_xml(&location, r#"<x:p xmlns:x="urn:producer"/>"#)
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), before);
assert!(
String::from_utf8(document.document.to_xml().unwrap())
.unwrap()
.contains(opaque)
);
let reopened = Document::from_bytes(&before).unwrap();
for (ordinal, text) in expected.iter().enumerate() {
let location = reopened
.cell_paragraph_story_location(0, 0, 0, ordinal)
.unwrap();
assert_eq!(
reopened
.story_range_paragraph_snapshot(&location)
.unwrap()
.text(),
Some(*text)
);
}
}
#[test]
fn fx252_independent_mc_and_opaque_inputs_follow_physical_ancestry() {
// Independently authored primary Part3/provenance inputs. These are
// code-built package parts, not a new fixture or native Word oracle.
let cases = [
(
r###"opaque-exact-noop"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque></w:p>"###,
0,
r###"<w:p><w:r><w:t>old</w:t></w:r><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque></w:p>"###,
true,
),
(
r###"opaque-valid-scalar"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque></w:p>"###,
0,
r###"<w:p><w:r><w:t>new</w:t></w:r><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque></w:p>"###,
true,
),
(
r###"opaque-copy"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque></w:p>"###,
0,
r###"<w:p><w:r><w:t>new</w:t></w:r><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque></w:p>"###,
false,
),
(
r###"opaque-move"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque></w:p>"###,
0,
r###"<w:p><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque><w:r><w:t>new</w:t></w:r></w:p>"###,
false,
),
(
r###"opaque-swapped-order"###,
r###" "###,
r###"<w:p><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque><y:opaque key="second"/><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p><y:opaque key="second"/><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque><w:r><w:t>new</w:t></w:r></w:p>"###,
false,
),
(
r###"opaque-collapse-duplicates"###,
r###" "###,
r###"<w:p><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque><w:r><w:t>new</w:t></w:r></w:p>"###,
false,
),
(
r###"opaque-changed-ancestry"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque></w:p>"###,
0,
r###"<w:p><w:r><w:t>new</w:t></w:r><w:hyperlink><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque></w:hyperlink></w:p>"###,
false,
),
(
r###"opaque-reinterpreted-uri"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque></w:p>"###,
0,
r###"<w:p xmlns:x="urn:s95:opaque-b"><w:r><w:t>new</w:t></w:r><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque></w:p>"###,
false,
),
(
r###"opaque-other-owner"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque></w:p><w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
1,
r###"<w:p><w:r><w:t>new</w:t></w:r><x:opaque key="retain"><x:nested>verbatim</x:nested></x:opaque></w:p>"###,
false,
),
(
r###"mc-self-grant"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p mc:Ignorable="x"><w:r><w:t>new</w:t></w:r><x:new/></w:p>"###,
true,
),
(
r###"mc-ancestor-grant"###,
r###" mc:Ignorable="x""###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p><w:r><w:t>new</w:t></w:r><x:new/></w:p>"###,
true,
),
(
r###"mc-local-blank-accumulates"###,
r###" mc:Ignorable="x""###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p mc:Ignorable=" "><w:r><w:t>new</w:t></w:r><x:new/></w:p>"###,
true,
),
(
r###"mc-child-adds-uri"###,
r###" mc:Ignorable="x""###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p mc:Ignorable="y"><w:r><w:t>new</w:t></w:r><x:new/><y:new/></w:p>"###,
true,
),
(
r###"mc-foreign-child-self-grant"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p><w:r><w:t>new</w:t></w:r><y:new mc:Ignorable="y"/></w:p>"###,
true,
),
(
r###"mc-sibling-restore"###,
r###" "###,
r###"<w:p mc:Ignorable="x"><w:r><w:t>old</w:t></w:r></w:p><w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
1,
r###"<w:p><w:r><w:t>new</w:t></w:r><x:new/></w:p>"###,
false,
),
(
r###"mc-rebind-not-retarget"###,
r###" mc:Ignorable="a""###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p xmlns:a="urn:s95:opaque-b"><w:r><w:t>new</w:t></w:r><a:new/></w:p>"###,
false,
),
(
r###"mc-rebind-old-uri-alias"###,
r###" mc:Ignorable="a""###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p xmlns:a="urn:s95:opaque-b"><w:r><w:t>new</w:t></w:r><x:new/></w:p>"###,
true,
),
(
r###"mc-rebind-add-current-uri"###,
r###" mc:Ignorable="a""###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p xmlns:a="urn:s95:opaque-b" mc:Ignorable="a"><w:r><w:t>new</w:t></w:r><a:new/><x:new/></w:p>"###,
true,
),
(
r###"mc-default-child-uri"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p mc:Ignorable="x"><w:r><w:t>new</w:t></w:r><new xmlns="urn:s95:opaque-a"/></w:p>"###,
true,
),
(
r###"mc-wrong-unqualified-attribute"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p Ignorable="x"><w:r><w:t>new</w:t></w:r><x:new/></w:p>"###,
false,
),
(
r###"mc-wrong-foreign-attribute"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p y:Ignorable="x"><w:r><w:t>new</w:t></w:r><x:new/></w:p>"###,
false,
),
(
r###"mc-unbound-declaration"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p mc:Ignorable="unbound"><w:r><w:t>new</w:t></w:r><x:new/></w:p>"###,
false,
),
(
r###"mc-child-binding-not-retroactive"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p mc:Ignorable="late"><w:r><w:t>new</w:t></w:r><late:new xmlns:late="urn:s95:opaque-a"/></w:p>"###,
false,
),
(
r###"mc-understood-word-no-waiver"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p mc:Ignorable="w"><w:r><w:t>new</w:t></w:r><w:bogus/></w:p>"###,
false,
),
(
r###"mc-uri-numeric-once"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p xmlns:z="urn:s95:ext" mc:Ignorable="z"><w:r><w:t>new</w:t></w:r><q:new xmlns:q="urn:s95:ext"/></w:p>"###,
true,
),
(
r###"mc-uri-literal-entity-not-twice"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p xmlns:z="urn:s95:ex&#116;" mc:Ignorable="z"><w:r><w:t>new</w:t></w:r><q:new xmlns:q="urn:s95:ext"/></w:p>"###,
false,
),
(
r###"mc-new-alternate-content"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p><w:r><w:t>new</w:t></w:r><mc:AlternateContent><mc:Choice Requires="x"><w:r><w:t>choice</w:t></w:r></mc:Choice><mc:Fallback><w:r><w:t>old</w:t></w:r></mc:Fallback></mc:AlternateContent></w:p>"###,
false,
),
(
r###"mc-new-process-content"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p mc:Ignorable="x" mc:ProcessContent="x:new"><w:r><w:t>new</w:t></w:r><x:new/></w:p>"###,
false,
),
(
r###"mc-new-must-understand"###,
r###" "###,
r###"<w:p><w:r><w:t>old</w:t></w:r></w:p>"###,
0,
r###"<w:p mc:MustUnderstand="x"><w:r><w:t>new</w:t></w:r></w:p>"###,
false,
),
(
r###"mc-imported-complex-exact-noop"###,
r###" "###,
r###"<w:p><mc:AlternateContent><mc:Choice Requires="x"><w:r><w:t>choice</w:t></w:r></mc:Choice><mc:Fallback><w:r><w:t>old</w:t></w:r></mc:Fallback></mc:AlternateContent></w:p>"###,
0,
r###"<w:p><mc:AlternateContent><mc:Choice Requires="x"><w:r><w:t>choice</w:t></w:r></mc:Choice><mc:Fallback><w:r><w:t>old</w:t></w:r></mc:Fallback></mc:AlternateContent></w:p>"###,
true,
),
];
for (name, attributes, body, selected, replacement, accepted) in cases {
let mut document = Document::new();
let source = format!(
r#"<w:document xmlns:w="{W_NS}" xmlns:mc="{MC_NS}" xmlns:x="urn:s95:opaque-a" xmlns:y="urn:s95:opaque-b" xmlns:a="urn:s95:opaque-a"{attributes}><w:body>{body}<w:sectPr/></w:body></w:document>"#
);
document
.package
.set_part(&document.doc_part_name.clone(), source.into_bytes());
let mut document = Document::from_package(document.package).unwrap();
let location = document.story_item_snapshots().unwrap()[selected]
.location()
.clone();
let selected_xml = document.paragraph_element_xml(&location).unwrap();
// Preserve the public namespace-complete fragment contract. The
// untouched input pairs themselves intentionally inherit xmlns.
let mut reader = quick_xml::Reader::from_reader(selected_xml.as_slice());
let root = match reader.read_event().unwrap() {
Event::Start(root) | Event::Empty(root) => root,
_ => panic!("selected paragraph root"),
};
let mut scope = BTreeMap::new();
update_story_namespace_scope(&mut scope, &root).unwrap();
let replacement =
close_content_fragment_namespaces(replacement.as_bytes(), &scope).unwrap();
let replacement = std::str::from_utf8(&replacement).unwrap();
let before = document.to_bytes().unwrap();
let result = document.replace_paragraph_xml(&location, replacement);
assert_eq!(result.is_ok(), accepted, "{name}: {result:?}");
if !accepted || name.contains("exact-noop") {
assert_eq!(document.to_bytes().unwrap(), before, "{name}");
} else {
let bytes = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&bytes).unwrap();
assert!(reopened.paragraph(selected).is_some(), "{name}");
}
}
}
#[test]
fn fx252_edited_paragraph_direct_children_follow_schema() {
let mut document = Document::new();
document.add_paragraph("keep");
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let original = document.paragraph_element_xml(&location).unwrap();
let original = std::str::from_utf8(&original).unwrap();
let before = document.to_bytes().unwrap();
// Exact official CT_P particle expansion, independently authenticated
// in the retained fifth-edition XSD receipt. Helper admission alone
// does not claim required attributes or range pairing are validated.
let context = EditedParagraphContext::default();
let schema_names = [
"bdo",
"bookmarkEnd",
"bookmarkStart",
"commentRangeEnd",
"commentRangeStart",
"customXml",
"customXmlDelRangeEnd",
"customXmlDelRangeStart",
"customXmlInsRangeEnd",
"customXmlInsRangeStart",
"customXmlMoveFromRangeEnd",
"customXmlMoveFromRangeStart",
"customXmlMoveToRangeEnd",
"customXmlMoveToRangeStart",
"del",
"dir",
"fldSimple",
"hyperlink",
"ins",
"moveFrom",
"moveFromRangeEnd",
"moveFromRangeStart",
"moveTo",
"moveToRangeEnd",
"moveToRangeStart",
"pPr",
"permEnd",
"permStart",
"proofErr",
"r",
"sdt",
"smartTag",
"subDoc",
];
for name in schema_names {
let xml = format!(r#"<w:p xmlns:w="{W_NS}"><w:{name}/></w:p>"#);
admit_edited_paragraph(original.as_bytes(), xml.as_bytes(), &context).unwrap();
}
for name in ["oMath", "oMathPara"] {
let xml = format!(r#"<w:p xmlns:w="{W_NS}" xmlns:m="{M_NS}"><m:{name}/></w:p>"#);
admit_edited_paragraph(original.as_bytes(), xml.as_bytes(), &context).unwrap();
}
for children in [
"<w:pPr/><w:pPr/>",
"<w:r/><w:pPr/>",
"<w:t/>",
"<w:tbl/>",
"<w:drawing/>",
"<w:sectPr/>",
"<m:f/>",
"<m:r/>",
"<x:r/>",
] {
let xml = format!(
r#"<w:p xmlns:w="{W_NS}" xmlns:m="{M_NS}" xmlns:x="urn:foreign">{children}</w:p>"#
);
assert!(
admit_edited_paragraph(original.as_bytes(), xml.as_bytes(), &context).is_err(),
"{children}"
);
}
for child in ["<w:bogus/>", "<x:foo xmlns:x=\"urn:example\"/>"] {
let xml = original.replace("</w:p>", &format!("{child}</w:p>"));
assert!(
document.replace_paragraph_xml(&location, &xml).is_err(),
"new child {child}"
);
assert_eq!(document.to_bytes().unwrap(), before);
}
}
#[test]
fn fx252_existing_nested_host_routes_remain_delegated() {
for contents in [
"<w:hyperlink><w:r><w:t>keep</w:t></w:r><x:island keep='exact'/></w:hyperlink>",
"<w:r><w:t>keep</w:t><x:island keep='exact'/></w:r>",
"<x:island keep='exact'/><x:island keep='exact'/><w:r><w:t>keep</w:t></w:r>",
] {
let mut document = Document::new();
let source = format!(
r#"<w:document xmlns:w="{W_NS}" xmlns:x="urn:producer"><w:body><w:p>{contents}</w:p><w:sectPr/></w:body></w:document>"#
);
document
.package
.set_part(&document.doc_part_name.clone(), source.into_bytes());
let mut document = Document::from_package(document.package).unwrap();
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let original = document.paragraph_element_xml(&location).unwrap();
let original = std::str::from_utf8(&original).unwrap();
let changed = original.replace(">keep<", ">changed<");
document.replace_paragraph_xml(&location, &changed).unwrap();
let saved = document.to_bytes().unwrap();
let mut reopened = Document::from_bytes(&saved).unwrap();
assert_eq!(reopened.paragraph(0).unwrap().text(), "changed");
let location = reopened.story_item_snapshots().unwrap()[0]
.location()
.clone();
let xml = reopened.paragraph_element_xml(&location).unwrap();
let xml = std::str::from_utf8(&xml).unwrap();
let copied = xml.replace("</w:p>", "<x:island keep='exact'/></w:p>");
assert!(reopened.replace_paragraph_xml(&location, &copied).is_err());
assert_eq!(reopened.to_bytes().unwrap(), saved);
}
// Existing nested foreign host routes are not a new recursive grammar.
let mut document = Document::new();
document.add_paragraph("keep");
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let xml = format!(
r#"<w:p xmlns:w="{W_NS}" xmlns:x="urn:hook"><w:r><w:t>keep</w:t></w:r><w:customXml><x:hostHook/></w:customXml></w:p>"#
);
document.replace_paragraph_xml(&location, &xml).unwrap();
}
#[test]
fn fx252_typed_sdt_metadata_remains_editable_around_opaque_source() {
let opaque = r#"<f:keep xmlns:f="urn:metadata" data='exact'/>"#;
let mut document = Document::new();
let source = format!(
r#"<w:document xmlns:w="{W_NS}"><w:body><w:p><w:sdt><w:sdtPr><w:id w:val="10"/><w:alias w:val="Alias"/><w:tag w:val="key"/><w:date w:fullDate="2026-10-16T00:00:00Z"><w:dateFormat w:val="yyyy-MM-dd"/></w:date>{opaque}</w:sdtPr><w:sdtContent><w:r><w:t>held</w:t></w:r></w:sdtContent></w:sdt></w:p><w:sectPr/></w:body></w:document>"#
);
document
.package
.set_part(&document.doc_part_name.clone(), source.into_bytes());
let mut document = Document::from_package(document.package).unwrap();
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let original = document.paragraph_element_xml(&location).unwrap();
let original = std::str::from_utf8(&original).unwrap();
let changed = original
.replace("w:val=\"Alias\"", "w:val=\"Changed\"")
.replace("yyyy-MM-dd", "dd/MM/yyyy");
assert_ne!(changed, original);
document.replace_paragraph_xml(&location, &changed).unwrap();
let reopened = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
let xml = reopened.story_item_snapshots().unwrap()[0].xml().to_vec();
let xml = std::str::from_utf8(&xml).unwrap();
assert!(
xml.contains("w:val=\"Changed\"") && xml.contains("dd/MM/yyyy") && xml.contains(opaque)
);
}
#[test]
fn fx252_unchanged_complex_mc_attribute_does_not_freeze_typed_metadata() {
let opaque = r#"<f:keep xmlns:f="urn:metadata" data='exact'/>"#;
let mut document = Document::new();
let source = format!(
r#"<w:document xmlns:w="{W_NS}"><w:body><w:p><w:sdt><w:sdtPr xmlns:mc="{MC_NS}" mc:MustUnderstand="w"><w:id w:val="10"/><w:alias w:val="Alias"/><w:tag w:val="key"/><w:date w:fullDate="2026-10-16T00:00:00Z"><w:dateFormat w:val="yyyy-MM-dd"/></w:date>{opaque}</w:sdtPr><w:sdtContent><w:r><w:t>held</w:t></w:r></w:sdtContent></w:sdt></w:p><w:sectPr/></w:body></w:document>"#
);
document
.package
.set_part(&document.doc_part_name.clone(), source.into_bytes());
let mut document = Document::from_package(document.package).unwrap();
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let original = document.paragraph_element_xml(&location).unwrap();
let original = std::str::from_utf8(&original).unwrap();
let changed = original
.replace("w:val=\"Alias\"", "w:val=\"Changed\"")
.replace("yyyy-MM-dd", "dd/MM/yyyy");
assert_ne!(changed, original);
document.replace_paragraph_xml(&location, &changed).unwrap();
let reopened = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
let xml = reopened.story_item_snapshots().unwrap()[0].xml().to_vec();
let xml = std::str::from_utf8(&xml).unwrap();
assert!(
xml.contains("w:val=\"Changed\"") && xml.contains("dd/MM/yyyy") && xml.contains(opaque)
);
}
#[test]
fn fx252_unselected_invalid_mc_declarations_do_not_become_ancestors() {
let mut document = Document::new();
let source = format!(
r#"<w:document xmlns:w="{W_NS}" xmlns:mc="{MC_NS}"><w:body><w:p mc:Ignorable="mc"><w:r><w:t>sibling</w:t></w:r></w:p><w:p><w:r><w:t>keep</w:t></w:r></w:p><w:sectPr/></w:body></w:document>"#
);
document
.package
.set_part(&document.doc_part_name.clone(), source.into_bytes());
let mut document = Document::from_package(document.package).unwrap();
let location = document.story_item_snapshots().unwrap()[1]
.location()
.clone();
let original = document.paragraph_element_xml(&location).unwrap();
let changed = std::str::from_utf8(&original)
.unwrap()
.replace(">keep<", ">changed<");
document.replace_paragraph_xml(&location, &changed).unwrap();
assert_eq!(document.paragraph(0).unwrap().text(), "sibling");
assert_eq!(document.paragraph(1).unwrap().text(), "changed");
}
#[test]
fn fx252_retained_choice_requires_keeps_declaration_meaning() {
let island = r#"<mc:AlternateContent><mc:Choice Requires="x"><w:r><w:t>choice</w:t></w:r></mc:Choice><mc:Fallback><w:r><w:t>fallback</w:t></w:r></mc:Fallback></mc:AlternateContent>"#;
let mut document = Document::new();
let source = format!(
r#"<w:document xmlns:w="{W_NS}" xmlns:mc="{MC_NS}" xmlns:x="urn:s95:opaque-a"><w:body><w:p><w:r><w:t>old</w:t></w:r>{island}</w:p><w:sectPr/></w:body></w:document>"#
);
document
.package
.set_part(&document.doc_part_name.clone(), source.into_bytes());
let mut document = Document::from_package(document.package).unwrap();
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let original = document.paragraph_element_xml(&location).unwrap();
let original = std::str::from_utf8(&original).unwrap();
let positive = original.replace(">old<", ">changed<");
document
.replace_paragraph_xml(&location, &positive)
.unwrap();
let before = document.to_bytes().unwrap();
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let selected = document.paragraph_element_xml(&location).unwrap();
let selected = std::str::from_utf8(&selected).unwrap();
let reinterpreted = selected
.replace("urn:s95:opaque-a", "urn:s95:opaque-b")
.replace(">changed<", ">later<");
assert_ne!(reinterpreted, selected);
assert!(
document
.replace_paragraph_xml(&location, &reinterpreted)
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), before);
}
#[test]
fn fx252_delegated_opaque_depth_retains_linear_route_storage() {
let depth = 64;
let mut document = Document::new();
document.add_paragraph("old");
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let original = document.paragraph_element_xml(&location).unwrap();
let nested = format!("{}{}", "<x:n>".repeat(depth), "</x:n>".repeat(depth));
let incoming = format!(
r#"<w:p xmlns:w="{W_NS}" xmlns:x="urn:delegated"><w:r><w:t>changed</w:t></w:r><w:customXml>{nested}</w:customXml></w:p>"#
);
FX252_RETAINED_ROUTE_ENTRIES.set(0);
admit_edited_paragraph(
&original,
incoming.as_bytes(),
&EditedParagraphContext::default(),
)
.unwrap();
assert!(
FX252_RETAINED_ROUTE_ENTRIES.get() <= 4 * (depth + 8),
"actual retained structural entries: {}",
FX252_RETAINED_ROUTE_ENTRIES.get()
);
document
.replace_paragraph_xml(&location, &incoming)
.unwrap();
}
#[test]
fn fx252_deep_namespace_declarations_keep_only_scope_deltas() {
let depth = 64;
let nested = (0..depth)
.map(|index| {
format!(r#"<x:n xmlns:n{index}="urn:depth:{index}" mc:Ignorable="n{index}">"#)
})
.collect::<String>()
+ &"</x:n>".repeat(depth);
let incoming = format!(
r#"<w:p xmlns:w="{W_NS}" xmlns:x="urn:delegated" xmlns:mc="{MC_NS}"><w:customXml>{nested}</w:customXml><w:r><w:t>changed</w:t></w:r></w:p>"#
);
let mut document = Document::new();
document.add_paragraph("old");
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let original = document.paragraph_element_xml(&location).unwrap();
FX252_RETAINED_SCOPE_ENTRIES.set(0);
admit_edited_paragraph(
&original,
incoming.as_bytes(),
&EditedParagraphContext::default(),
)
.unwrap();
assert!(
FX252_RETAINED_SCOPE_ENTRIES.get() <= 4 * (depth + 8),
"actual retained namespace and grant entries: {}",
FX252_RETAINED_SCOPE_ENTRIES.get()
);
document
.replace_paragraph_xml(&location, &incoming)
.unwrap();
}
#[test]
fn fx252_many_ignored_islands_share_long_namespace_storage() {
let namespace = format!("urn:producer:{}", "n".repeat(1024));
let incoming = format!(
r#"<w:p xmlns:w="{W_NS}" xmlns:x="{namespace}" xmlns:mc="{MC_NS}" mc:Ignorable="x">{}<w:r><w:t>changed</w:t></w:r></w:p>"#,
"<x:n/>".repeat(64)
);
let mut document = Document::new();
document.add_paragraph("old");
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
// Applicable ignored foreign children remain an actual public positive.
document
.replace_paragraph_xml(&location, &incoming)
.unwrap();
FX252_RETAINED_NAMESPACE_BYTES.set(0);
let mut routes = EditedParagraphRoutes::default();
let admission = edited_paragraph_admission(
incoming.as_bytes(),
&EditedParagraphContext::default(),
&mut routes,
)
.unwrap();
assert_eq!(admission.islands.len(), 64);
println!(
"retained namespace bytes {}, input bytes {}",
FX252_RETAINED_NAMESPACE_BYTES.get(),
incoming.len()
);
assert!(
FX252_RETAINED_NAMESPACE_BYTES.get() <= 2 * incoming.len(),
"actual retained namespace bytes: {}, input bytes: {}",
FX252_RETAINED_NAMESPACE_BYTES.get(),
incoming.len()
);
}
#[test]
fn fx252_ignorable_uris_follow_declaration_ancestry() {
let mut document = Document::new();
document.add_paragraph("keep");
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let before = document.to_bytes().unwrap();
let xml = format!(
r#"<w:p xmlns:w="{WORD_NAMESPACE}" xmlns:mc="{MC_NS}" mc:Ignorable="w"><w:r><w:t>keep</w:t></w:r><w:bogus/></w:p>"#
);
assert!(document.replace_paragraph_xml(&location, &xml).is_err());
assert_eq!(document.to_bytes().unwrap(), before);
}
#[test]
fn fx252_unchanged_opaque_islands_require_physical_correspondence() {
let mut document = Document::new();
let source = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}" xmlns:x="urn:producer"><w:body><w:p><x:island keep='exact'/><w:r><w:t>keep</w:t></w:r></w:p><w:sectPr/></w:body></w:document>"#
);
document
.package
.set_part(&document.doc_part_name.clone(), source.into_bytes());
let mut document = Document::from_package(document.package).unwrap();
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let original = document.paragraph_element_xml(&location).unwrap();
let original = std::str::from_utf8(&original).unwrap();
let before = document.to_bytes().unwrap();
let moved = original
.replace("<x:island keep='exact'/>", "")
.replace("</w:p>", "<x:island keep='exact'/></w:p>");
assert!(document.replace_paragraph_xml(&location, &moved).is_err());
assert_eq!(document.to_bytes().unwrap(), before);
}
#[test]
fn fx211_selected_paragraph_xml_stages_noops_and_late_refusals() {
let mut document = Document::new();
document.add_paragraph("before");
document.add_paragraph("unrelated");
let snapshots = document.story_item_snapshots().unwrap();
let location = snapshots[0].location().clone();
let selected = document.story_range_paragraph_snapshot(&location).unwrap();
let identical = std::str::from_utf8(selected.xml()).unwrap().to_owned();
let before = document.to_bytes().unwrap();
document
.replace_paragraph_xml(&location, &identical)
.unwrap();
assert_eq!(document.to_bytes().unwrap(), before);
let invalid = format!(
r#"<w:p xmlns:w="{WORD_NAMESPACE}" xmlns:r="{}"><w:hyperlink r:id="absent"><w:r><w:t>late</w:t></w:r></w:hyperlink></w:p>"#,
rdocx_oxml::namespace::R_NS
);
let error = document
.replace_paragraph_xml(&location, &invalid)
.unwrap_err();
assert!(error.to_string().contains("relationship"), "{error}");
assert_eq!(document.to_bytes().unwrap(), before);
let opaque = r#"<x:payload xmlns:x="urn:producer" x:key='keep'><x:item/></x:payload>"#;
let xml = format!(
r#"<q:p xmlns:q="{WORD_NAMESPACE}" xmlns:mc="{MC_NS}" xmlns:x="urn:producer" mc:Ignorable="x"><q:r><q:t>after</q:t></q:r>{opaque}</q:p>"#
);
document.replace_paragraph_xml(&location, &xml).unwrap();
let saved = document.to_bytes().unwrap();
let mut reopened = Document::from_bytes(&saved).unwrap();
assert_eq!(reopened.paragraph(0).unwrap().text(), "after");
assert_eq!(reopened.paragraph(1).unwrap().text(), "unrelated");
assert!(
String::from_utf8(reopened.story_item_snapshots().unwrap()[0].xml().to_vec())
.unwrap()
.contains(opaque)
);
let twice = Document::from_bytes(&reopened.to_bytes().unwrap()).unwrap();
assert!(
String::from_utf8(twice.story_item_snapshots().unwrap()[0].xml().to_vec())
.unwrap()
.contains(opaque)
);
}
#[test]
fn fx211_selected_run_xml_replaces_checked_existing_owner_atomically() {
let mut document = Document::new();
document.add_paragraph("first").add_run("second");
let location = document.story_item_snapshots().unwrap()[0]
.location()
.clone();
let position = StoryRunPosition {
location: location.clone(),
run_index: 1,
};
let before = document.to_bytes().unwrap();
let wrong = format!(r#"<w:fld xmlns:w="{WORD_NAMESPACE}"/>"#);
assert!(document.replace_run_xml(&position, &wrong).is_err());
assert_eq!(document.to_bytes().unwrap(), before);
let xml = format!(
r#"<alias:r xmlns:alias="{WORD_NAMESPACE}"><alias:t>changed</alias:t></alias:r>"#
);
let missing = StoryRunPosition {
location: location.clone(),
run_index: 999,
};
assert!(document.replace_run_xml(&missing, &xml).is_err());
assert_eq!(document.to_bytes().unwrap(), before);
document.replace_run_xml(&position, &xml).unwrap();
let reopened = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
assert_eq!(reopened.paragraph(0).unwrap().text(), "firstchanged");
}
use super::*;
use crate::FieldEvaluationContext;
use crate::paragraph::Alignment;
use oxml_chart::{
Axis, AxisData, AxisId, AxisKind, AxisPosition, BarDirection, BarGrouping, CT_PlotArea,
ChartData, ChartKind, NumericData, Plot, Series, StringRef,
};
use oxml_sml::Column;
use rdocx_oxml::text::Field;
use rdocx_oxml::units::{HalfPoint, Twips};
use std::fs;
use std::io::Cursor;
use std::process::Command;
const WORD_VERSION: &str = "16.104";
const WORD_BUILD: &str = "16.104.25121423";
const WORD_CHART_CANDIDATE_SHA256: &str =
"904e718b1fe0e524be7df75430705a8a70e03089ed96460ad008c4016c36ad3d";
const FX087_WORD_VERSION: &str = "16.112.3";
const FX087_WORD_BUILD: &str = "16.112.26083020";
const FX087_PAGES_VERSION: &str = "15.1.1";
const FX087_PAGES_BUILD: &str = "7044.0.273";
const FX087_CANDIDATE_SHA256: &str =
"ab67b50393fc5258f7a3e9719344639d665feccc2615b13cab1915ea9a84566b";
#[test]
fn fx200_checked_one_call_lists_reject_malformed_graph_without_publication() {
for numbered in [false, true] {
let mut document = Document::new();
document.try_add_numbered_list_item("existing", 0).unwrap();
let duplicate = document.numbering.as_ref().unwrap().nums[0].clone();
document.numbering.as_mut().unwrap().nums.push(duplicate);
let numbering_before = document.numbering.as_ref().unwrap().to_xml().unwrap();
let count_before = document.paragraph_count();
let error = if numbered {
document.try_add_numbered_list_item("bad", 0).map(|_| ())
} else {
document.try_add_bullet_list_item("bad", 0).map(|_| ())
};
assert!(
error
.unwrap_err()
.to_string()
.contains("duplicate numbering instance")
);
assert_eq!(
document.numbering.as_ref().unwrap().to_xml().unwrap(),
numbering_before
);
assert_eq!(document.paragraph_count(), count_before);
}
}
#[test]
fn pagination_update_publishes_one_atomic_candidate() {
let mut document = Document::new();
for name in ["PAGE", "NUMPAGES", "SECTION", "SECTIONPAGES"] {
document
.add_paragraph("")
.add_run("")
.add_field(name, "OLD")
.unwrap();
}
document.set_header("physical header");
document.to_bytes().unwrap();
LAYOUT_INVOCATIONS.set(0);
let report = document.update_layout_backed_fields().unwrap();
assert_eq!(report.updated_count(), 4);
assert_eq!(LAYOUT_INVOCATIONS.get(), 1);
let saved = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&saved).unwrap();
let evaluations = reopened
.evaluate_fields(&crate::field::FieldEvaluationContext::default())
.unwrap();
assert!(evaluations.iter().all(|field| field.cached_result == "1"));
}
#[test]
fn python_story_inventory_scales_linearly() {
for paragraph_count in [64, 128] {
let mut document = Document::new();
for index in 0..paragraph_count {
document.add_paragraph(&format!("paragraph {index}"));
}
STORY_SOURCE_BUILDS.set(0);
let items = document.story_item_snapshots().unwrap();
assert_eq!(
items
.iter()
.filter(|item| item.location().item_kind() == StoryItemKind::Paragraph)
.count(),
paragraph_count,
);
assert_eq!(STORY_SOURCE_BUILDS.get(), 1);
STORY_SOURCE_BUILDS.set(0);
assert!(document.story_link_snapshots().unwrap().is_empty());
assert_eq!(STORY_SOURCE_BUILDS.get(), 1);
}
}
// The per-story algorithm that `story_range_paragraph_sources` replaced.
// Each story looks up its source and owner again, each owner and control
// is scanned from the start of its source, and each paragraph scope, own
// declarations included, is read from the start of its source too.
fn reference_story_range_paragraph_spans(
xml: &[u8],
owner: &StoryOwnerSpan,
story: &StoryId,
) -> Result<Vec<(ContentLocation, Range<usize>)>> {
let mut paragraphs = Vec::new();
for (index, item) in scan_story_items(xml, owner)?.iter().enumerate() {
let spans = match item.kind {
StoryItemKind::Paragraph => vec![(vec![index], item.full.clone())],
StoryItemKind::ContentControl => scan_story_control_paragraphs(xml, item)?
.into_iter()
.enumerate()
.map(|(paragraph, span)| (vec![index, paragraph], span))
.collect(),
_ => continue,
};
for (path, span) in spans {
paragraphs.push((
ContentLocation::new(story.clone(), StoryItemKind::Paragraph, path),
span,
));
}
}
Ok(paragraphs)
}
fn reference_story_range_paragraphs(
document: &Document,
) -> Result<Vec<(ContentLocation, Vec<u8>)>> {
let mut paragraphs = Vec::new();
for (story_index, story) in document.stories()?.into_iter().enumerate() {
let (source, owner) = document.story_source_and_owner(&story)?;
let source_xml = source.xml.as_ref();
for (location, span) in
reference_story_range_paragraph_spans(source_xml, &owner, &story)?
{
let scope = story_namespace_scope_at(source_xml, span.start)?;
let order = span.start;
let xml = close_content_fragment_namespaces(&source_xml[span], &scope)?;
paragraphs.push((story_index, order, location, xml));
}
}
paragraphs.sort_by_key(|(story_index, offset, _, _)| (*story_index, *offset));
Ok(paragraphs
.into_iter()
.map(|(_, _, location, xml)| (location, xml))
.collect())
}
fn reference_comment_story_range_paragraphs(
document: &Document,
) -> Result<Vec<(ContentLocation, Vec<u8>)>> {
let mut paragraphs = reference_story_range_paragraphs(document)?;
let canonical = document.document.to_xml()?;
let restored = document.namespace_restored_main_source()?;
if restored == canonical {
return Ok(paragraphs);
}
let (boundaries, _) = comment_namespace_source_boundaries(&canonical, &restored)?
.ok_or_else(|| Error::Other("replay changed physical source".into()))?;
let mut paragraph_spans = HashMap::new();
for owner in &scan_story_owners(&canonical, StoryKind::Body)? {
let story = StoryId {
kind: owner.kind,
part_name: document.doc_part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
};
for (location, span) in
reference_story_range_paragraph_spans(&canonical, owner, &story)?
{
if paragraph_spans.insert(location, span).is_some() {
return Err(Error::Other("duplicate physical paragraph path".into()));
}
}
}
let scopes = story_namespace_scopes_at(
&restored,
paragraph_spans
.values()
.filter_map(|span| boundaries.get(&span.start).copied()),
)?;
for (location, xml) in &mut paragraphs {
if location.story.part_name != document.doc_part_name {
continue;
}
let span = ¶graph_spans[location];
let start = boundaries[&span.start];
let end = boundaries[&span.end];
*xml = close_content_fragment_namespaces(&restored[start..end], &scopes[&start])?;
}
Ok(paragraphs)
}
#[allow(clippy::type_complexity)] // The cache fields, compared as one tuple.
fn reference_text_box_cache_paragraphs(
document: &Document,
) -> Result<Vec<(ContentLocation, Vec<u8>, usize, usize)>> {
let mut result = Vec::new();
let mut seen = HashSet::new();
for (location, xml) in reference_story_range_paragraphs(document)? {
if !matches!(
location.story.kind,
StoryKind::TextBox | StoryKind::TableCell
) {
continue;
}
let (source, paragraph) = document.story_range_paragraph_source(&location)?;
let text_box = scan_story_owners(source.xml.as_ref(), source.root_kind)?
.into_iter()
.filter(|owner| {
owner.kind == StoryKind::TextBox
&& owner.full.start <= paragraph.full.start
&& owner.full.end >= paragraph.full.end
})
.min_by_key(|owner| owner.full.len());
if let Some(text_box) = text_box
&& seen.insert((
source.part_name.clone(),
paragraph.full.start,
paragraph.full.end,
))
{
result.push((location, xml, text_box.owner_index, paragraph.full.start));
}
}
result.sort_by(|left, right| {
(left.0.story.part_name.as_str(), left.2, left.3).cmp(&(
right.0.story.part_name.as_str(),
right.2,
right.3,
))
});
let mut ordinals = HashMap::<(String, usize), usize>::new();
for (location, _, owner_index, paragraph_index) in &mut result {
let ordinal = ordinals
.entry((location.story.part_name.clone(), *owner_index))
.or_default();
*paragraph_index = *ordinal;
*ordinal += 1;
}
Ok(result)
}
// Every story family, a nested table, block, nested and inline controls,
// text boxes in the body, a cell and a header, complex fields, bookmarks
// and a comment range. Paragraphs of the raw parts carry their own
// namespace declarations: one redeclares an inherited prefix, one binds a
// second Word prefix and some are self-closing. The header uses another
// Word prefix and is also referenced as a footer, so its cells and text
// box are reached from two sources.
fn all_story_range_fixture() -> Document {
let w = WORD_NAMESPACE;
let r = drawing_ns::R;
let field = concat!(
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r>"#,
r#"<w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>"#,
r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>1</w:t></w:r>"#,
r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
);
let text_box = |prefix: &str, text: &str| {
format!(
r#"<{prefix}:r><{prefix}:pict><v:shape><v:textbox><{prefix}:txbxContent><{prefix}:p><{prefix}:r><{prefix}:t>{text}</{prefix}:t></{prefix}:r></{prefix}:p><{prefix}:p xmlns:x="urn:box"/></{prefix}:txbxContent></v:textbox></v:shape></{prefix}:pict></{prefix}:r>"#
)
};
let body = format!(
concat!(
r#"<w:p><w:bookmarkStart w:id="1" w:name="BodyMark"/><w:r><w:t>body</w:t></w:r><w:bookmarkEnd w:id="1"/></w:p>"#,
r#"<w:p><w:r><w:t>page </w:t></w:r>{field}<w:sdt><w:sdtContent><w:r><w:t>inline control</w:t></w:r></w:sdtContent></w:sdt></w:p>"#,
r#"<w:tbl><w:tr><w:tc><w:p><w:r><w:t>cell</w:t></w:r></w:p></w:tc>"#,
r#"<w:tc><w:p><w:r><w:t>outer cell</w:t></w:r></w:p><w:tbl><w:tr><w:tc><w:p><w:r><w:t>nested cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p/></w:tc>"#,
r#"<w:tc><w:p>{cell_box}</w:p></w:tc></w:tr></w:tbl>"#,
r#"<w:sdt><w:sdtContent><w:p><w:r><w:t>control</w:t></w:r></w:p><w:sdt><w:sdtContent><w:p><w:r><w:t>nested control</w:t></w:r></w:p></w:sdtContent></w:sdt><w:p><w:r><w:t>control end</w:t></w:r></w:p></w:sdtContent></w:sdt>"#,
r#"<w:p>{body_box}</w:p>"#,
r#"<w:p><w:commentRangeStart w:id="2"/><w:r><w:t>commented</w:t></w:r><w:commentRangeEnd w:id="2"/><w:r><w:commentReference w:id="2"/></w:r></w:p>"#,
r#"<w:sectPr><w:headerReference w:type="default" r:id="storyHeader"/><w:footerReference w:type="default" r:id="storyFooter"/><w:footerReference w:type="first" r:id="storyHeaderAsFooter"/></w:sectPr>"#,
),
field = field,
cell_box = text_box("w", "cell text box"),
body_box = text_box("w", "body text box"),
);
let header = format!(
concat!(
r#"<q:hdr xmlns:q="{w}" xmlns:x="urn:producer" xmlns:v="urn:schemas-microsoft-com:vml">"#,
r#"<q:p><q:bookmarkStart q:id="3" q:name="HeaderMark"/><q:r><q:t>header</q:t></q:r><q:bookmarkEnd q:id="3"/></q:p>"#,
r#"<q:p xmlns:x="urn:other"><q:r><q:t>redeclared</q:t></q:r></q:p>"#,
r#"<q:p xmlns:z="{w}"><z:r><z:t>own word prefix</z:t></z:r></q:p>"#,
r#"<q:p xmlns:x="urn:empty"/>"#,
r#"<q:p>{field}</q:p>"#,
r#"<q:tbl><q:tr><q:tc><q:p><q:r><q:t>header cell</q:t></q:r></q:p></q:tc><q:tc><q:p>{header_box}</q:p></q:tc></q:tr></q:tbl>"#,
r#"<q:sdt><q:sdtContent><q:p><q:r><q:t>header control</q:t></q:r></q:p></q:sdtContent></q:sdt>"#,
r#"</q:hdr>"#,
),
w = w,
field = field.replace("w:", "q:"),
header_box = text_box("q", "header text box"),
);
let mut seed = Document::new();
let mut package = OpcPackage::from_reader(Cursor::new(seed.to_bytes().unwrap())).unwrap();
package.set_part(
"/word/document.xml",
format!(
r#"<w:document xmlns:w="{w}" xmlns:r="{r}" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:x="urn:producer"><w:body>{body}</w:body></w:document>"#
)
.into_bytes(),
);
let relationships = package.get_or_create_part_rels("/word/document.xml");
for (id, relationship_type, target) in [
("storyHeader", rel_types::HEADER, "header-story.xml"),
("storyHeaderAsFooter", rel_types::FOOTER, "header-story.xml"),
("storyFooter", rel_types::FOOTER, "footer-story.xml"),
(
"storyFootnotes",
rel_types::FOOTNOTES,
"footnotes-story.xml",
),
("storyEndnotes", rel_types::ENDNOTES, "endnotes-story.xml"),
("storyComments", rel_types::COMMENTS, "comments-story.xml"),
] {
relationships.add_with_id(id, relationship_type, target);
}
for (part, family, xml) in [
("header-story.xml", "header", header),
(
"footer-story.xml",
"footer",
format!(
r#"<w:ftr xmlns:w="{w}"><w:p><w:r><w:t>footer</w:t></w:r></w:p><w:p><w:fldSimple w:instr="PAGE"><w:r><w:t>1</w:t></w:r></w:fldSimple></w:p></w:ftr>"#
),
),
(
"footnotes-story.xml",
"footnotes",
format!(
r#"<w:footnotes xmlns:w="{w}"><w:footnote w:id="2"><w:p><w:bookmarkStart w:id="4" w:name="NoteMark"/><w:r><w:t>footnote</w:t></w:r><w:bookmarkEnd w:id="4"/></w:p><w:tbl><w:tr><w:tc><w:p><w:r><w:t>note cell</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:p/></w:footnote></w:footnotes>"#
),
),
(
"endnotes-story.xml",
"endnotes",
format!(
r#"<w:endnotes xmlns:w="{w}"><w:endnote w:id="2"><w:p><w:r><w:t>endnote</w:t></w:r></w:p><w:sdt><w:sdtContent><w:p><w:r><w:t>endnote control</w:t></w:r></w:p></w:sdtContent></w:sdt></w:endnote></w:endnotes>"#
),
),
(
"comments-story.xml",
"comments",
format!(
r#"<w:comments xmlns:w="{w}"><w:comment w:id="2" w:author="Ada"><w:p><w:r><w:t>comment</w:t></w:r></w:p><w:p xmlns:x="urn:comment"><w:r><w:t>second</w:t></w:r></w:p></w:comment></w:comments>"#
),
),
] {
let part = format!("/word/{part}");
package.set_part(&part, xml.into_bytes());
package.content_types.add_override(
&part,
&format!(
"application/vnd.openxmlformats-officedocument.wordprocessingml.{family}+xml"
),
);
}
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
Document::from_bytes(bytes.get_ref()).unwrap()
}
fn authored_story_range_fixture() -> Document {
let mut document = Document::new();
document.add_paragraph("Introduction");
let mut table = document.add_table(3, 3);
for row in 0..3 {
for column in 0..3 {
table
.cell(row, column)
.unwrap()
.set_text(&format!("cell {row} {column}"));
}
}
document.add_paragraph("Commented text");
document.set_header("header");
document.set_footer("footer");
document.add_footnote("footnote");
document
.add_comment_on_text("Commented", 0, "Ada", None, "note", None)
.unwrap();
document.insert_toc(0, 3).unwrap();
Document::from_bytes(&document.to_bytes().unwrap()).unwrap()
}
// Namespace declarations that the typed main document drops, so the
// rich comment projection replays them on a restored main source.
fn restored_comment_story_range_fixture() -> Document {
let mut seed = Document::new();
seed.add_paragraph("A");
let id = seed
.add_comment_on_text("A", 0, "Ada", None, "Review", None)
.unwrap();
let body = format!(
concat!(
r#"<w:p xmlns:x="urn:x"><w:commentRangeStart w:id="{id}"/><w:r><w:t>A</w:t></w:r><w:r><x:custom x:a="1"/></w:r><w:commentRangeEnd w:id="{id}"/><w:r><w:commentReference w:id="{id}"/></w:r></w:p>"#,
r#"<w:tbl xmlns:x="urn:x"><w:tr><w:tc><w:p><w:r><w:t>cell</w:t></w:r><x:custom x:a="2"/></w:p></w:tc></w:tr></w:tbl>"#,
r#"<w:sdt><w:sdtContent><w:p xmlns:x="urn:x"><w:r><w:t>control</w:t></w:r><w:r><x:custom x:a="3"/></w:r></w:p></w:sdtContent></w:sdt>"#,
r#"<w:p/>"#,
),
id = id,
);
let mut package = OpcPackage::from_reader(Cursor::new(seed.to_bytes().unwrap())).unwrap();
package.set_part(
"/word/document.xml",
format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body>{body}</w:body></w:document>"#
)
.into_bytes(),
);
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
Document::from_bytes(bytes.get_ref()).unwrap()
}
// A self-closing owner, here an empty comment, holds no paragraph. A scan
// from the start of its source used to run past it and fail at the next
// closing tag, which broke the comment listing and page field updates.
#[test]
fn story_range_paragraphs_match_the_per_story_scan() {
let all = all_story_range_fixture();
let paragraphs = all.story_range_paragraphs().unwrap();
assert_eq!(
paragraphs
.iter()
.map(|(location, _)| location.story().kind())
.collect::<HashSet<_>>()
.len(),
8
);
assert!(
paragraphs
.iter()
.any(|(location, _)| location.index_path().len() == 2)
);
assert!(!all.text_box_cache_paragraphs().unwrap().is_empty());
assert_eq!(
all.story_sources()
.unwrap()
.iter()
.filter(|source| source.part_name == "/word/header-story.xml")
.count(),
2
);
let restored = restored_comment_story_range_fixture();
assert_ne!(
restored.namespace_restored_main_source().unwrap(),
restored.document.to_xml().unwrap()
);
for document in [all, authored_story_range_fixture(), restored] {
assert_eq!(
document.story_range_paragraphs().unwrap(),
reference_story_range_paragraphs(&document).unwrap()
);
assert_eq!(
document.comment_story_range_paragraphs().unwrap(),
reference_comment_story_range_paragraphs(&document).unwrap()
);
assert_eq!(
document
.text_box_cache_paragraphs()
.unwrap()
.into_iter()
.map(|paragraph| (
paragraph.location,
paragraph.xml,
paragraph.owner_index,
paragraph.paragraph_index
))
.collect::<Vec<_>>(),
reference_text_box_cache_paragraphs(&document).unwrap()
);
// The batched spans also match for every owner of every source,
// including owners `stories` reports from an earlier source.
for source in document.story_sources().unwrap() {
let owners = scan_story_owners(&source.xml, source.root_kind)
.unwrap()
.into_iter()
.map(|owner| {
let story = StoryId {
kind: owner.kind,
part_name: source.part_name.clone(),
owner_index: owner.owner_index,
fingerprint: owner.fingerprint,
};
(story, owner)
})
.collect::<Vec<_>>();
let mut reference = Vec::new();
for (story, owner) in &owners {
reference.extend(
reference_story_range_paragraph_spans(&source.xml, owner, story).unwrap(),
);
}
assert_eq!(
story_range_paragraph_spans(&source.xml, &owners)
.unwrap()
.into_iter()
.flatten()
.collect::<Vec<_>>(),
reference
);
}
}
}
#[test]
fn story_excerpt_scope_preserves_single_root_local_declarations() {
let xml = format!(
r#"<q:document xmlns:q="{WORD_NAMESPACE}" xmlns:x="urn:ancestor"><q:body><q:p xmlns:x="{WORD_NAMESPACE}"><x:r><x:t>local</x:t></x:r></q:p><q:p xmlns:x="urn:empty"/></q:body></q:document>"#
);
let start = xml.find("<q:p ").unwrap();
let empty = xml.find(r#"<q:p xmlns:x="urn:empty"/>"#).unwrap();
let scopes = story_namespace_scopes_at(xml.as_bytes(), [start, empty]).unwrap();
assert_eq!(scopes[&start]["x"], "urn:ancestor");
assert_eq!(scopes[&empty]["x"], "urn:ancestor");
let end = start + xml[start..].find("</q:p>").unwrap() + "</q:p>".len();
let (closed, _) =
scoped_story_fragment(xml.as_bytes(), start..end, &scopes[&start]).unwrap();
let item = StoryItemSpan {
kind: StoryItemKind::Paragraph,
full: start..end,
scan: start..end,
direct_owner_child: true,
complex_field: false,
complex_ancestors: Vec::new(),
sdt_context: None,
};
assert_eq!(
story_item_text_with_scope(xml.as_bytes(), &item, &scopes[&start], &[])
.unwrap()
.as_deref(),
Some("local")
);
assert!(
String::from_utf8(closed)
.unwrap()
.contains(&format!(r#"xmlns:x="{WORD_NAMESPACE}""#))
);
assert_eq!(
story_namespace_scope_at(xml.as_bytes(), start).unwrap()["x"],
WORD_NAMESPACE
);
}
#[test]
fn complex_field_excerpt_link_offsets_preserve_exact_source_bytes() {
// This tests the private projection mechanism. Public typed discovery
// deliberately does not admit a hyperlink wrapper inside a field span.
let field = concat!(
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r>"#,
r#"<w:r><w:instrText> PAGE </w:instrText></w:r>"#,
r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r>"#,
r#"<w:hyperlink r:id="rIdActual"><w:r><w:t>7</w:t></w:r></w:hyperlink>"#,
r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
);
for (word_prefix, rel_prefix, first_local) in
[("w", "r", false), ("q", "rel", false), ("q", "rel", true)]
{
let mut field = field
.replace("w:", &format!("{word_prefix}:"))
.replace("r:id", &format!("{rel_prefix}:id"));
if first_local {
field = field.replacen(
&format!("<{word_prefix}:r>"),
&format!(r#"<{word_prefix}:r xmlns:{rel_prefix}="urn:first-run">"#),
1,
);
}
let xml = format!(
r#"<{word_prefix}:document xmlns:{word_prefix}="{WORD_NAMESPACE}" xmlns:{rel_prefix}="{}" xmlns:x="urn:producer&escaped"><{word_prefix}:body><{word_prefix}:p><{word_prefix}:hyperlink {rel_prefix}:id="rIdBefore"><{word_prefix}:r><{word_prefix}:t>before</{word_prefix}:t></{word_prefix}:r></{word_prefix}:hyperlink>{field}<{word_prefix}:hyperlink {word_prefix}:anchor="after"><{word_prefix}:r><{word_prefix}:t>after</{word_prefix}:t></{word_prefix}:r></{word_prefix}:hyperlink></{word_prefix}:p></{word_prefix}:body></{word_prefix}:document>"#,
drawing_ns::R
);
let start = xml.find(&field).unwrap();
let end = start + field.len();
let item = StoryItemSpan {
kind: StoryItemKind::Field,
full: start + field.find('>').unwrap() + 1
..end - format!("</{word_prefix}:r>").len(),
scan: start..end,
direct_owner_child: false,
complex_field: true,
complex_ancestors: vec![0],
sdt_context: None,
};
let scope = story_namespace_scopes_at(xml.as_bytes(), [start])
.unwrap()
.remove(&start)
.unwrap();
let (closed, local, _) = scoped_story_item(xml.as_bytes(), &item, &scope).unwrap();
assert_eq!(&closed[local.scan.clone()], field.as_bytes());
assert_eq!(
&closed[local.full.clone()],
&xml.as_bytes()[item.full.clone()]
);
assert!(local.complex_ancestors.is_empty());
assert_eq!(
story_item_text_with_scope(xml.as_bytes(), &item, &scope, &[])
.unwrap()
.as_deref(),
Some("7")
);
let links = scan_story_item_links_with_scope(xml.as_bytes(), &item, &scope).unwrap();
assert_eq!(links.len(), 1);
let expected = format!(
r#"<{word_prefix}:hyperlink {rel_prefix}:id="rIdActual"><{word_prefix}:r><{word_prefix}:t>7</{word_prefix}:t></{word_prefix}:r></{word_prefix}:hyperlink>"#
);
assert_eq!(&xml.as_bytes()[links[0].full.clone()], expected.as_bytes());
assert_eq!(links[0].full.start, xml.find(&expected).unwrap());
assert_eq!(links[0].rel_id.as_deref(), Some("rIdActual"));
let mut invalid = item.clone();
invalid.full.start = start - 1;
assert!(scoped_story_item(xml.as_bytes(), &invalid, &scope).is_err());
}
}
#[test]
fn story_link_snapshots_do_not_rescan_story_prefix_per_link() {
let mut document = Document::new();
let relationship_id = document.add_hyperlink_relationship("https://example.com");
for index in 0..64 {
document
.add_paragraph("")
.add_hyperlink(&format!("link {index}"), &relationship_id);
}
STORY_TEXT_PREFIX_BYTES.set(0);
let links = document.story_link_snapshots().unwrap();
assert_eq!(links.len(), 64);
assert_eq!(links.first().unwrap().1.text, "link 0");
assert_eq!(links.last().unwrap().1.text, "link 63");
assert_eq!(STORY_TEXT_PREFIX_BYTES.get(), 0);
}
/// Word writes a text box as DrawingML in `mc:Choice` and as VML in
/// `mc:Fallback`. Only the first Choice that holds a text box is an
/// owner, and its drawing lists no item in the paragraph that holds it.
/// A Choice before it, a picture here, and any Choice after it stay
/// opaque, as the Fallback does.
#[test]
fn only_the_first_choice_of_alternate_content_with_a_text_box_is_an_owner() {
let text_box = |text: &str| {
format!(r#"<w:txbxContent><w:p><w:r><w:t>{text}</w:t></w:r></w:p></w:txbxContent>"#)
};
let choice = |text: &str| {
format!(
r#"<mc:Choice Requires="wps"><w:drawing><wp:anchor><a:graphic><a:graphicData><wps:wsp><wps:txbx>{}</wps:txbx></wps:wsp></a:graphicData></a:graphic></wp:anchor></w:drawing></mc:Choice>"#,
text_box(text)
)
};
let picture = r#"<mc:Choice Requires="wps"><w:drawing><wp:inline><a:graphic><a:graphicData/></a:graphic></wp:inline></w:drawing></mc:Choice>"#;
for branches in [
choice("choice"),
[picture, &choice("choice"), &choice("later choice")].concat(),
] {
let xml = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" xmlns:wps="http://schemas.microsoft.com/office/word/2010/wordprocessingShape" xmlns:v="urn:schemas-microsoft-com:vml"><w:body><w:p><w:r><w:t>host</w:t></w:r><w:r><mc:AlternateContent>{branches}<mc:Fallback><w:pict><v:shape><v:textbox>{}</v:textbox></v:shape></w:pict></mc:Fallback></mc:AlternateContent></w:r></w:p></w:body></w:document>"#,
text_box("fallback"),
);
let owners = scan_story_owners(xml.as_bytes(), StoryKind::Body).unwrap();
assert_eq!(
owners.iter().map(|owner| owner.kind).collect::<Vec<_>>(),
[StoryKind::Body, StoryKind::TextBox]
);
assert_eq!(&xml[owners[1].full.clone()], text_box("choice"));
let body_items = scan_story_items(xml.as_bytes(), &owners[0]).unwrap();
assert_eq!(
body_items.iter().map(|item| item.kind).collect::<Vec<_>>(),
[StoryItemKind::Paragraph]
);
assert_eq!(
story_item_text(xml.as_bytes(), &body_items[0], &[], 0..xml.len(), 0).unwrap(),
Some("host".to_owned())
);
}
}
/// A run's `w:tab`, empty or not, reads as a tab. A tab stop in the
/// paragraph properties and a tab in a deleted run do not.
#[test]
fn fx203_validation_styles_select_qualified_first_supported_mc_branches() {
let word = W_NS;
let mc = MC_NS;
let source = format!(
r#"<w:document xmlns:w="{word}" xmlns:mc="{mc}" xmlns:x="urn:unsupported" xmlns:q="{word}"><w:body><w:pStyle w:val="before"/><mc:AlternateContent><mc:Choice Requires="x"><w:pStyle w:val="phantom"/></mc:Choice><mc:Choice Requires="q"><w:rStyle w:val="chosen"/></mc:Choice><mc:Choice Requires="w"><w:pStyle w:val="second"/></mc:Choice><mc:Fallback><w:pStyle w:val="fallback"/></mc:Fallback></mc:AlternateContent><w:tblStyle w:val="after"/></w:body></w:document>"#
);
assert_eq!(
document_style_references(source.as_bytes()).unwrap(),
[
("paragraph", "before".into()),
("character", "chosen".into()),
("table", "after".into())
]
);
let rebound = source.replace(
"<mc:Choice Requires=\"q\">",
"<mc:Choice xmlns:q=\"urn:foreign\" Requires=\"q\">",
);
assert_eq!(
document_style_references(rebound.as_bytes()).unwrap()[1],
("paragraph", "second".into())
);
let fallback = rebound.replace(
"<mc:Choice Requires=\"w\">",
"<mc:Choice xmlns:w=\"urn:foreign\" Requires=\"w\">",
);
assert_eq!(
document_style_references(fallback.as_bytes()).unwrap(),
[
("paragraph", "before".into()),
("paragraph", "fallback".into()),
("table", "after".into())
]
);
let foreign = source.replace(
"<mc:Choice Requires=\"q\">",
"<mc:Choice xmlns:mc=\"urn:foreign\" Requires=\"q\">",
);
assert_eq!(
document_style_references(foreign.as_bytes()).unwrap()[1],
("paragraph", "second".into())
);
assert!(
document_style_references(source.replace("</mc:Fallback>", "</mc:Choice>").as_bytes())
.is_err()
);
assert!(document_style_references(format!("{source}<other/>").as_bytes()).is_err());
assert!(document_style_references(b"<w:document").is_err());
}
#[test]
fn story_item_text_reads_run_tabs_only() {
let xml = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body><w:p><w:pPr><w:tabs><w:tab w:val="right" w:pos="9000"/><w:tab w:val="left" w:pos="100"></w:tab></w:tabs></w:pPr><w:r><w:t>a</w:t><w:tab/><w:t>b</w:t></w:r><w:del w:id="1" w:author="A"><w:r><w:tab/></w:r></w:del><w:r><w:tab></w:tab><w:t>c</w:t></w:r></w:p><w:p><w:r><w:tab/></w:r></w:p></w:body></w:document>"#
);
let owners = scan_story_owners(xml.as_bytes(), StoryKind::Body).unwrap();
let items = scan_story_items(xml.as_bytes(), &owners[0]).unwrap();
assert_eq!(
items
.iter()
.map(|item| story_item_text(xml.as_bytes(), item, &[], 0..xml.len(), 0).unwrap())
.collect::<Vec<_>>(),
[Some("a\tb\tc".to_owned()), Some("\t".to_owned())]
);
}
#[test]
fn authored_relationship_source_order_resolves_namespaces_and_entities() {
let xml = format!(
r#"<w:root xmlns:w="{WORD_NAMESPACE}" xmlns:r="{}" xmlns:q="{}" xmlns:x="urn:foreign"><x:lookalike x:id="rId1">r:id="rId1"</x:lookalike><w:a q:embed="rId2"/><w:b r:id="rId1"/></w:root>"#,
drawing_ns::R,
drawing_ns::R,
);
let relationship_ids = HashSet::from(["rId1".to_owned(), "rId2".to_owned()]);
assert_eq!(
authored_relationship_ids_in_source_order(xml.as_bytes(), &relationship_ids),
Some(vec!["rId2".to_owned(), "rId1".to_owned()])
);
}
fn replace_numbering_xml(document: &mut Document, xml: Vec<u8>) -> Vec<u8> {
let mut package =
OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
package.set_part(DEFAULT_NUMBERING_PART, xml);
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
bytes.into_inner()
}
#[cfg(all(feature = "digital-signatures", not(target_arch = "wasm32")))]
fn signature_fixture(name: &str) -> Vec<u8> {
use base64::Engine as _;
let source = include_str!("../../oxml-opc/src/signature.rs");
let prefix = format!("const {name}: &str = \"");
let encoded = source
.split_once(&prefix)
.and_then(|(_, remainder)| remainder.split_once("\";").map(|(value, _)| value))
.expect("signature fixture remains available");
base64::engine::general_purpose::STANDARD
.decode(encoded)
.unwrap()
}
#[test]
fn identifier_reservation_reports_relationship_overflow() {
let mut occupied = HashSet::from([format!("rId{}", u32::MAX)]);
let mut cursor = u32::MAX;
let error = reserve_relationship_from_cursor(&mut occupied, &mut cursor).unwrap_err();
assert!(
error
.to_string()
.contains("relationship id range is exhausted")
);
assert_eq!(occupied.len(), 1, "a failed reservation must be atomic");
}
#[test]
fn provisional_relationships_reserve_numeric_capacity() {
let owner = "/word/document.xml";
let mut package = OpcPackage::new();
package.get_or_create_part_rels(owner).add_with_id(
&format!("rId{}", u32::MAX - 1),
"urn:producer",
"producer.bin",
);
let mut identifiers = DocumentIdentifiers::scan(&package).unwrap();
let first = identifiers
.reserve_bundle_relationship_id_checked(owner, rel_types::STYLES)
.unwrap();
assert!(first.starts_with("rdocxDeferredStyles"));
assert!(
identifiers
.reserve_bundle_relationship_id_checked(owner, rel_types::NUMBERING)
.unwrap_err()
.to_string()
.contains("relationship id range is exhausted")
);
assert!(identifiers.reserve_relationship_id_checked(owner).is_err());
assert_eq!(identifiers.provisional_relationship_ids[owner].len(), 1);
}
#[test]
fn same_part_normalized_drawing_ids_are_counted_not_refused() {
let mut package =
OpcPackage::with_main_part("word/document.xml", content_types::WORD_DOCUMENT);
package.set_part(
"/word/document.xml",
format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}" xmlns:wp="{}"><w:body><wp:docPr id="1"/><wp:docPr id="1"/></w:body></w:document>"#,
drawing_ns::WP,
)
.into_bytes(),
);
let mut identifiers = DocumentIdentifiers::scan(&package).unwrap();
assert_eq!(
identifiers.repeated_drawing_ids,
HashMap::from([("/word/document.xml".to_owned(), HashMap::from([(1, 2)]))])
);
assert_eq!(identifiers.reserve_drawing_id().unwrap(), 2);
}
#[test]
fn foreign_doc_pr_does_not_enter_drawing_identity_scope() {
let mut package =
OpcPackage::with_main_part("word/document.xml", content_types::WORD_DOCUMENT);
package.set_part(
"/word/document.xml",
format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}" xmlns:wp="{}" xmlns:x="urn:foreign"><w:body><x:docPr id="1"/><wp:docPr id="1"/></w:body></w:document>"#,
drawing_ns::WP,
)
.into_bytes(),
);
let mut identifiers = DocumentIdentifiers::scan(&package).unwrap();
assert_eq!(identifiers.reserve_drawing_id().unwrap(), 2);
}
#[test]
fn default_namespace_use_respects_element_scope() {
let root = "urn:root-default";
let declarations = vec![("xmlns".to_owned(), root.to_owned())];
for xml in [
format!(
r#"<q:document xmlns:q="{WORD_NAMESPACE}" xmlns="{root}"><q:body foreign="value"/></q:document>"#
),
format!(
r#"<q:document xmlns:q="{WORD_NAMESPACE}" xmlns="{root}"><q:body><local xmlns="urn:nested"><child/></local></q:body></q:document>"#
),
format!(
r#"<q:document xmlns:q="{WORD_NAMESPACE}" xmlns="{root}"><q:body><local xmlns="{root}"><child/></local></q:body></q:document>"#
),
format!(
r#"<q:document xmlns:q="{WORD_NAMESPACE}" xmlns="{root}"><q:body><local xmlns=""><child/></local></q:body></q:document>"#
),
format!(
r#"<?xml version="1.0"?><w:document xmlns:w="{WORD_NAMESPACE}" xmlns="{root}"><w:body><w:p><w:r><w:drawing xmlns:wp="{}" xmlns:a="{}"><wp:inline><wp:docPr id="1"/><a:graphic/></wp:inline></w:drawing></w:r></w:p></w:body></w:document>"#,
drawing_ns::WP,
drawing_ns::A,
),
] {
assert_eq!(
unsafe_serializer_namespace_prefix(&declarations, &[], Some(xml.as_bytes())),
None,
"{xml}"
);
}
let used = format!(
r#"<q:document xmlns:q="{WORD_NAMESPACE}" xmlns="{root}"><q:body><local/></q:body></q:document>"#
);
assert_eq!(
unsafe_serializer_namespace_prefix(&declarations, &[], Some(used.as_bytes())),
Some("default".to_owned())
);
let malformed =
format!(r#"<q:document xmlns:q="{WORD_NAMESPACE}" xmlns="{root}"><q:body>"#);
let ambiguous = format!(
r#"<q:document xmlns:q="{WORD_NAMESPACE}" xmlns="{root}" xmlns="urn:other"><q:body/></q:document>"#
);
for xml in [malformed, ambiguous] {
assert_eq!(
unsafe_serializer_namespace_prefix(&declarations, &[], Some(xml.as_bytes())),
Some("default".to_owned()),
"{xml}"
);
}
}
#[test]
fn merge_preflights_all_required_bundle_relationships() {
let owner = "/word/document.xml";
let mut source =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
let mut package = OpcPackage::from_reader(Cursor::new(source.to_bytes().unwrap())).unwrap();
package
.get_or_create_part_rels(owner)
.items
.retain(|relationship| relationship.rel_type != rel_types::STYLES);
package.get_or_create_part_rels(owner).add_with_id(
&format!("rId{}", u32::MAX - 1),
"urn:producer",
"producer.bin",
);
package.parts.remove(DEFAULT_STYLES_PART);
package.content_types.overrides.remove(DEFAULT_STYLES_PART);
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
let mut target = Document::from_bytes(bytes.get_ref()).unwrap();
let mut other = Document::new();
other
.add_style(StyleBuilder::paragraph("Merged", "Merged"))
.unwrap();
other.add_list_definition(&[ListLevel::decimal()]);
let before = {
let mut bytes = Cursor::new(Vec::new());
target.package.write_to(&mut bytes).unwrap();
bytes.into_inner()
};
assert!(target.reserve_merge_bundles(&other).is_err());
let after = {
let mut bytes = Cursor::new(Vec::new());
target.package.write_to(&mut bytes).unwrap();
bytes.into_inner()
};
assert_eq!(after, before);
assert!(target.styles_part_name.is_none());
assert!(target.numbering_part_name.is_none());
}
#[test]
fn preferred_part_fallback_uses_the_greatest_positive_family_suffix() {
fn reserve(existing: &[&str], preferred: &str) -> String {
let mut package = OpcPackage::new();
for part_name in existing {
package.set_part(part_name, Vec::new());
}
DocumentIdentifiers::scan(&package)
.unwrap()
.reserve_preferred_part_name(preferred)
.unwrap()
}
assert_eq!(
reserve(&["/word/header1.xml"], "/word/header1.xml"),
"/word/header2.xml"
);
assert_eq!(
reserve(&["/WORD/HEADER1.XML"], "/word/header1.xml"),
"/word/header2.xml"
);
let mut relationship_only = OpcPackage::new();
relationship_only
.get_or_create_part_rels("/word/document.xml")
.add("urn:producer", "HEADER1.XML");
assert_eq!(
DocumentIdentifiers::scan(&relationship_only)
.unwrap()
.reserve_preferred_part_name("/word/header1.xml")
.unwrap(),
"/word/header2.xml"
);
assert_eq!(
reserve(
&[
"/word/comments.xml",
"/word/comments4.xml",
"/word/comments0.xml",
"/word/comments-9.xml",
"/word/commentsx.xml",
],
"/word/comments.xml",
),
"/word/comments5.xml"
);
assert_eq!(
reserve(&["/word/footnotes.xml"], "/word/footnotes.xml"),
"/word/footnotes1.xml"
);
let mut identifiers = DocumentIdentifiers::scan(&OpcPackage::new()).unwrap();
for malformed in [
"comments.xml",
"/word/comments",
"/word/.xml",
"/word/1.xml",
] {
assert!(identifiers.reserve_preferred_part_name(malformed).is_err());
}
assert!(identifiers.part_names.is_empty());
}
#[test]
fn relationship_only_targets_stay_reserved_when_facades_materialize_them() {
fn reopen(package: OpcPackage) -> Document {
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
Document::from_bytes(bytes.get_ref()).unwrap()
}
let mut settings_source = Document::new();
let mut settings_package =
OpcPackage::from_reader(Cursor::new(settings_source.to_bytes().unwrap())).unwrap();
settings_package.parts.remove(DEFAULT_SETTINGS_PART);
settings_package
.content_types
.overrides
.remove(DEFAULT_SETTINGS_PART);
let mut settings = reopen(settings_package);
settings.set_auto_hyphenation(true).unwrap();
assert_ne!(
settings
.identifiers
.reserve_fragment_part_name(DEFAULT_SETTINGS_PART)
.unwrap(),
DEFAULT_SETTINGS_PART
);
let mut comments_source = Document::new();
comments_source.add_paragraph("commented");
let mut comments_package =
OpcPackage::from_reader(Cursor::new(comments_source.to_bytes().unwrap())).unwrap();
comments_package
.get_or_create_part_rels("/word/document.xml")
.add_with_id("producerComments", rel_types::COMMENTS, "comments7.xml");
comments_package
.get_or_create_part_rels("/word/document.xml")
.add_with_id(
"producerCommentsExtended",
crate::comments::COMMENTS_EXTENDED_REL_TYPE,
"commentsExtended7.xml",
);
let mut comments = reopen(comments_package);
comments
.add_comment(
crate::comments::RunRange {
start: crate::comments::RunPosition {
body_index: 0,
run_index: 0,
},
end: crate::comments::RunPosition {
body_index: 0,
run_index: 1,
},
},
"Reviewer",
None,
"note",
)
.unwrap();
for target in ["/word/comments7.xml", "/word/commentsExtended7.xml"] {
assert_ne!(
comments
.identifiers
.reserve_fragment_part_name(target)
.unwrap(),
target
);
}
let mut header_source = Document::new();
header_source.set_header("producer");
let mut header_package =
OpcPackage::from_reader(Cursor::new(header_source.to_bytes().unwrap())).unwrap();
header_package.parts.remove("/word/header1.xml");
header_package
.content_types
.overrides
.remove("/word/header1.xml");
let mut header = reopen(header_package);
header.set_header("replacement");
assert_ne!(
header
.identifiers
.reserve_fragment_part_name("/word/header1.xml")
.unwrap(),
"/word/header1.xml"
);
}
#[test]
fn authored_bundles_preserve_unrelated_conventional_parts() {
let mut source =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
let mut package = OpcPackage::from_reader(Cursor::new(source.to_bytes().unwrap())).unwrap();
package
.get_or_create_part_rels("/word/document.xml")
.items
.retain(|relationship| relationship.rel_type != rel_types::STYLES);
let orphans = [
(DEFAULT_STYLES_PART, b"producer-styles".as_slice()),
(DEFAULT_NUMBERING_PART, b"producer-numbering".as_slice()),
(DEFAULT_CORE_PROPERTIES_PART, b"producer-core".as_slice()),
];
for (part_name, bytes) in orphans {
package.set_part(part_name, bytes.to_vec());
package
.content_types
.add_override(part_name, "application/example+xml");
}
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
let mut document = Document::from_bytes(bytes.get_ref()).unwrap();
document
.add_style(StyleBuilder::paragraph("Custom", "Custom"))
.unwrap();
document.add_list_definition(&[ListLevel::decimal()]);
document.set_title("Title");
let saved = document.to_bytes().unwrap();
let package = OpcPackage::from_reader(Cursor::new(saved)).unwrap();
for (part_name, expected) in orphans {
assert_eq!(package.get_part(part_name), Some(expected));
assert_eq!(
package.content_types.content_type_for(part_name),
Some("application/example+xml")
);
}
let document_relationships = package.get_part_rels("/word/document.xml").unwrap();
for (rel_type, expected) in [
(rel_types::STYLES, "/word/styles1.xml"),
(rel_types::NUMBERING, "/word/numbering1.xml"),
] {
let relationship = document_relationships.get_by_type(rel_type).unwrap();
assert_eq!(
OpcPackage::resolve_rel_target("/word/document.xml", &relationship.target),
expected
);
}
let core_relationship = package
.package_rels
.get_by_type(CORE_PROPERTIES_REL_TYPE)
.unwrap();
assert_eq!(
OpcPackage::resolve_rel_target("/", &core_relationship.target),
"/docProps/core1.xml"
);
}
#[test]
fn identifier_content_type_registry_uses_ascii_case_insensitive_keys() {
let mut package = OpcPackage::new();
package.content_types.add_default("PNG", "image/png");
package
.content_types
.add_override("/WORD/DOCUMENT.XML", content_types::WORD_DOCUMENT);
let mut identifiers = DocumentIdentifiers::scan(&package).unwrap();
assert!(identifiers.content_type_defaults.contains("png"));
assert!(
identifiers
.content_type_overrides
.contains("/word/document.xml")
);
identifiers.register_content_type_default("JPG");
identifiers.register_content_type_override("/WORD/HEADER1.XML");
assert!(identifiers.content_type_defaults.contains("jpg"));
assert!(identifiers.authored_content_type_defaults.contains("jpg"));
assert!(
identifiers
.content_type_overrides
.contains("/word/header1.xml")
);
identifiers.retire_authored_part("/WORD/HEADER1.XML");
assert!(
!identifiers
.content_type_overrides
.contains("/word/header1.xml")
);
}
#[test]
fn read_only_save_canonicalizes_a_missing_styles_edge() {
let mut source =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
let mut package = OpcPackage::from_reader(Cursor::new(source.to_bytes().unwrap())).unwrap();
package
.get_or_create_part_rels("/word/document.xml")
.items
.retain(|relationship| relationship.rel_type != rel_types::STYLES);
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
let mut document = Document::from_bytes(bytes.get_ref()).unwrap();
let saved = OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
let styles = saved
.get_part_rels("/word/document.xml")
.and_then(|relationships| relationships.get_by_type(rel_types::STYLES))
.unwrap();
assert!(
styles
.id
.strip_prefix("rId")
.is_some_and(|suffix| suffix.parse::<u32>().is_ok())
);
assert!(!styles.id.starts_with("rdocxDeferred"));
}
#[cfg(all(feature = "digital-signatures", not(target_arch = "wasm32")))]
#[test]
fn signing_and_subsequent_save_share_canonical_package_preparation() {
let mut source =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
source.add_paragraph("signed");
let mut package = OpcPackage::from_reader(Cursor::new(source.to_bytes().unwrap())).unwrap();
package
.get_or_create_part_rels("/word/document.xml")
.items
.retain(|relationship| relationship.rel_type != rel_types::STYLES);
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
let mut document = Document::from_bytes(bytes.get_ref()).unwrap();
let report = document
.sign(
&signature_fixture("TEST_PRIVATE_KEY_PKCS8_BASE64"),
&signature_fixture("TEST_CERTIFICATE_DER_BASE64"),
)
.unwrap();
assert!(report.cryptographically_valid);
assert!(report.coverage_complete);
let styles = document
.package
.get_part_rels("/word/document.xml")
.and_then(|relationships| {
relationships.items.iter().find(|relationship| {
relationship.rel_type == rel_types::STYLES
&& relationship_is_internal(relationship)
})
})
.unwrap();
assert!(
styles
.id
.strip_prefix("rId")
.is_some_and(|suffix| suffix.parse::<u32>().is_ok())
);
let saved = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&saved).unwrap();
let reports = reopened.verify_signatures().unwrap();
assert_eq!(reports.len(), 1);
assert!(reports[0].cryptographically_valid);
assert!(reports[0].coverage_complete);
}
#[test]
fn staged_numbering_bundle_uses_a_collision_safe_nonsemantic_relationship_id() {
fn image_relationship_id(bytes: Vec<u8>) -> String {
OpcPackage::from_reader(Cursor::new(bytes))
.unwrap()
.get_part_rels("/word/document.xml")
.and_then(|relationships| relationships.get_by_type(rel_types::IMAGE))
.expect("image relationship")
.id
.clone()
}
let mut control =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
control.add_picture(b"image", "image.png", Length::pt(1.0), Length::pt(1.0));
control.add_list_definition(&[ListLevel::decimal()]);
let control_image_id = image_relationship_id(control.to_bytes().unwrap());
let mut document =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
document.add_list_definition(&[ListLevel::decimal()]);
let provisional = document
.package
.get_part_rels("/word/document.xml")
.and_then(|relationships| relationships.get_by_type(rel_types::NUMBERING))
.expect("staged numbering relationship")
.id
.clone();
assert!(provisional.starts_with("rdocxDeferredNumbering"));
document.add_picture(b"image", "image.png", Length::pt(1.0), Length::pt(1.0));
assert_eq!(
image_relationship_id(document.to_bytes().unwrap()),
control_image_id
);
let mut staged = document.clone_for_staging();
staged.canonicalize_typed_identifiers().unwrap();
staged.canonicalize_drawing_ids().unwrap();
let numeric = staged
.package
.get_part_rels("/word/document.xml")
.and_then(|relationships| relationships.get_by_type(rel_types::NUMBERING))
.expect("canonical numbering relationship")
.id
.clone();
assert!(
numeric
.strip_prefix("rId")
.is_some_and(|value| value.parse::<u32>().is_ok())
);
let occupied = staged
.identifiers
.relationship_ids
.get("/word/document.xml")
.unwrap();
assert!(occupied.contains(&numeric));
assert!(!occupied.contains(&provisional));
let authored = staged
.identifiers
.authored_bundle_relationship_ids
.get("/word/document.xml")
.unwrap();
assert!(authored.contains(&numeric));
assert!(!authored.contains(&provisional));
staged.canonicalize_typed_identifiers().unwrap();
staged.canonicalize_drawing_ids().unwrap();
assert_eq!(
staged
.package
.get_part_rels("/word/document.xml")
.and_then(|relationships| relationships.get_by_type(rel_types::NUMBERING))
.unwrap()
.id,
numeric
);
let mut collision =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
collision
.package
.get_or_create_part_rels("/word/document.xml")
.add_with_id("rId99", "urn:producer", "producer.bin");
collision.identifiers = DocumentIdentifiers::scan(&collision.package).unwrap();
collision.add_list_definition(&[ListLevel::decimal()]);
let collision_bytes = collision.to_bytes().unwrap();
let collision_package = OpcPackage::from_reader(Cursor::new(collision_bytes)).unwrap();
let relationships = collision_package
.get_part_rels("/word/document.xml")
.unwrap();
assert_eq!(
relationships.get_by_id("rId99").unwrap().rel_type,
"urn:producer"
);
assert!(
relationships
.get_by_type(rel_types::NUMBERING)
.expect("numbering fallback relationship")
.id
.starts_with("rId")
);
let mut producer =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
producer
.package
.get_or_create_part_rels("/word/document.xml")
.add_with_id("rdocxDeferredNumbering", "urn:producer", "producer.bin");
producer.identifiers = DocumentIdentifiers::scan(&producer.package).unwrap();
producer.add_list_definition(&[ListLevel::decimal()]);
assert_eq!(
producer
.package
.get_part_rels("/word/document.xml")
.and_then(|relationships| relationships.get_by_type(rel_types::NUMBERING))
.unwrap()
.id,
"rdocxDeferredNumbering1"
);
let saved = OpcPackage::from_reader(Cursor::new(producer.to_bytes().unwrap())).unwrap();
let relationships = saved.get_part_rels("/word/document.xml").unwrap();
assert_eq!(
relationships
.get_by_id("rdocxDeferredNumbering")
.unwrap()
.rel_type,
"urn:producer"
);
let numbering = relationships.get_by_type(rel_types::NUMBERING).unwrap();
assert!(
numbering
.id
.strip_prefix("rId")
.is_some_and(|value| value.parse::<u32>().is_ok())
);
assert!(!relationships.items.iter().any(|relationship| {
relationship.id.starts_with("rdocxDeferredNumbering")
&& relationship.rel_type == rel_types::NUMBERING
}));
}
#[test]
fn prepared_reopen_preserves_authored_bundle_provenance_and_final_order() {
fn build(image_before_reopen: bool) -> Vec<u8> {
let mut document = Document::new_with_profile(WordCreationProfile::Minimal(
WordPackageClass::Document,
));
document.add_list_definition(&[ListLevel::decimal()]);
let provisional = document
.package
.get_part_rels("/word/document.xml")
.and_then(|relationships| relationships.get_by_type(rel_types::NUMBERING))
.unwrap()
.id
.clone();
assert!(provisional.starts_with("rdocxDeferredNumbering"));
if image_before_reopen {
document.add_picture(b"image", "image.png", Length::pt(1.0), Length::pt(1.0));
}
let mut document = document.prepare_and_reopen_staged().unwrap();
let numbering = document
.package
.get_part_rels("/word/document.xml")
.and_then(|relationships| relationships.get_by_type(rel_types::NUMBERING))
.unwrap()
.id
.clone();
assert!(
numbering
.strip_prefix("rId")
.is_some_and(|suffix| suffix.parse::<u32>().is_ok())
);
assert!(
document.identifiers.authored_bundle_relationship_ids["/word/document.xml"]
.contains(&numbering)
);
assert!(
!document.identifiers.preserved_relationship_ids["/word/document.xml"]
.contains(&numbering)
);
if !image_before_reopen {
document.add_picture(b"image", "image.png", Length::pt(1.0), Length::pt(1.0));
}
document.to_bytes().unwrap()
}
assert_eq!(build(false), build(true));
}
#[test]
fn external_bundle_edges_do_not_shadow_or_satisfy_internal_parts() {
let mut source = Document::new();
let mut package = OpcPackage::from_reader(Cursor::new(source.to_bytes().unwrap())).unwrap();
for (id, mode) in [
("producerExternalStyles", "External"),
("producerLowercaseStyles", "internal"),
("producerMalformedStyles", "ProducerMode"),
] {
package
.get_or_create_part_rels("/word/document.xml")
.items
.insert(
0,
oxml_opc::relationship::Relationship {
id: id.to_owned(),
rel_type: rel_types::STYLES.to_owned(),
target: format!("https://example.com/{id}.xml"),
target_mode: Some(mode.to_owned()),
},
);
}
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
let document = Document::from_bytes(bytes.get_ref()).unwrap();
assert_eq!(
document.styles_part_name.as_deref(),
Some(DEFAULT_STYLES_PART)
);
let mut external_only =
OpcPackage::from_reader(Cursor::new(source.to_bytes().unwrap())).unwrap();
external_only
.get_or_create_part_rels("/word/document.xml")
.items
.retain(|relationship| relationship.rel_type != rel_types::STYLES);
external_only
.get_or_create_part_rels("/word/document.xml")
.items
.push(oxml_opc::relationship::Relationship {
id: "rdocxStyles".to_owned(),
rel_type: rel_types::STYLES.to_owned(),
target: "https://example.com/styles.xml".to_owned(),
target_mode: Some("External".to_owned()),
});
external_only.parts.remove(DEFAULT_STYLES_PART);
external_only
.content_types
.overrides
.remove(DEFAULT_STYLES_PART);
let mut bytes = Cursor::new(Vec::new());
external_only.write_to(&mut bytes).unwrap();
let mut document = Document::from_bytes(bytes.get_ref()).unwrap();
document
.add_style(StyleBuilder::paragraph("Custom", "Custom"))
.unwrap();
let saved = OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
let relationships = saved.get_part_rels("/word/document.xml").unwrap();
assert!(relationships.items.iter().any(|relationship| {
relationship.id == "rdocxStyles"
&& relationship.target_mode.as_deref() == Some("External")
}));
assert!(relationships.items.iter().any(|relationship| {
relationship.id.starts_with("rId")
&& relationship.rel_type == rel_types::STYLES
&& relationship_is_internal(relationship)
}));
let mut malformed_header = Document::new();
malformed_header.set_header("producer header");
let header_id = malformed_header
.document
.body
.sect_pr
.as_ref()
.unwrap()
.header_refs[0]
.rel_id
.clone();
malformed_header
.package
.get_or_create_part_rels("/word/document.xml")
.items
.iter_mut()
.find(|relationship| relationship.id == header_id)
.unwrap()
.target_mode = Some("internal".to_owned());
assert_eq!(malformed_header.header_text(), None);
assert!(malformed_header.active_header_footer_parts().is_empty());
malformed_header.set_header("replacement header");
assert_eq!(
malformed_header.header_text().as_deref(),
Some("replacement header")
);
let active_relationship = malformed_header
.package
.get_part_rels("/word/document.xml")
.unwrap()
.items
.iter()
.find(|relationship| {
relationship.rel_type == rel_types::HEADER && relationship_is_internal(relationship)
})
.unwrap();
assert_ne!(active_relationship.id, header_id);
}
#[test]
fn malformed_header_modes_do_not_shadow_or_enter_raw_mutations() {
let mut document = Document::new();
document.set_header("valid secret");
let valid_id = document.section_properties().unwrap().header_refs[0]
.rel_id
.clone();
document.package.set_part(
"/word/producer-header.xml",
format!(
r#"<w:hdr xmlns:w="{}"><w:p><w:r><w:t>producer secret</w:t></w:r></w:p></w:hdr>"#,
rdocx_oxml::namespace::W_NS
)
.into_bytes(),
);
document
.package
.get_or_create_part_rels("/word/document.xml")
.items
.insert(
0,
oxml_opc::relationship::Relationship {
id: "producerHeader".to_owned(),
rel_type: rel_types::HEADER.to_owned(),
target: "producer-header.xml".to_owned(),
target_mode: Some("internal".to_owned()),
},
);
document.section_properties_mut().header_refs.insert(
0,
HdrFtrRef {
hdr_ftr_type: HdrFtrType::Default,
rel_id: "producerHeader".to_owned(),
},
);
assert_eq!(document.replace_regex("secret", "changed").unwrap(), 1);
assert_eq!(document.header_text().as_deref(), Some("valid changed"));
assert!(
String::from_utf8_lossy(
document
.package
.get_part("/word/producer-header.xml")
.unwrap()
)
.contains("producer secret")
);
assert!(document.load_header_footer(&valid_id, true).is_some());
let mut external_only = Document::new();
external_only.set_header("external secret");
let relationship = external_only
.package
.get_or_create_part_rels("/word/document.xml")
.items
.iter_mut()
.find(|relationship| relationship.rel_type == rel_types::HEADER)
.unwrap();
relationship.target_mode = Some("External".to_owned());
assert_eq!(external_only.replace_regex("secret", "changed").unwrap(), 0);
assert!(external_only.header_text().is_none());
let mut watermark = Document::new();
watermark.set_header("producer header");
let producer_id = watermark.section_properties().unwrap().header_refs[0]
.rel_id
.clone();
watermark
.package
.get_or_create_part_rels("/word/document.xml")
.items
.iter_mut()
.find(|relationship| relationship.id == producer_id)
.unwrap()
.target_mode = Some("ProducerDefined".to_owned());
watermark.set_text_watermark("DRAFT").unwrap();
assert!(
watermark
.package
.get_part_rels("/word/document.xml")
.unwrap()
.items
.iter()
.any(|relationship| {
relationship.rel_type == rel_types::HEADER
&& relationship.id != producer_id
&& relationship_is_internal(relationship)
})
);
}
#[test]
fn cross_type_header_footer_references_never_mutate_the_target_story() {
let mut document = Document::new();
document.set_header("header secret");
document.set_footer("footer secret");
let section = document.section_properties().unwrap();
let header_id = section.header_refs[0].rel_id.clone();
let footer_id = section.footer_refs[0].rel_id.clone();
let header_part = document.header_footer_part_name(&header_id, true).unwrap();
let footer_part = document.header_footer_part_name(&footer_id, false).unwrap();
let section = document.section_properties_mut();
section.header_refs[0].rel_id.clone_from(&footer_id);
section.footer_refs[0].rel_id.clone_from(&header_id);
assert_eq!(document.replace_regex("secret", "changed").unwrap(), 0);
assert!(
String::from_utf8_lossy(document.package.get_part(&header_part).unwrap())
.contains("header secret")
);
assert!(
String::from_utf8_lossy(document.package.get_part(&footer_part).unwrap())
.contains("footer secret")
);
assert!(document.load_header_footer(&footer_id, true).is_none());
assert!(document.load_header_footer(&header_id, false).is_none());
fn assert_setter_reserves_a_fresh_part(
is_header: bool,
hdr_type: HdrFtrType,
setter: impl FnOnce(&mut Document),
) {
let producer_part = "/word/producer-cross-type.xml";
let producer_bytes = b"producer bytes that must not change";
let mut document = Document::new();
document
.package
.set_part(producer_part, producer_bytes.to_vec());
let cross_type = if is_header {
rel_types::FOOTER
} else {
rel_types::HEADER
};
document
.package
.get_or_create_part_rels("/word/document.xml")
.add_with_id("producerCrossType", cross_type, "producer-cross-type.xml");
let reference = HdrFtrRef {
hdr_ftr_type: hdr_type,
rel_id: "producerCrossType".to_owned(),
};
if is_header {
document
.section_properties_mut()
.header_refs
.push(reference);
} else {
document
.section_properties_mut()
.footer_refs
.push(reference);
}
setter(&mut document);
assert_eq!(
document.package.get_part(producer_part),
Some(producer_bytes.as_slice())
);
let section = document.section_properties().unwrap();
let installed = if is_header {
§ion.header_refs
} else {
§ion.footer_refs
}
.iter()
.find(|reference| reference.hdr_ftr_type == hdr_type)
.unwrap();
assert_ne!(installed.rel_id, "producerCrossType");
let installed_part = document
.header_footer_part_name(&installed.rel_id, is_header)
.unwrap();
assert_ne!(installed_part, producer_part);
}
let png = super::watermark_tests::PNG;
let size = Length::pt(12.0);
assert_setter_reserves_a_fresh_part(true, HdrFtrType::Default, |document| {
document.set_header("new header")
});
assert_setter_reserves_a_fresh_part(false, HdrFtrType::Default, |document| {
document.set_footer("new footer")
});
assert_setter_reserves_a_fresh_part(true, HdrFtrType::First, |document| {
document.set_first_page_header("new first header")
});
assert_setter_reserves_a_fresh_part(false, HdrFtrType::First, |document| {
document.set_first_page_footer("new first footer")
});
assert_setter_reserves_a_fresh_part(true, HdrFtrType::Default, |document| {
document.set_header_image(png, "image.png", size, size)
});
assert_setter_reserves_a_fresh_part(false, HdrFtrType::Default, |document| {
document.set_footer_image(png, "image.png", size, size)
});
assert_setter_reserves_a_fresh_part(true, HdrFtrType::Default, |document| {
document.set_raw_header_with_images(
format!(r#"<w:hdr xmlns:w="{}"/>"#, rdocx_oxml::namespace::W_NS).into_bytes(),
&[],
HdrFtrType::Default,
)
});
assert_setter_reserves_a_fresh_part(false, HdrFtrType::Default, |document| {
document.set_raw_footer_with_images(
format!(r#"<w:ftr xmlns:w="{}"/>"#, rdocx_oxml::namespace::W_NS).into_bytes(),
&[],
HdrFtrType::Default,
)
});
assert_setter_reserves_a_fresh_part(true, HdrFtrType::Default, |document| {
document.set_header_image_with_background(png, "image.png", size, size, "000000")
});
assert_setter_reserves_a_fresh_part(true, HdrFtrType::First, |document| {
document.set_first_page_header_image(png, "image.png", size, size)
});
}
#[test]
fn unrelated_header_shaped_target_does_not_shadow_or_enter_replacement() {
let mut document = Document::new();
document.set_header("valid secret");
document.package.set_part(
"/word/producer-shaped.xml",
format!(
r#"<w:hdr xmlns:w="{}"><w:p><w:r><w:t>producer secret</w:t></w:r></w:p></w:hdr>"#,
rdocx_oxml::namespace::W_NS
)
.into_bytes(),
);
document
.package
.get_or_create_part_rels("/word/document.xml")
.add_with_id("producerShaped", rel_types::IMAGE, "producer-shaped.xml");
document.section_properties_mut().header_refs.insert(
0,
HdrFtrRef {
hdr_ftr_type: HdrFtrType::Default,
rel_id: "producerShaped".to_owned(),
},
);
let replacements = HashMap::from([("secret", "changed")]);
assert_eq!(document.replace_all(&replacements), 1);
assert_eq!(document.header_text().as_deref(), Some("valid changed"));
assert!(
String::from_utf8_lossy(
document
.package
.get_part("/word/producer-shaped.xml")
.unwrap()
)
.contains("producer secret")
);
}
#[test]
fn bundle_and_semantic_relationships_are_canonicalized_together() {
fn build(reverse: bool) -> Vec<u8> {
let mut document = Document::new_with_profile(WordCreationProfile::Minimal(
WordPackageClass::Document,
));
document.add_paragraph("review");
document.add_paragraph("link: ");
document
.package
.get_or_create_part_rels("/word/document.xml")
.add_with_id("rId40", "urn:producer", "producer.bin");
document.identifiers = DocumentIdentifiers::scan(&document.package).unwrap();
if reverse {
document
.add_comment(
crate::comments::RunRange {
start: crate::comments::RunPosition {
body_index: 0,
run_index: 0,
},
end: crate::comments::RunPosition {
body_index: 0,
run_index: 1,
},
},
"Reviewer",
None,
"note",
)
.unwrap();
document.set_first_page_header("first");
document.set_header("default");
document.add_picture(b"image", "image.png", Length::pt(1.0), Length::pt(1.0));
let hyperlink = document.add_hyperlink_relationship("https://example.com");
document
.paragraph_mut(1)
.unwrap()
.add_hyperlink("example", &hyperlink);
document.add_list_definition(&[ListLevel::decimal()]);
document
.add_style(StyleBuilder::paragraph("Custom", "Custom"))
.unwrap();
document.set_auto_hyphenation(true).unwrap();
} else {
document.set_auto_hyphenation(true).unwrap();
document
.add_style(StyleBuilder::paragraph("Custom", "Custom"))
.unwrap();
document.add_list_definition(&[ListLevel::decimal()]);
let hyperlink = document.add_hyperlink_relationship("https://example.com");
document
.paragraph_mut(1)
.unwrap()
.add_hyperlink("example", &hyperlink);
document.add_picture(b"image", "image.png", Length::pt(1.0), Length::pt(1.0));
document.set_header("default");
document.set_first_page_header("first");
document
.add_comment(
crate::comments::RunRange {
start: crate::comments::RunPosition {
body_index: 0,
run_index: 0,
},
end: crate::comments::RunPosition {
body_index: 0,
run_index: 1,
},
},
"Reviewer",
None,
"note",
)
.unwrap();
}
document.to_bytes().unwrap()
}
assert_eq!(build(false), build(true));
let package = OpcPackage::from_reader(Cursor::new(build(false))).unwrap();
let relationships = package.get_part_rels("/word/document.xml").unwrap();
assert_eq!(
relationships.get_by_id("rId40").unwrap().rel_type,
"urn:producer"
);
assert!(
relationships
.get_by_type(rel_types::NUMBERING)
.unwrap()
.id
.starts_with("rId")
);
assert!(
relationships
.get_by_type(rel_types::IMAGE)
.unwrap()
.id
.starts_with("rId")
);
}
#[test]
fn deferred_bundle_facades_panic_before_live_mutation_on_exhaustion() {
fn document_relationship_exhausted(_base: &str) -> Document {
let mut source = Document::new_with_profile(WordCreationProfile::Minimal(
WordPackageClass::Document,
));
let mut package =
OpcPackage::from_reader(Cursor::new(source.to_bytes().unwrap())).unwrap();
package
.get_or_create_part_rels("/word/document.xml")
.items
.retain(|relationship| relationship.rel_type != rel_types::STYLES);
package
.get_or_create_part_rels("/word/document.xml")
.add_with_id(
&format!("rId{}", u32::MAX),
"urn:exhaustion",
"unchanged.bin",
);
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
Document::from_bytes(bytes.get_ref()).unwrap()
}
fn package_bytes(document: &Document) -> Vec<u8> {
let mut bytes = Cursor::new(Vec::new());
document.package.write_to(&mut bytes).unwrap();
bytes.into_inner()
}
for (base, mutation) in [
(
"rdocxNumbering",
(|document: &mut Document| {
document.add_bullet_list_item("item", 0);
}) as fn(&mut Document),
),
("rdocxNumbering", |document| {
document.add_numbered_list_item("item", 0);
}),
("rdocxNumbering", |document| {
document.add_list_definition(&[ListLevel::decimal()]);
}),
("rdocxStyles", |document| {
document
.add_style(StyleBuilder::paragraph("Custom", "Custom"))
.unwrap();
}),
("rdocxFootnotes", |document| {
document.add_footnote("note");
}),
] {
let mut document = document_relationship_exhausted(base);
let before = package_bytes(&document);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
mutation(&mut document);
}));
assert!(result.is_err());
assert_eq!(package_bytes(&document), before);
assert_eq!(document.paragraph_count(), 0);
assert!(document.numbering.is_none());
assert!(document.footnotes().is_empty());
assert!(document.style("Custom").is_none());
}
for mutation in [
(|document: &mut Document| document.set_title("Title")) as fn(&mut Document),
|document| document.set_author("Author"),
|document| document.set_subject("Subject"),
|document| document.set_keywords("Keywords"),
] {
let mut document = document_relationship_exhausted("rdocxUnused");
document.package.package_rels.add_with_id(
&format!("rId{}", u32::MAX),
"urn:exhaustion",
"unchanged.bin",
);
document.identifiers = DocumentIdentifiers::scan(&document.package).unwrap();
let before = package_bytes(&document);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
mutation(&mut document);
}));
assert!(result.is_err());
assert_eq!(package_bytes(&document), before);
assert!(document.core_properties.is_none());
}
}
#[test]
fn failed_save_identifier_reservation_leaves_document_unchanged() {
let mut document = Document::new();
document.add_picture(
b"save-rollback",
"rollback.png",
Length::pt(1.0),
Length::pt(1.0),
);
let before = document.to_bytes().unwrap();
let owner = document.doc_part_name.clone();
let exhausted = format!("rId{}", u32::MAX);
document
.identifiers
.preserved_relationship_ids
.entry(owner.clone())
.or_default()
.insert(exhausted.clone());
let error = document.to_bytes().unwrap_err();
assert!(
error
.to_string()
.contains("relationship id range is exhausted")
);
document
.identifiers
.preserved_relationship_ids
.get_mut(&owner)
.unwrap()
.remove(&exhausted);
assert_eq!(document.to_bytes().unwrap(), before);
}
#[test]
fn image_facades_fail_before_mutating_on_relationship_exhaustion() {
let mut source = Document::new();
let mut package = OpcPackage::from_reader(Cursor::new(source.to_bytes().unwrap())).unwrap();
package
.get_or_create_part_rels("/word/document.xml")
.add_with_id(
&format!("rId{}", u32::MAX),
"urn:exhaustion",
"unchanged.bin",
);
let mut output = Cursor::new(Vec::new());
package.write_to(&mut output).unwrap();
let source_bytes = output.into_inner();
let package_bytes = |document: &Document| {
let mut output = Cursor::new(Vec::new());
document.package.write_to(&mut output).unwrap();
output.into_inner()
};
let mut picture = Document::from_bytes(&source_bytes).unwrap();
let before = package_bytes(&picture);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
picture.add_picture(b"image", "image.png", Length::pt(1.0), Length::pt(1.0));
}));
assert!(result.is_err());
assert_eq!(package_bytes(&picture), before);
assert_eq!(picture.paragraph_count(), 0);
let mut embedded = Document::from_bytes(&source_bytes).unwrap();
let before = package_bytes(&embedded);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
embedded.embed_image(b"image", "image.png");
}));
assert!(result.is_err());
assert_eq!(package_bytes(&embedded), before);
let mut automatic = Document::from_bytes(&source_bytes).unwrap();
let before = package_bytes(&automatic);
assert!(
automatic
.add_picture_auto(super::watermark_tests::PNG, "image.png")
.is_err()
);
assert_eq!(package_bytes(&automatic), before);
assert_eq!(automatic.paragraph_count(), 0);
let mut background = Document::from_bytes(&source_bytes).unwrap();
let before = package_bytes(&background);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
background.add_background_image(b"image", "image.png");
}));
assert!(result.is_err());
assert_eq!(package_bytes(&background), before);
assert_eq!(background.paragraph_count(), 0);
let mut anchored = Document::from_bytes(&source_bytes).unwrap();
let before = package_bytes(&anchored);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
anchored.add_anchored_image(
b"image",
"image.png",
Length::pt(1.0),
Length::pt(1.0),
true,
);
}));
assert!(result.is_err());
assert_eq!(package_bytes(&anchored), before);
assert_eq!(anchored.paragraph_count(), 0);
}
#[test]
fn background_and_anchored_images_roll_back_drawing_id_exhaustion() {
fn assert_rollback(mutation: impl FnOnce(&mut Document)) {
let mut document = Document::new();
document.identifiers.drawing_ids.insert(u32::MAX);
let before = document.clone_for_staging();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
mutation(&mut document);
}));
assert!(result.is_err());
assert_eq!(document.document, before.document);
assert_eq!(document.package.parts, before.package.parts);
assert_eq!(
document.package.part_rels.len(),
before.package.part_rels.len()
);
}
assert_rollback(|document| {
document.add_background_image(b"image", "image.png");
});
assert_rollback(|document| {
document.add_anchored_image(
b"image",
"image.png",
Length::pt(1.0),
Length::pt(1.0),
false,
);
});
let mut document = Document::new();
let body = document.stories().unwrap()[0].clone();
document.identifiers.drawing_ids.insert(u32::MAX);
let before = document.clone_for_staging();
let result = document.add_picture_with_options(
&body,
super::watermark_tests::PNG,
"image.png",
PictureOptions {
width: Length::pt(1.0),
height: Length::pt(1.0),
crop: None,
anchor: None,
name: None,
description: None,
title: None,
decorative: false,
},
);
assert!(result.is_err());
assert_eq!(document.document, before.document);
assert_eq!(document.package.parts, before.package.parts);
assert_eq!(
document.package.part_rels.len(),
before.package.part_rels.len()
);
}
#[test]
fn gapped_imported_bookmarks_keep_lowest_free_allocation() {
let mut source = Document::new();
source.add_paragraph("zero");
source.add_paragraph("two");
for (index, id) in [(0, 0), (1, 2)] {
let BodyContent::Paragraph(paragraph) = &mut source.document.body.content[index] else {
panic!("paragraph");
};
assert!(paragraph.insert_bookmark_start(0, id, &format!("b{id}")));
assert!(paragraph.insert_bookmark_end(1, id));
}
let mut reopened = Document::from_bytes(&source.to_bytes().unwrap()).unwrap();
reopened.add_paragraph("one");
let id = reopened
.add_bookmark(
"b1",
crate::comments::RunRange {
start: crate::comments::RunPosition {
body_index: 2,
run_index: 0,
},
end: crate::comments::RunPosition {
body_index: 2,
run_index: 1,
},
},
)
.unwrap();
assert_eq!(id, 1);
}
#[test]
fn toc_reservation_observes_nested_bookmark_ids() {
let mut document = Document::new();
{
let mut table = document.add_table(1, 1);
let mut cell = table.cell(0, 0).unwrap();
cell.set_text("nested");
}
let BodyContent::Table(table) = &mut document.document.body.content[0] else {
panic!("table");
};
let CellContent::Paragraph(paragraph) = &mut table.rows[0].cells[0].content[0] else {
panic!("paragraph");
};
assert!(paragraph.insert_bookmark_start(0, 100, "nested"));
assert!(paragraph.insert_bookmark_end(1, 100));
document.add_paragraph("Chapter").style("Heading1");
document.insert_toc(0, 1).unwrap();
let toc = document
.bookmarks()
.into_iter()
.find(|bookmark| bookmark.name() == Some("_Toc1"))
.unwrap();
assert_eq!(toc.id(), Some(0));
}
#[test]
fn cell_and_body_pictures_receive_distinct_document_order_ids() {
const IMAGE: &[u8] = b"not-a-real-png";
let mut document = Document::new();
document.add_picture(IMAGE, "body.png", Length::pt(1.0), Length::pt(1.0));
let relationship = document.embed_image(IMAGE, "cell.png");
document.add_table(1, 1).cell(0, 0).unwrap().add_picture(
&relationship,
Length::pt(1.0),
Length::pt(1.0),
);
let package = OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
let xml = std::str::from_utf8(package.get_part("/word/document.xml").unwrap()).unwrap();
assert_eq!(xml.matches(r#"wp:docPr id="1""#).count(), 1, "{xml}");
assert_eq!(xml.matches(r#"wp:docPr id="2""#).count(), 1, "{xml}");
}
#[test]
fn hyperlink_and_drawing_relationships_follow_final_paragraph_order() {
fn build(reverse_allocation: bool) -> Vec<u8> {
let mut document = Document::new();
let (hyperlink, picture) = if reverse_allocation {
let picture = document.embed_image(b"image", "image.png");
let hyperlink = document.add_hyperlink_relationship("https://example.com");
(hyperlink, picture)
} else {
let hyperlink = document.add_hyperlink_relationship("https://example.com");
let picture = document.embed_image(b"image", "image.png");
(hyperlink, picture)
};
let mut paragraph = document.add_paragraph("");
paragraph.add_hyperlink("link", &hyperlink);
paragraph.add_picture(&picture, Length::pt(1.0), Length::pt(1.0));
document.to_bytes().unwrap()
}
assert_eq!(build(false), build(true));
}
#[test]
fn current_unpreserved_relationships_join_semantic_canonicalization() {
fn add_theme(document: &mut Document, id: &str) {
document.package.set_part(
DEFAULT_THEME_PART,
oxml_drawing::theme::OFFICE_DEFAULT_XML.as_bytes().to_vec(),
);
document
.package
.content_types
.add_override(DEFAULT_THEME_PART, content_types::THEME);
document
.package
.get_or_create_part_rels("/word/document.xml")
.add_with_id(id, rel_types::THEME, "theme/theme1.xml");
}
let mut authored =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
authored
.add_chart(
ChartKind::Bar,
Length::inches(2.0),
Length::inches(1.0),
&f158_chart_data(1),
)
.unwrap();
add_theme(&mut authored, "rId40");
let reopened = Document::from_bytes(&authored.to_bytes().unwrap()).unwrap();
let relationships = reopened
.package
.get_part_rels("/word/document.xml")
.unwrap();
assert_eq!(
relationships.get_by_type(rel_types::CHART).unwrap().id,
"rId1"
);
assert_eq!(
relationships.get_by_type(rel_types::THEME).unwrap().id,
"rId2"
);
let mut producer =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
let mut producer = Document::from_bytes(&producer.to_bytes().unwrap()).unwrap();
add_theme(&mut producer, "rId9");
let mut producer_bytes = Cursor::new(Vec::new());
producer.package.write_to(&mut producer_bytes).unwrap();
let mut producer = Document::from_bytes(producer_bytes.get_ref()).unwrap();
producer
.add_chart(
ChartKind::Bar,
Length::inches(2.0),
Length::inches(1.0),
&f158_chart_data(1),
)
.unwrap();
let reopened = Document::from_bytes(&producer.to_bytes().unwrap()).unwrap();
let relationships = reopened
.package
.get_part_rels("/word/document.xml")
.unwrap();
assert_eq!(
relationships.get_by_type(rel_types::THEME).unwrap().id,
"rId9"
);
assert_eq!(
relationships.get_by_type(rel_types::CHART).unwrap().id,
"rId10"
);
let mut opaque =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
let relationships = opaque.package.get_or_create_part_rels("/word/document.xml");
relationships.add_with_id("rId41", "urn:producer", "opaque.bin");
relationships
.items
.push(oxml_opc::relationship::Relationship {
id: "rId40".to_owned(),
rel_type: rel_types::HYPERLINK.to_owned(),
target: "https://example.com/opaque".to_owned(),
target_mode: Some("External".to_owned()),
});
let reopened = Document::from_bytes(&opaque.to_bytes().unwrap()).unwrap();
let relationships = reopened
.package
.get_part_rels("/word/document.xml")
.unwrap();
assert!(relationships.items.iter().any(|relationship| {
relationship.id == "rId1"
&& relationship.rel_type == "urn:producer"
&& relationship.target == "opaque.bin"
&& relationship_is_internal(relationship)
}));
assert!(relationships.items.iter().any(|relationship| {
relationship.id == "rId2"
&& relationship.rel_type == rel_types::HYPERLINK
&& relationship.target == "https://example.com/opaque"
&& relationship.target_mode.as_deref() == Some("External")
}));
}
#[test]
fn opaque_xml_relationship_ids_are_fixed_across_opposite_allocation_order() {
const RAW: &[u8] = br#"<producer:item xmlns:producer="urn:producer" r:embed="rId40"/>"#;
fn build(raw_relationship_first: bool) -> Vec<u8> {
let mut document = Document::new_with_profile(WordCreationProfile::Minimal(
WordPackageClass::Document,
));
document
.body_namespace_bindings
.push(("xmlns:r".to_owned(), drawing_ns::R.to_owned()));
let add_raw_relationship = |document: &mut Document| {
document
.package
.set_part("/word/opaque.bin", b"opaque relationship target".to_vec());
document
.package
.content_types
.add_default("bin", "application/octet-stream");
document
.package
.get_or_create_part_rels("/word/document.xml")
.add_with_id("rId40", "urn:producer:opaque", "opaque.bin");
};
if raw_relationship_first {
add_raw_relationship(&mut document);
}
let hyperlink = document.add_hyperlink_relationship("https://example.com");
if !raw_relationship_first {
add_raw_relationship(&mut document);
}
document.add_paragraph("").add_hyperlink("link", &hyperlink);
document
.document
.body
.content
.push(BodyContent::RawXml(RAW.to_vec()));
document.to_bytes().unwrap()
}
let raw_first = build(true);
let modeled_first = build(false);
assert_eq!(raw_first, modeled_first);
let package = OpcPackage::from_reader(Cursor::new(raw_first)).unwrap();
let relationships = package
.get_part_rels("/word/document.xml")
.expect("document relationships");
assert!(relationships.items.iter().any(|relationship| {
relationship.id == "rId40"
&& relationship.rel_type == "urn:producer:opaque"
&& relationship.target == "opaque.bin"
}));
assert!(relationships.items.iter().any(|relationship| {
relationship.id == "rId1" && relationship.rel_type == rel_types::HYPERLINK
}));
assert!(
package
.get_part("/word/document.xml")
.is_some_and(|xml| xml.windows(RAW.len()).any(|window| window == RAW))
);
}
#[test]
fn paragraph_local_binding_covers_unsupported_run_child_relationship() {
const RAW: &[u8] = br#"<w:custom localRel:id="rId40"/>"#;
let xml = format!(
r#"<w:document xmlns:w="{WORD_NAMESPACE}"><w:body><w:p xmlns:localRel="{}"><w:r><w:t>producer</w:t>{}</w:r></w:p><w:sectPr/></w:body></w:document>"#,
drawing_ns::R,
std::str::from_utf8(RAW).unwrap(),
);
let seed =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
let mut package = seed.package;
package.set_part("/word/document.xml", xml.into_bytes());
package.set_part("/word/opaque.bin", b"opaque relationship target".to_vec());
package
.content_types
.add_default("bin", "application/octet-stream");
package
.get_or_create_part_rels("/word/document.xml")
.add_with_id("rId40", "urn:producer:opaque", "opaque.bin");
let mut document = Document::from_package(package).unwrap();
let hyperlink = document.add_hyperlink_relationship("https://example.com/modeled");
document
.add_paragraph("")
.add_hyperlink("modeled", &hyperlink);
let saved = OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
let relationships = saved.get_part_rels("/word/document.xml").unwrap();
assert!(relationships.get_by_id("rId40").is_some());
assert_eq!(
relationships.get_by_type(rel_types::HYPERLINK).unwrap().id,
"rId41"
);
assert!(
saved
.get_part("/word/document.xml")
.is_some_and(|xml| xml.windows(RAW.len()).any(|window| window == RAW))
);
}
#[test]
fn word_main_part_and_relationship_owner_resolve_case_equivalent_spelling() {
let mut document =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
let mut package =
OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
let document_xml = package.parts.remove("/word/document.xml").unwrap();
package
.parts
.insert("/WORD/DOCUMENT.XML".to_owned(), document_xml);
let relationships = package.part_rels.remove("/word/document.xml").unwrap();
package
.part_rels
.insert("/WORD/DOCUMENT.XML".to_owned(), relationships);
package.set_part("/word/occupied.bin", b"occupied".to_vec());
package
.content_types
.add_default("bin", "application/octet-stream");
package
.get_or_create_part_rels("/word/document.xml")
.add_with_id("rId40", "urn:producer:occupied", "occupied.bin");
let mut reopened = Document::from_package(package).unwrap();
assert!(
reopened
.identifiers
.relationship_ids
.contains_key("/word/document.xml")
);
assert!(
!reopened
.identifiers
.relationship_ids
.contains_key("/WORD/DOCUMENT.XML")
);
let hyperlink = reopened.add_hyperlink_relationship("https://example.com/mixed-case");
assert_eq!(hyperlink, "rId41");
reopened
.add_paragraph("")
.add_hyperlink("mixed case", &hyperlink);
let saved = OpcPackage::from_reader(Cursor::new(reopened.to_bytes().unwrap())).unwrap();
assert!(saved.parts.contains_key("/WORD/DOCUMENT.XML"));
assert!(saved.part_rels.contains_key("/WORD/DOCUMENT.XML"));
let relationships = saved.get_part_rels("/word/document.xml").unwrap();
assert!(relationships.get_by_id("rId40").is_some());
assert_eq!(
relationships.get_by_type(rel_types::HYPERLINK).unwrap().id,
"rId41"
);
}
#[test]
fn custom_xml_identity_decoys_do_not_block_open() {
let mut document = Document::new();
let bytes = document.to_bytes().unwrap();
let mut package = OpcPackage::from_reader(Cursor::new(bytes)).unwrap();
package.set_part(
"/customXml/item1.xml",
br#"<x:root xmlns:x="urn:custom" xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing"><w:bookmarkStart w:id="0"/><w:bookmarkStart w:id="0"/><wp:docPr id="bad">"#.to_vec(),
);
let mut output = Cursor::new(Vec::new());
package.write_to(&mut output).unwrap();
Document::from_bytes(&output.into_inner()).expect("opaque custom XML is not a story");
}
#[test]
fn inserted_numbering_is_registered_before_later_authoring() {
let mut source = Document::new();
let source_id = source.add_list_definition(&[ListLevel::decimal()]);
source.add_paragraph("source").set_numbering(source_id, 0);
let mut destination = Document::new();
destination.insert_document(0, &source);
let inserted_id = destination
.numbering
.as_ref()
.unwrap()
.nums
.first()
.unwrap()
.num_id;
let authored_id = destination.add_list_definition(&[ListLevel::bullet()]);
assert_ne!(inserted_id, authored_id);
}
#[test]
fn unsupported_names_come_from_the_accepted_parser_event() {
assert_eq!(
raw_element_names(b"<?producer keep?><x:item xmlns:x=\"urn:test\"/>")
.as_ref()
.map(|(qualified, local)| (qualified.as_str(), local.as_str())),
Some(("x:item", "item")),
);
}
#[test]
fn namespace_declaration_decoding_keeps_xml_error_classification() {
let valid = format!(
"<w:document xmlns:w=\"{WORD_NAMESPACE}\" xmlns:x=\"urn:plain\" \
xmlns:y=\"urn:a&b\"><w:body/></w:document>"
);
let scopes = document_namespace_scopes(valid.as_bytes()).unwrap();
assert!(
scopes
.root_declarations
.contains(&("xmlns:x".to_owned(), "urn:plain".to_owned()))
);
assert!(
scopes
.root_declarations
.contains(&("xmlns:y".to_owned(), "urn:a&b".to_owned()))
);
let malformed = format!(
"<w:document xmlns:w=\"{WORD_NAMESPACE}\" \
xmlns:x=\"urn:&bad;\"><w:body/></w:document>"
);
assert!(matches!(
document_namespace_scopes(malformed.as_bytes()),
Err(Error::Oxml(_))
));
}
fn minimal_chart_workbook() -> Workbook {
Workbook::new(
"Sheet1",
vec![
Column::Text {
header: "Category".to_owned(),
values: vec!["North".to_owned(), "South".to_owned(), "West".to_owned()],
},
Column::Number {
header: "Revenue".to_owned(),
values: vec![12.5, 19.0, 14.25],
number_format: None,
},
],
)
.expect("valid chart workbook")
}
fn minimal_typed_chart() -> CT_ChartSpace {
let values = NumericData::new(
"Sheet1!$B$2:$B$4".to_owned(),
"General".to_owned(),
vec![12.5, 19.0, 14.25],
)
.expect("valid values");
let mut series = Series::new(0, 0, values);
series.name = Some(
StringRef::new("Sheet1!$B$1".to_owned(), vec!["Revenue".to_owned()])
.expect("valid series name"),
);
series.categories = Some(AxisData::String(
StringRef::new(
"Sheet1!$A$2:$A$4".to_owned(),
vec!["North".to_owned(), "South".to_owned(), "West".to_owned()],
)
.expect("valid categories"),
));
let category_axis = AxisId::new(48_650_112).expect("valid category axis ID");
let value_axis = AxisId::new(48_672_768).expect("valid value axis ID");
let plot = Plot::bar(
BarDirection::Column,
BarGrouping::Clustered,
vec![series],
[category_axis, value_axis],
)
.expect("valid bar plot");
let axes = vec![
Axis::new(
AxisKind::Category,
category_axis,
AxisPosition::Bottom,
value_axis,
),
Axis::new(
AxisKind::Value,
value_axis,
AxisPosition::Left,
category_axis,
),
];
let mut chart = CT_ChartSpace::from_xml(
format!(
r#"<c:chartSpace xmlns:c="{}" xmlns:a="{}" xmlns:r="{}"><c:chart><c:plotArea/></c:chart></c:chartSpace>"#,
oxml_chart::C_NS,
oxml_chart::A_NS,
oxml_chart::R_NS,
)
.as_bytes(),
)
.expect("valid chart shell");
chart.chart.auto_title_deleted = true;
chart.chart.plot_area = CT_PlotArea::new(vec![plot], axes).expect("valid plot area");
chart
}
#[cfg(feature = "agile-encryption")]
#[test]
fn native_encrypted_save_round_trips_without_live_package_mutation() {
let mut document = Document::new();
document.add_paragraph("F-170 encrypted round trip");
let original_parts = document.package.parts.clone();
let original_relationships = document.package.package_rels.to_xml().unwrap();
let error = document.to_encrypted_bytes("").unwrap_err();
assert!(matches!(
error,
Error::Opc(oxml_opc::OpcError::InvalidPassword)
));
assert_eq!(document.package.parts, original_parts);
assert_eq!(
document.package.package_rels.to_xml().unwrap(),
original_relationships
);
let encrypted = document.to_encrypted_bytes("rdocx-f170").unwrap();
let reopened = Document::from_encrypted_bytes(&encrypted, "rdocx-f170").unwrap();
assert_eq!(reopened.text(), "F-170 encrypted round trip\n");
assert_eq!(document.package.parts, original_parts);
assert_eq!(
document.package.package_rels.to_xml().unwrap(),
original_relationships
);
let path = std::env::temp_dir().join(format!(
"rdocx-f170-atomic-save-{}.docx",
std::process::id()
));
fs::write(&path, b"existing destination").unwrap();
assert!(document.save_encrypted(&path, "").is_err());
assert_eq!(fs::read(&path).unwrap(), b"existing destination");
document.save_encrypted(&path, "rdocx-f170").unwrap();
let saved = fs::read(&path).unwrap();
assert_eq!(
Document::from_encrypted_bytes(&saved, "rdocx-f170")
.unwrap()
.text(),
"F-170 encrypted round trip\n"
);
fs::remove_file(path).unwrap();
}
#[cfg(feature = "agile-encryption")]
#[test]
#[ignore = "requires pinned Microsoft Word 16.104 and human password evidence"]
fn word_opens_the_written_agile_document() {
let plist = "/Applications/Microsoft Word.app/Contents/Info.plist";
let version = Command::new("plutil")
.args(["-extract", "CFBundleShortVersionString", "raw", plist])
.output()
.unwrap();
assert!(version.status.success());
assert_eq!(
String::from_utf8_lossy(&version.stdout).trim(),
WORD_VERSION
);
let build = Command::new("plutil")
.args(["-extract", "CFBundleVersion", "raw", plist])
.output()
.unwrap();
assert!(build.status.success());
assert_eq!(String::from_utf8_lossy(&build.stdout).trim(), WORD_BUILD);
let mut document = Document::new();
document.add_paragraph("F-170 Microsoft Word encryption oracle");
let path = Path::new("/private/tmp/F-170-word-16.104-encrypted.docx");
document.save_encrypted(path, "rdocx-f170").unwrap();
assert_eq!(
std::env::var("RDOCX_F170_WORD_CORRECT_PASSWORD").as_deref(),
Ok("opened"),
"open {} in Word {WORD_VERSION} build {WORD_BUILD} with password rdocx-f170, then rerun with RDOCX_F170_WORD_CORRECT_PASSWORD=opened",
path.display()
);
assert_eq!(
std::env::var("RDOCX_F170_WORD_WRONG_PASSWORD").as_deref(),
Ok("rejected"),
"open {} with a wrong password, then rerun with RDOCX_F170_WORD_WRONG_PASSWORD=rejected",
path.display()
);
}
fn document_with_minimal_chart() -> Document {
let mut document =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
document
.add_chart_package(
ChartPackageSource::Typed {
chart: &minimal_typed_chart(),
workbook: &minimal_chart_workbook(),
},
Length::inches(5.0),
Length::inches(3.0),
)
.expect("assemble chart package");
document
}
fn reset_layout_invocations() {
LAYOUT_INVOCATIONS.set(0);
}
fn layout_invocations() -> usize {
LAYOUT_INVOCATIONS.get()
}
fn caller_only_font() -> (&'static str, Vec<u8>) {
const FAMILY: &str = "Callira";
const SOURCE: &[u8] =
include_bytes!("../../oxml-layout/fonts/Carlito-Regular.ttf").as_slice();
fn replace_all_same_length(data: &mut [u8], from: &[u8], to: &[u8]) -> usize {
assert_eq!(from.len(), to.len());
let mut replaced = 0;
let mut offset = 0;
while let Some(index) = data[offset..]
.windows(from.len())
.position(|window| window == from)
{
let start = offset + index;
data[start..start + from.len()].copy_from_slice(to);
offset = start + from.len();
replaced += 1;
}
replaced
}
let mut bytes = SOURCE.to_vec();
let ascii = replace_all_same_length(&mut bytes, b"Carlito", FAMILY.as_bytes());
let source_utf16 = "Carlito"
.encode_utf16()
.flat_map(u16::to_be_bytes)
.collect::<Vec<_>>();
let family_utf16 = FAMILY
.encode_utf16()
.flat_map(u16::to_be_bytes)
.collect::<Vec<_>>();
let utf16 = replace_all_same_length(&mut bytes, &source_utf16, &family_utf16);
assert!(ascii > 0 && utf16 > 0, "font family records were renamed");
assert!(
oxml_layout::bundled_fonts::bundled_font_data()
.iter()
.all(|(family, bundled)| *family != FAMILY && *bundled != bytes),
"the caller-only font must not match bundled family names or bytes"
);
(FAMILY, bytes)
}
#[test]
fn fx195_native_selected_pages_and_revision_options_keep_caller_bundle() {
let (family, bytes) = caller_only_font();
let mut document = Document::new();
document
.add_paragraph("caller first")
.add_run("iiiiWWWW")
.font(family);
let revision_xml = format!(
r#"<w:p xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:pPr><w:pageBreakBefore/></w:pPr><w:r><w:rPr><w:rFonts w:ascii="{family}" w:hAnsi="{family}"/></w:rPr><w:t>WWWWiiii</w:t></w:r><w:ins w:id="195" w:author="Caller control"><w:r><w:rPr><w:rFonts w:ascii="{family}" w:hAnsi="{family}"/></w:rPr><w:t>INSERTED</w:t></w:r></w:ins><w:del w:id="196" w:author="Caller control"><w:r><w:rPr><w:rFonts w:ascii="{family}" w:hAnsi="{family}"/></w:rPr><w:delText>DELETED</w:delText></w:r></w:del></w:p>"#
);
let revised = CT_P::from_xml_fragment(revision_xml.as_bytes()).unwrap();
assert_eq!(revised.revisions.len(), 2, "both revisions must be typed");
assert_eq!(
revised.revisions[0].2.kind(),
rdocx_oxml::revision::RevisionKind::Insertion
);
assert_eq!(
revised.revisions[1].2.kind(),
rdocx_oxml::revision::RevisionKind::Deletion
);
assert_eq!(revised.accepted_text(), "WWWWiiiiINSERTED");
document
.document
.body
.content
.push(BodyContent::Paragraph(revised));
let source_before = document.document.to_xml().unwrap();
let source_xml = std::str::from_utf8(&source_before).unwrap();
assert!(source_xml.contains("<w:ins ") && source_xml.contains("<w:del "));
assert!(source_xml.contains("INSERTED") && source_xml.contains("DELETED"));
let before = document.to_bytes().unwrap();
let inputs = [(family, bytes.as_slice())];
let mut rendered_views = Vec::new();
for options in [
RenderOptions::default(),
RenderOptions {
revision_view: rdocx_layout::RevisionView::Tracked,
revision_style: rdocx_layout::RevisionStyle::Plain,
},
] {
reset_layout_invocations();
let layout = document
.layout_with_additional_fonts_and_options(&inputs, options)
.unwrap();
assert_eq!(layout_invocations(), 1);
assert_eq!(layout.revision_view, options.revision_view);
assert_eq!(layout.layout.pages.len(), 2);
assert!(
layout
.layout
.fonts
.iter()
.any(|font| font.data.as_ref() == bytes.as_slice())
);
let tracked = options.revision_view == rdocx_layout::RevisionView::Tracked;
let mut visible_text = String::new();
oxml_layout::walk(&layout.layout.pages[1].elements, &mut |element, _| {
if let oxml_layout::PositionedElement::Text(run) = element {
visible_text.push_str(&run.text);
let selected = layout
.layout
.fonts
.iter()
.find(|font| font.id == run.font_id)
.unwrap();
assert_eq!(selected.data.as_ref(), bytes.as_slice());
assert_eq!(selected.face_index, 0);
assert_eq!(selected.family, family);
}
});
assert_eq!(
visible_text,
if tracked {
"WWWWiiiiINSERTEDDELETED"
} else {
"WWWWiiiiINSERTED"
},
"revision selection must affect actual shaped content"
);
let mut raster_results = Vec::new();
for format in [
oxml_pdf::RasterFormat::Png {
transparent_background: false,
},
oxml_pdf::RasterFormat::Jpeg { quality: 80 },
oxml_pdf::RasterFormat::Tiff,
] {
let raster = oxml_pdf::RasterOptions { dpi: 72.0, format };
reset_layout_invocations();
let rendered = document
.render_pages_with_fonts_and_options(&[1], raster, &inputs, options)
.unwrap();
assert_eq!(
rendered,
oxml_pdf::render_pages(&layout.layout, &[1], raster).unwrap()
);
assert_eq!(layout_invocations(), 1);
raster_results.push(rendered);
assert!(
document
.render_pages_with_fonts_and_options(&[2], raster, &inputs, options)
.is_err()
);
}
let png = document
.render_page_to_png_with_fonts_and_options(1, 72.0, &inputs, options)
.unwrap();
assert_eq!(png, oxml_pdf::render_page_to_png(&layout.layout, 1, 72.0));
let svg = document
.render_page_to_svg_with_fonts_and_options(1, &inputs, options)
.unwrap();
assert_eq!(svg, crate::svg::render_page(&layout.layout, 1));
let svg_text = &svg.as_ref().unwrap().svg;
assert!(svg_text.contains("INSERTED"));
assert_eq!(svg_text.contains("DELETED"), tracked);
assert!(
document
.render_page_to_svg_with_fonts_and_options(2, &inputs, options)
.unwrap()
.is_none()
);
let bundled = document
.layout_with_fonts_and_bundled_fallback_and_options(&inputs, options)
.unwrap();
let pdf = document
.to_pdf_with_fonts_deterministic_and_options(&inputs, options)
.unwrap();
assert_eq!(
pdf,
oxml_pdf::render_to_pdf_checked(&bundled.layout).unwrap()
);
rendered_views.push((pdf, png, svg, raster_results));
assert_eq!(document.document.to_xml().unwrap(), source_before);
assert_eq!(document.to_bytes().unwrap(), before);
}
assert_ne!(
rendered_views[0].0, rendered_views[1].0,
"PDF revision view"
);
assert_ne!(
rendered_views[0].1, rendered_views[1].1,
"PNG revision view"
);
assert_ne!(
rendered_views[0].2, rendered_views[1].2,
"SVG revision view"
);
for (accepted, tracked) in rendered_views[0].3.iter().zip(&rendered_views[1].3) {
assert_ne!(
accepted, tracked,
"each selected raster format honors revisions"
);
}
let bundled = document
.layout_with_fonts_and_bundled_fallback(&inputs)
.unwrap();
let profile = oxml_pdf::PdfConformance::PdfA2b;
assert_eq!(
document
.to_pdfa_with_fonts_deterministic(&inputs, profile)
.unwrap(),
oxml_pdf::render_to_pdf_with_options(
&bundled.layout,
oxml_pdf::PdfOptions::new(profile)
)
.unwrap()
);
let first = document.to_pdf_with_fonts(&inputs).unwrap();
assert_eq!(document.to_pdf_with_fonts(&inputs).unwrap(), first);
assert_eq!(document.to_bytes().unwrap(), before);
}
#[test]
fn fx195_additional_caller_fonts_share_native_output_bundle() {
let (family, bytes) = caller_only_font();
let mut document = Document::new();
document.add_paragraph("caller geometry");
document
.paragraph_mut(0)
.unwrap()
.run_mut(0)
.expect("caller geometry fixture has a run")
.set_font(family);
let inputs = [(family, bytes.as_slice())];
let options = RenderOptions::default();
let layout = document
.layout_with_additional_fonts_and_options(&inputs, options)
.unwrap();
assert!(layout.layout.fonts.iter().any(|font| font.family == family
&& font.data.as_ref() == bytes.as_slice()
&& font.face_index == 0));
let before = document.to_bytes().unwrap();
assert_eq!(
document
.to_pdf_with_fonts_and_options(&inputs, options)
.unwrap(),
oxml_pdf::render_to_pdf_checked(&layout.layout).unwrap()
);
assert_eq!(
document
.render_page_to_png_with_fonts_and_options(0, 72.0, &inputs, options)
.unwrap(),
oxml_pdf::render_page_to_png(&layout.layout, 0, 72.0)
);
assert_eq!(
document
.render_all_pages_with_fonts_and_options(72.0, &inputs, options)
.unwrap(),
oxml_pdf::render_all_pages(&layout.layout, 72.0)
);
assert_eq!(
document
.render_page_to_svg_with_fonts_and_options(0, &inputs, options)
.unwrap(),
crate::svg::render_page(&layout.layout, 0)
);
assert!(
document
.render_page_to_png_with_fonts_and_options(usize::MAX, 72.0, &inputs, options)
.unwrap()
.is_none()
);
assert_eq!(document.to_bytes().unwrap(), before);
assert_eq!(
document.to_pdf_with_fonts(&[]).unwrap(),
document.to_pdf().unwrap()
);
}
#[test]
fn body_items_preserve_paragraph_table_control_and_raw_order() {
let mut doc = Document::new();
doc.add_paragraph("first");
doc.add_table(1, 1);
doc.document
.body
.content
.push(BodyContent::RawXml(b"<w:custom/>".to_vec()));
let mut reader = quick_xml::Reader::from_reader(
br#"<w:sdt xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:sdtContent><w:p><w:r><w:t>inside</w:t></w:r></w:p></w:sdtContent></w:sdt>"#
.as_slice(),
);
let mut buffer = Vec::new();
let control = match reader.read_event_into(&mut buffer).unwrap() {
Event::Start(start) => CT_Sdt::from_xml(&mut reader, &start).unwrap(),
event => panic!("expected content control start, got {event:?}"),
};
doc.document
.body
.content
.push(BodyContent::ContentControl(control));
doc.add_paragraph("last");
let items = doc
.body_items()
.map(|item| match item {
BodyItemRef::Paragraph(paragraph) => format!("paragraph:{}", paragraph.text()),
BodyItemRef::Table(_) => "table".to_owned(),
BodyItemRef::ContentControl(control) => format!("control:{}", control.text()),
BodyItemRef::UnsupportedXml(raw) => {
format!("raw:{}", std::str::from_utf8(raw).unwrap())
}
})
.collect::<Vec<_>>();
assert_eq!(
items,
[
"paragraph:first",
"table",
"raw:<w:custom/>",
"control:inside",
"paragraph:last",
]
);
}
#[test]
fn reader_reports_background_and_section_completeness_facts() {
let mut doc = Document::new();
assert!(!doc.has_document_background());
assert!(doc.has_section_layout_formatting());
assert!(!doc.has_unmodeled_section_properties());
doc.document.background_xml = Some(b"<w:background/>".to_vec());
doc.document
.body
.sect_pr
.as_mut()
.expect("default section properties")
.extra_xml
.push(b"<w:printerSettings r:id=\"rId1\"/>".to_vec());
assert!(doc.has_document_background());
assert!(doc.has_unmodeled_section_properties());
}
#[test]
fn rendering_all_pages_performs_one_layout() {
let mut doc = Document::new();
doc.add_paragraph("Page 1");
for page in 2..=20 {
doc.add_paragraph(&format!("Page {page}"))
.page_break_before(true);
}
reset_layout_invocations();
for page_index in 0..20 {
assert!(
doc.render_page_to_png_deterministic(page_index, 1.0)
.expect("deterministic layout should succeed")
.is_some(),
"page {page_index} should exist"
);
}
assert_eq!(layout_invocations(), 1);
}
#[test]
fn tracked_option_layouts_are_not_cached() {
let mut doc = Document::new();
doc.add_paragraph("tracked view");
let options = RenderOptions {
revision_view: rdocx_layout::RevisionView::Tracked,
revision_style: rdocx_layout::RevisionStyle::Plain,
};
reset_layout_invocations();
doc.render_page_to_png_deterministic_with_options(0, 1.0, options)
.unwrap();
doc.render_page_to_png_deterministic_with_options(0, 1.0, options)
.unwrap();
assert_eq!(layout_invocations(), 2);
}
#[test]
fn document_mutation_invalidates_cached_layout() {
let mut doc = Document::new();
doc.add_paragraph("Before mutation");
reset_layout_invocations();
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
assert_eq!(layout_invocations(), 1);
doc.add_paragraph("After mutation");
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
assert_eq!(layout_invocations(), 2);
}
#[test]
fn field_update_batch_invalidates_cached_layout_once() {
let mut doc = Document::new();
let mut paragraph = CT_P::new();
paragraph.runs.push(CT_R {
properties: None,
content: vec![RunContent::Field(Field::new("PAGE", "4"))],
extra_xml: Vec::new(),
extra_xml_positions: Vec::new(),
alt_drawings: Vec::new(),
});
doc.document
.body
.content
.push(BodyContent::Paragraph(paragraph));
reset_layout_invocations();
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
assert_eq!(layout_invocations(), 1);
assert_eq!(
doc.update_fields(&FieldEvaluationContext::default())
.unwrap(),
1
);
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
assert_eq!(layout_invocations(), 2);
}
#[test]
fn mutable_accessor_invalidates_cached_layout() {
let mut doc = Document::new();
doc.add_paragraph("Before wrapper mutation");
reset_layout_invocations();
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
assert_eq!(layout_invocations(), 1);
doc.paragraph_mut(0)
.expect("paragraph should exist")
.add_run(" changed");
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
assert_eq!(layout_invocations(), 2);
let mut table = doc.add_table(1, 1);
table
.cell(0, 0)
.expect("cell should exist")
.set_text("table mutation");
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
assert_eq!(layout_invocations(), 3);
}
#[test]
fn immutable_run_accessors_preserve_cached_layout() {
let mut doc = Document::new();
doc.add_paragraph("Before immutable access")
.add_run(" remains cached");
reset_layout_invocations();
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
assert_eq!(layout_invocations(), 1);
let paragraph = doc.paragraph(0).expect("paragraph should exist");
assert_eq!(paragraph.run_count(), 2);
assert_eq!(
paragraph.run(1).expect("run should exist").text(),
" remains cached"
);
assert!(paragraph.run(2).is_none());
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
assert_eq!(layout_invocations(), 1);
}
#[test]
fn font_modes_use_isolated_layout_caches() {
let mut doc = Document::new();
doc.add_paragraph("Font mode isolation");
reset_layout_invocations();
doc.render_page_to_png(0, 1.0).unwrap();
doc.render_page_to_png(0, 1.0).unwrap();
assert!(doc.layout_page(0).unwrap().is_some());
assert!(doc.layout_page(usize::MAX).unwrap().is_none());
assert_eq!(doc.render_all_pages(1.0).unwrap().len(), 1);
assert!(!doc.to_pdf().unwrap().is_empty());
assert_eq!(layout_invocations(), 1);
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
assert_eq!(layout_invocations(), 2);
doc.render_page_to_png(0, 1.0).unwrap();
doc.render_page_to_png_deterministic(0, 1.0).unwrap();
assert_eq!(layout_invocations(), 2);
let (family, font_data) = oxml_layout::bundled_fonts::bundled_font_data()[0];
doc.to_pdf_with_fonts(&[(family, font_data)]).unwrap();
doc.to_pdf_with_fonts(&[(family, font_data)]).unwrap();
assert_eq!(layout_invocations(), 4);
}
#[test]
fn officemath_document_settings_reach_pdf_and_raster_backends() {
fn has_direct_rule(element: &oxml_layout::PositionedElement) -> bool {
matches!(element, oxml_layout::PositionedElement::Line { .. })
}
fn fraction_group_mut(
elements: &mut [oxml_layout::PositionedElement],
) -> Option<&mut oxml_layout::GroupElement> {
for element in elements {
match element {
oxml_layout::PositionedElement::Group(group) => {
if group.children.iter().any(has_direct_rule) {
return Some(group);
}
if let Some(found) = fraction_group_mut(&mut group.children) {
return Some(found);
}
}
oxml_layout::PositionedElement::MarkedContent { children, .. } => {
if let Some(found) = fraction_group_mut(children) {
return Some(found);
}
}
_ => {}
}
}
None
}
let word = rdocx_oxml::namespace::W_NS;
let math = rdocx_oxml::namespace::M_NS;
let document_xml = format!(
r#"<w:document xmlns:w="{word}" xmlns:m="{math}"><w:body><w:p><w:r><w:t>before</w:t></w:r><m:oMath><m:f><m:num><m:r><m:t>1</m:t></m:r></m:num><m:den><m:r><m:t>2</m:t></m:r></m:den></m:f></m:oMath><w:r><w:t>after</w:t></w:r></w:p><w:sectPr/></w:body></w:document>"#
);
let settings_xml = format!(
r#"<w:settings xmlns:w="{word}" xmlns:m="{math}"><m:mathPr><m:mathFont m:val="Caladea"/><m:jc m:val="right"/><m:preSp m:val="80"/><m:postSp m:val="120"/></m:mathPr></w:settings>"#
);
let mut document = Document::new();
document.document =
CT_Document::from_xml(document_xml.as_bytes()).expect("document parses");
document.settings =
Some(CT_Settings::from_xml(settings_xml.as_bytes()).expect("math settings parse"));
let input = document.build_layout_input().unwrap();
assert_eq!(
input
.math_properties
.as_ref()
.and_then(|properties| properties.math_font.as_deref()),
Some("Caladea")
);
let pdf = document
.to_pdf_deterministic()
.expect("OfficeMath PDF render");
assert!(pdf.starts_with(b"%PDF-"));
let png = document
.render_page_to_png_deterministic(0, 150.0)
.expect("OfficeMath raster render")
.expect("OfficeMath page exists");
assert!(png.starts_with(b"\x89PNG\r\n\x1a\n"));
let mut mutated = document
.cached_deterministic_layout()
.expect("deterministic OfficeMath layout")
.layout
.clone();
let page = Arc::make_mut(&mut mutated.pages[0]);
let fraction = fraction_group_mut(&mut page.elements).expect("rendered fraction group");
let original_y = fraction.transform.f;
fraction.transform.f += 1.01;
assert!((fraction.transform.f - original_y).abs() > 1.0);
let mutated_png = oxml_pdf::render_page_to_png(&mutated, 0, 150.0)
.expect("mutated OfficeMath page renders");
assert_ne!(png, mutated_png, "1.01 point page mutation must be visible");
}
#[test]
fn native_word_pdfa_method_selects_the_requested_profile() {
let mut doc = Document::new();
doc.add_paragraph("Archival Word document");
for (profile, part) in [
(oxml_pdf::PdfConformance::PdfA2b, "2"),
(oxml_pdf::PdfConformance::PdfA3b, "3"),
] {
let pdf = doc.to_pdfa_deterministic(profile).unwrap();
assert!(
String::from_utf8_lossy(&pdf)
.contains(&format!("<pdfaid:part>{part}</pdfaid:part>"))
);
}
}
#[test]
fn full_layout_exposes_resolvable_font_data_and_reuses_the_cache() {
let mut doc = Document::new();
doc.add_paragraph("complete layout result");
reset_layout_invocations();
let first = doc.layout().expect("normal layout should succeed");
let second = doc
.layout_with_options(RenderOptions::default())
.expect("accepted layout should succeed");
assert!(Arc::ptr_eq(&first, &second));
assert_eq!(layout_invocations(), 1);
let mut sourced_runs = 0;
for page in &first.layout.pages {
oxml_layout::walk(&page.elements, &mut |element, _| {
if let oxml_layout::PositionedElement::Text(run) = element {
assert!(first.layout.fonts.iter().any(|font| font.id == run.font_id));
if let Some(source) = run.source {
assert!(first.source_node(source.node).is_some());
sourced_runs += 1;
}
}
});
}
assert!(sourced_runs > 0);
assert!(
!doc.to_pdf()
.expect("PDF should use cached layout")
.is_empty()
);
assert_eq!(layout_invocations(), 1);
}
#[test]
fn layout_with_fonts_returns_the_caller_font_mapping_without_caching() {
let mut doc = Document::new();
let (family, bytes) = caller_only_font();
doc.add_paragraph("")
.add_run("caller font result")
.font(family);
reset_layout_invocations();
let first = doc
.layout_with_fonts(&[(family, &bytes)])
.expect("caller-font layout should succeed");
let second = doc
.layout_with_fonts_and_options(&[(family, &bytes)], RenderOptions::default())
.expect("caller-font options layout should succeed");
assert_eq!(layout_invocations(), 2);
for result in [&first, &second] {
let mut positioned_runs = Vec::new();
for page in &result.layout.pages {
oxml_layout::walk(&page.elements, &mut |element, _| {
if let oxml_layout::PositionedElement::Text(run) = element
&& run.source.is_some()
{
positioned_runs.push(run.clone());
}
});
}
assert!(
!positioned_runs.is_empty(),
"caller-font text was positioned"
);
for run in positioned_runs {
let font = result
.layout
.fonts
.iter()
.find(|font| font.id == run.font_id)
.expect("caller-font glyph run should resolve its font id");
assert_eq!(font.family, family);
assert_eq!(font.data.as_ref(), bytes.as_slice());
let source = run
.source
.expect("caller-font text should retain source provenance");
assert!(result.source_node(source.node).is_some());
}
}
}
#[test]
fn caller_font_layout_does_not_fall_through_to_system_or_bundled_fonts() {
let mut doc = Document::new();
doc.add_paragraph("caller isolation requires an explicit font universe");
assert!(doc.layout_with_fonts(&[]).is_err());
assert!(
doc.normal_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_none()
);
}
/// `to_pdf_with_fonts` documents caller fonts over the fonts `to_pdf`
/// uses. It rendered through the caller-only layout instead, so a family
/// that neither the caller nor the document supplies failed the render.
#[test]
fn pdf_with_caller_fonts_falls_back_to_the_fonts_to_pdf_uses() {
let (caller_family, caller_bytes) = caller_only_font();
let mut document = Document::new();
document.add_paragraph("Calibri resolves as to_pdf resolves it");
document
.add_paragraph("")
.add_run("a family nobody supplies")
.font("Unobtainium Sans");
document
.add_paragraph("")
.add_run("caller face must win")
.font(caller_family);
assert_eq!(
document
.to_pdf_with_fonts(&[])
.expect("an empty caller set renders like to_pdf"),
document.to_pdf().unwrap()
);
let pdf = document
.to_pdf_with_fonts(&[(caller_family, &caller_bytes)])
.expect("missing families fall back past the caller set");
assert!(
pdf.windows(caller_family.len())
.any(|window| window == caller_family.as_bytes()),
"the caller face is embedded"
);
assert!(
document
.layout_with_fonts(&[(caller_family, &caller_bytes)])
.is_err(),
"the caller-only layout stays strict"
);
}
#[test]
fn bundled_fallback_completes_an_incomplete_caller_font_set() {
let (caller_family, caller_bytes) = caller_only_font();
let mut document = Document::new();
document.add_paragraph("Calibri must resolve from bundled fonts");
document
.add_paragraph("")
.add_run("caller face must win")
.font(caller_family);
assert!(
document
.layout_with_fonts(&[(caller_family, &caller_bytes)])
.is_err(),
"strict caller-only layout must not borrow bundled Calibri"
);
let result = document
.layout_with_fonts_and_bundled_fallback(&[(caller_family, &caller_bytes)])
.expect("bundled fallback completes the caller font set");
let mut caller_runs = 0;
let mut bundled_runs = 0;
for page in &result.layout.pages {
oxml_layout::walk(&page.elements, &mut |element, _| {
let oxml_layout::PositionedElement::Text(run) = element else {
return;
};
let font = result
.layout
.fonts
.iter()
.find(|font| font.id == run.font_id)
.expect("every glyph run resolves its font");
if font.family == caller_family {
assert_eq!(font.data.as_ref(), caller_bytes.as_slice());
caller_runs += 1;
} else if oxml_layout::bundled_fonts::bundled_font_data()
.iter()
.any(|(_, bytes)| *bytes == font.data.as_ref())
{
bundled_runs += 1;
}
});
}
assert!(caller_runs > 0, "the matching caller face has priority");
assert!(bundled_runs > 0, "the missing family uses bundled fallback");
assert!(
document
.normal_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_none(),
"bundled fallback stays isolated from the normal engine"
);
}
#[test]
fn bundled_fallback_engine_reuses_and_transfers_exact_context() {
let (caller_family, caller_bytes) = caller_only_font();
let fonts = [(caller_family, caller_bytes.as_slice())];
let mut source = Document::new();
for index in 0..140 {
source.add_paragraph(&format!("paragraph {index:03} stable line"));
}
let initial = source
.layout_with_fonts_and_bundled_fallback(&fonts)
.expect("initial bundled-fallback layout");
source
.paragraph_mut(70)
.expect("middle paragraph")
.add_run(" changed");
let warm = source
.layout_with_fonts_and_bundled_fallback(&fonts)
.expect("warm bundled-fallback layout");
assert!(
warm.layout
.pages
.iter()
.zip(&initial.layout.pages)
.any(|(current, previous)| Arc::ptr_eq(current, previous)),
"a bounded edit reuses at least one retained page"
);
let mut receiver = source.clone_for_staging();
assert!(receiver.transfer_reusable_bundled_fallback_layout_from(&mut source, &fonts));
assert!(
source
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_none()
);
assert!(
receiver
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some()
);
let mut font_source = receiver.clone_for_staging();
let mut font_receiver = receiver.clone_for_staging();
font_source
.layout_with_fonts_and_bundled_fallback(&fonts)
.expect("prime font source");
font_receiver
.layout_with_fonts_and_bundled_fallback(&fonts)
.expect("prime font receiver");
let mut changed_bytes = caller_bytes.clone();
let last = changed_bytes.last_mut().expect("caller font has bytes");
*last ^= 1;
assert!(
!font_receiver.transfer_reusable_bundled_fallback_layout_from(
&mut font_source,
&[(caller_family, &changed_bytes)],
)
);
assert!(
font_source
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some()
);
assert!(
font_receiver
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some()
);
let mut context_source = receiver.clone_for_staging();
let mut context_receiver = receiver.clone_for_staging();
context_receiver.set_header("different retained context");
context_source
.layout_with_fonts_and_bundled_fallback(&fonts)
.expect("prime context source");
context_receiver
.layout_with_fonts_and_bundled_fallback(&fonts)
.expect("prime context receiver");
assert!(
!context_receiver
.transfer_reusable_bundled_fallback_layout_from(&mut context_source, &fonts)
);
assert!(
context_source
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some()
);
assert!(
context_receiver
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some()
);
}
#[test]
fn bundled_fallback_warm_layout_equals_fresh_layout() {
let (caller_family, caller_bytes) = caller_only_font();
let fonts = [(caller_family, caller_bytes.as_slice())];
let mut warm_document = Document::new();
for index in 0..140 {
warm_document.add_paragraph(&format!("paragraph {index:03} stable line"));
}
warm_document
.layout_with_fonts_and_bundled_fallback(&fonts)
.expect("prime warm engine");
warm_document
.paragraph_mut(70)
.expect("middle paragraph")
.add_run(" changed");
let fresh_document = warm_document.clone_for_staging();
let warm = warm_document
.layout_with_fonts_and_bundled_fallback_and_options(&fonts, RenderOptions::default())
.expect("warm layout");
let fresh = fresh_document
.layout_with_fonts_and_bundled_fallback_and_options(&fonts, RenderOptions::default())
.expect("fresh layout");
assert_eq!(warm.revision_view, fresh.revision_view);
assert_eq!(
format!("{:?}", warm.layout.pages),
format!("{:?}", fresh.layout.pages)
);
assert_eq!(
format!("{:?}", warm.layout.fonts),
format!("{:?}", fresh.layout.fonts)
);
assert_eq!(
format!("{:?}", warm.layout.diagnostics),
format!("{:?}", fresh.layout.diagnostics)
);
assert_eq!(
format!("{:?}", warm.layout.outlines),
format!("{:?}", fresh.layout.outlines)
);
assert_eq!(format!("{warm:?}"), format!("{fresh:?}"));
assert_eq!(
oxml_pdf::render_to_pdf(&warm.layout),
oxml_pdf::render_to_pdf(&fresh.layout)
);
}
#[test]
fn changed_alias_context_cannot_reuse_stale_layout_work() {
let (caller_family, caller_bytes) = caller_only_font();
let fonts = [(caller_family, caller_bytes.as_slice())];
let first_aliases = [("Document Sans", caller_family)];
let changed_aliases = [("Document Sans", "Carlito")];
let mut source = Document::new();
source
.add_paragraph("")
.add_run("alias-sensitive paragraph")
.font("Document Sans");
let first = source
.layout_with_fonts_aliases_and_bundled_fallback(&fonts, &first_aliases)
.expect("prime alias-aware engine");
let first_repeat = source
.layout_with_fonts_aliases_and_bundled_fallback(&fonts, &first_aliases)
.expect("equal alias context reuses safely");
assert!(
first
.layout
.pages
.iter()
.zip(&first_repeat.layout.pages)
.all(|(left, right)| Arc::ptr_eq(left, right)),
"equal aliases reuse completed page work"
);
let changed = source
.layout_with_fonts_aliases_and_bundled_fallback(&fonts, &changed_aliases)
.expect("changed alias context relayouts");
assert_ne!(
format!("{:?}", first_repeat.layout.fonts),
format!("{:?}", changed.layout.fonts)
);
let mut receiver = source.clone_for_staging();
receiver
.layout_with_fonts_aliases_and_bundled_fallback(&fonts, &changed_aliases)
.expect("prime receiver engine");
assert!(
!receiver.transfer_reusable_bundled_fallback_layout_from_with_aliases(
&mut source,
&fonts,
&first_aliases,
)
);
assert!(
source
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some()
);
assert!(
receiver
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some()
);
let mut compatible_source = receiver.clone_for_staging();
compatible_source
.layout_with_fonts_aliases_and_bundled_fallback(&fonts, &changed_aliases)
.expect("prime compatible source engine");
let mut compatible_receiver = compatible_source.clone_for_staging();
assert!(
compatible_receiver.transfer_reusable_bundled_fallback_layout_from_with_aliases(
&mut compatible_source,
&fonts,
&changed_aliases,
)
);
assert!(
compatible_source
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_none()
);
assert!(
compatible_receiver
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some()
);
}
#[test]
fn caller_alias_warm_layout_equals_cold_layout() {
let (caller_family, caller_bytes) = caller_only_font();
let fonts = [(caller_family, caller_bytes.as_slice())];
let aliases = [
("Document Sans", caller_family),
("Document UI", caller_family),
];
let mut warm_document = Document::new();
for index in 0..140 {
warm_document
.add_paragraph("")
.add_run(&format!("alias paragraph {index:03}"))
.font(if index % 2 == 0 {
"Document Sans"
} else {
"Document UI"
});
}
warm_document
.layout_with_fonts_aliases_and_bundled_fallback(&fonts, &aliases)
.expect("prime alias-aware engine");
warm_document
.paragraph_mut(70)
.expect("middle paragraph")
.add_run(" changed");
let cold_document = warm_document.clone_for_staging();
let warm = warm_document
.layout_with_fonts_aliases_and_bundled_fallback_and_options(
&fonts,
&aliases,
RenderOptions::default(),
)
.expect("warm alias-aware layout");
let cold = cold_document
.layout_with_fonts_aliases_and_bundled_fallback_and_options(
&fonts,
&aliases,
RenderOptions::default(),
)
.expect("cold alias-aware layout");
assert_eq!(format!("{warm:?}"), format!("{cold:?}"));
assert_eq!(
oxml_pdf::render_to_pdf(&warm.layout),
oxml_pdf::render_to_pdf(&cold.layout)
);
assert_eq!(
oxml_pdf::render_page_to_png(&warm.layout, 0, 72.0),
oxml_pdf::render_page_to_png(&cold.layout, 0, 72.0)
);
}
#[test]
fn substituted_page_reuse_keeps_pdf_and_raster_backends_identical() {
let mut warm_document = Document::new();
let mut fields = CT_P::new();
for instruction in ["PAGE", "NUMPAGES"] {
let mut run = CT_R::new("");
run.content = vec![RunContent::Field(Field::new(instruction, "cached"))];
fields.runs.push(run);
}
warm_document
.document
.body
.content
.push(BodyContent::Paragraph(fields));
for index in 0..140 {
warm_document.add_paragraph(&format!("paragraph {index:03} stable line"));
}
let fresh_document = warm_document.clone_for_staging();
warm_document
.layout_with_fonts_and_bundled_fallback(&[])
.expect("prime substituted pages");
let warm = warm_document
.layout_with_fonts_and_bundled_fallback(&[])
.expect("warm substituted pages");
let fresh = fresh_document
.layout_with_fonts_and_bundled_fallback(&[])
.expect("cold substituted pages");
assert_eq!(format!("{warm:?}"), format!("{fresh:?}"));
assert_eq!(
oxml_pdf::render_to_pdf(&warm.layout),
oxml_pdf::render_to_pdf(&fresh.layout)
);
assert_eq!(
oxml_pdf::render_all_pages(&warm.layout, 72.0),
oxml_pdf::render_all_pages(&fresh.layout, 72.0)
);
}
#[test]
fn cached_header_footer_warm_equals_cold() {
let mut warm_document = Document::new();
warm_document.set_header("default cached header");
warm_document.set_footer("default cached footer");
warm_document.set_first_page_header("first cached header");
warm_document.set_first_page_footer("first cached footer");
warm_document
.set_text_watermark("DRAFT")
.expect("watermark is valid");
for index in 0..140 {
warm_document.add_paragraph(&format!("paragraph {index:03} stable line"));
}
warm_document
.layout_with_fonts_and_bundled_fallback(&[])
.expect("prime deterministic header/footer engine");
warm_document
.paragraph_mut(70)
.expect("middle paragraph")
.add_run(" changed");
let fresh_document = warm_document.clone_for_staging();
let warm = warm_document
.layout_with_fonts_and_bundled_fallback(&[])
.expect("warm deterministic layout");
let fresh = fresh_document
.layout_with_fonts_and_bundled_fallback(&[])
.expect("cold deterministic layout");
assert_eq!(format!("{warm:?}"), format!("{fresh:?}"));
assert_eq!(
oxml_pdf::render_to_pdf(&warm.layout),
oxml_pdf::render_to_pdf(&fresh.layout)
);
}
#[test]
fn staged_mutations_preserve_valid_bundled_fallback_work() {
let (caller_family, caller_bytes) = caller_only_font();
let fonts = [(caller_family, caller_bytes.as_slice())];
let mut document = Document::new();
document.add_paragraph("staged mutation retains reusable work");
document
.layout_with_fonts_and_bundled_fallback(&fonts)
.expect("prime bundled-fallback engine");
document
.set_text_watermark("DRAFT")
.expect("successful staged mutation");
assert!(
document
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some(),
"successful staging retains the private engine"
);
document
.layout_with_fonts_and_bundled_fallback(&fonts)
.expect("engine remains usable after staging");
let header_part = document
.package
.get_part_rels(&document.doc_part_name)
.and_then(|relationships| relationships.get_by_type(rel_types::HEADER))
.map(|relationship| {
OpcPackage::resolve_rel_target(&document.doc_part_name, &relationship.target)
})
.expect("watermark header relationship");
document.package.set_part(&header_part, b"<".to_vec());
let before = document.package.get_part(&header_part).unwrap().to_vec();
assert!(document.set_text_watermark("FAIL").is_err());
assert_eq!(
document.package.get_part(&header_part),
Some(before.as_slice())
);
assert!(
document
.bundled_fallback_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some(),
"failed staging preserves the published engine"
);
let mut poisoned = Document::new();
poisoned.add_paragraph("poison recovery");
poisoned
.layout_with_fonts_and_bundled_fallback(&fonts)
.expect("prime engine before poison");
let poisoned = Arc::new(poisoned);
let poison_owner = Arc::clone(&poisoned);
assert!(
std::thread::spawn(move || {
let _engine = poison_owner.bundled_fallback_layout_engine.lock().unwrap();
panic!("poison bundled fallback engine lock");
})
.join()
.is_err()
);
poisoned
.layout_with_fonts_and_bundled_fallback(&fonts)
.expect("bundled-fallback layout recovers from poison");
}
#[test]
fn layout_options_keep_tracked_and_accepted_cache_ownership_separate() {
let mut doc = Document::new();
doc.add_paragraph("revision cache separation");
let tracked = RenderOptions {
revision_view: rdocx_layout::RevisionView::Tracked,
revision_style: rdocx_layout::RevisionStyle::Plain,
};
reset_layout_invocations();
let accepted_first = doc.layout().expect("accepted layout should succeed");
assert_eq!(layout_invocations(), 1);
let tracked_first = doc
.layout_with_options(tracked)
.expect("tracked layout should succeed");
let tracked_second = doc
.layout_with_options(tracked)
.expect("second tracked layout should succeed");
assert!(!Arc::ptr_eq(&tracked_first, &tracked_second));
assert_eq!(layout_invocations(), 3);
assert!(
doc.normal_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some()
);
let accepted_second = doc.layout().expect("accepted cache should succeed");
assert!(Arc::ptr_eq(&accepted_first, &accepted_second));
assert_eq!(layout_invocations(), 3);
assert_eq!(
tracked_first.revision_view,
rdocx_layout::RevisionView::Tracked
);
assert_eq!(
accepted_first.revision_view,
rdocx_layout::RevisionView::Accepted
);
}
#[test]
fn tracked_normal_layouts_retain_the_document_engine_without_caching_results() {
let mut doc = Document::new();
doc.add_paragraph("tracked engine reuse");
let tracked = RenderOptions {
revision_view: rdocx_layout::RevisionView::Tracked,
revision_style: rdocx_layout::RevisionStyle::Plain,
};
let first = doc
.layout_with_options(tracked)
.expect("first tracked layout");
let second = doc
.layout_with_options(tracked)
.expect("second tracked layout");
assert!(!Arc::ptr_eq(&first, &second));
assert!(
doc.normal_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some()
);
assert!(
doc.layout_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_none()
);
}
#[test]
fn document_remains_send_and_sync() {
fn assert_send_and_sync<T: Send + Sync>() {}
assert_send_and_sync::<Document>();
}
#[test]
fn relayout_caches_are_bounded_and_recover_from_poison() {
let mut document = Document::new();
document.add_paragraph("layout after engine lock poison");
let document = Arc::new(document);
let poison = Arc::clone(&document);
assert!(
std::thread::spawn(move || {
let _engine = poison.normal_layout_engine.lock().unwrap();
panic!("poison normal layout engine lock for recovery coverage");
})
.join()
.is_err()
);
let first = document
.layout()
.expect("layout recovers from poisoned engine lock");
let second = document.layout().expect("recovered layout remains cached");
assert!(Arc::ptr_eq(&first, &second));
}
#[test]
fn all_backends_accept_shared_layout_payloads_without_output_change() {
let mut document = Document::new();
document.add_paragraph("Shared layout").style("Heading1");
document.add_paragraph("Backend and provenance coverage");
let original = document.layout().expect("normal layout succeeds");
let shared = original.layout.clone();
assert!(
original
.layout
.pages
.iter()
.zip(&shared.pages)
.all(|(left, right)| Arc::ptr_eq(left, right))
);
assert!(
original
.layout
.fonts
.iter()
.zip(&shared.fonts)
.all(|(left, right)| Arc::ptr_eq(&left.data, &right.data))
);
assert_eq!(
oxml_pdf::render_to_pdf(&original.layout),
oxml_pdf::render_to_pdf(&shared)
);
assert_eq!(
oxml_pdf::render_page_to_png(&original.layout, 0, 72.0),
oxml_pdf::render_page_to_png(&shared, 0, 72.0)
);
assert_eq!(
format!(
"{:?}",
document
.layout_page(0)
.expect("page access")
.expect("first page")
),
format!("{:?}", shared.pages[0])
);
assert_eq!(original.layout.diagnostics, shared.diagnostics);
assert_eq!(
format!("{:?}", original.layout.outlines),
format!("{:?}", shared.outlines)
);
let (family, bytes) = caller_only_font();
let mut caller_document = Document::new();
caller_document
.add_paragraph("")
.add_run("caller-owned font")
.font(family);
let caller = caller_document
.layout_with_fonts(&[(family, &bytes)])
.expect("caller-font layout succeeds");
let caller_shared = caller.layout.clone();
assert!(
caller
.layout
.fonts
.iter()
.zip(&caller_shared.fonts)
.all(|(left, right)| Arc::ptr_eq(&left.data, &right.data))
);
let source = caller.layout.pages.iter().find_map(|page| {
let mut source = None;
oxml_layout::walk(&page.elements, &mut |element, _| {
if let oxml_layout::PositionedElement::Text(run) = element {
source = source.or(run.source);
}
});
source
});
assert!(source.is_some_and(|span| caller.source_node(span.node).is_some()));
}
#[test]
fn compatible_document_transfer_reuses_normal_layout_work() {
let mut source = Document::new();
source.add_paragraph("unchanged paragraph");
source.add_paragraph("old second paragraph");
let source_result = source.layout().expect("prime source engine");
let mut receiver = source.clone_for_staging();
let receiver_result = receiver.layout().expect("prime receiver engine");
assert!(receiver.transfer_reusable_layout_from(&mut source));
assert!(
source
.normal_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_none()
);
assert!(
receiver
.normal_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some()
);
assert!(Arc::ptr_eq(
&source_result,
&source.layout().expect("source result cache remains")
));
assert!(Arc::ptr_eq(
&receiver_result,
&receiver.layout().expect("receiver result cache remains")
));
receiver
.paragraph_mut(1)
.expect("second paragraph")
.add_run(" changed");
assert!(receiver.layout().is_ok());
}
#[test]
fn incompatible_or_failed_transfer_preserves_both_engines() {
let mut source = Document::new();
source.add_paragraph("source paragraph");
let mut receiver = source.clone_for_staging();
source.layout().expect("prime source engine");
receiver.layout().expect("prime receiver engine");
receiver.styles = CT_Styles::new();
receiver.invalidate_layout();
assert!(!receiver.transfer_reusable_layout_from(&mut source));
assert!(
source
.normal_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some()
);
assert!(
receiver
.normal_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some()
);
let poisoned = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _engine = source.normal_layout_engine.lock().unwrap();
panic!("poison source engine lock");
}));
assert!(poisoned.is_err());
let mut compatible = source.clone_for_staging();
assert!(compatible.transfer_reusable_layout_from(&mut source));
assert!(compatible.layout().is_ok());
}
#[test]
fn transferred_warm_layout_equals_fresh_layout() {
let mut source = Document::new();
source.add_paragraph("unchanged paragraph");
source.add_paragraph("old second paragraph");
source.layout().expect("prime source engine");
let mut warm = source.clone_for_staging();
warm.paragraph_mut(1)
.expect("second paragraph")
.add_run(" changed");
let fresh = warm.clone_for_staging();
assert!(warm.transfer_reusable_layout_from(&mut source));
let warm_result = warm.layout().expect("transferred warm layout");
let fresh_result = fresh.layout().expect("fresh cold layout");
assert_eq!(format!("{warm_result:?}"), format!("{fresh_result:?}"));
assert_eq!(
oxml_pdf::render_to_pdf(&warm_result.layout),
oxml_pdf::render_to_pdf(&fresh_result.layout)
);
}
#[test]
fn word_chart_part_and_workbook_round_trip() {
let workbook_bytes = minimal_chart_workbook()
.to_xlsx_bytes()
.expect("serialize expected workbook");
let mut document = document_with_minimal_chart();
let bytes = document.to_bytes().expect("save document");
let reopened = Document::from_bytes(&bytes).expect("reopen document");
let chart_part = "/word/charts/chart1.xml";
let workbook_part = "/word/embeddings/Workbook1.xlsx";
let chart_xml = reopened.package.get_part(chart_part).expect("chart part");
assert!(CT_ChartSpace::from_xml(chart_xml).is_ok());
assert_eq!(
reopened.package.get_part(workbook_part),
Some(workbook_bytes.as_slice())
);
assert_eq!(
reopened.package.content_types.content_type_for(chart_part),
Some(content_types::CHART)
);
assert_eq!(
reopened
.package
.content_types
.content_type_for(workbook_part),
Some(content_types::EMBEDDED_WORKBOOK)
);
let document_relationship = reopened
.package
.get_part_rels(&reopened.doc_part_name)
.and_then(|relationships| relationships.get_by_type(rel_types::CHART))
.expect("document to chart relationship");
assert_eq!(
OpcPackage::resolve_rel_target(&reopened.doc_part_name, &document_relationship.target,),
chart_part
);
let workbook_relationship = reopened
.package
.get_part_rels(chart_part)
.and_then(|relationships| relationships.get_by_type(rel_types::PACKAGE))
.expect("chart to workbook relationship");
assert_eq!(
OpcPackage::resolve_rel_target(chart_part, &workbook_relationship.target),
workbook_part
);
assert!(
std::str::from_utf8(chart_xml)
.expect("chart XML is utf8")
.contains(&format!(
r#"<c:externalData r:id="{}">"#,
workbook_relationship.id
))
);
let document_xml = std::str::from_utf8(
reopened
.package
.get_part(&reopened.doc_part_name)
.expect("document part"),
)
.expect("document XML is utf8");
assert!(document_xml.contains(&format!(r#"r:id="{}""#, document_relationship.id)));
}
#[test]
fn word_chart_parts_allocate_after_sparse_suffixes() {
let mut document = Document::new();
document
.package
.set_part("/word/charts/chart3.xml", b"occupied".to_vec());
document
.package
.set_part("/word/embeddings/Workbook7.xlsx", b"occupied".to_vec());
document
.add_chart_package(
ChartPackageSource::Typed {
chart: &minimal_typed_chart(),
workbook: &minimal_chart_workbook(),
},
Length::inches(5.0),
Length::inches(3.0),
)
.expect("assemble after sparse suffixes");
assert!(
document
.package
.get_part("/word/charts/chart4.xml")
.is_some()
);
assert!(
document
.package
.get_part("/word/embeddings/Workbook8.xlsx")
.is_some()
);
assert_eq!(
document.package.get_part("/word/charts/chart3.xml"),
Some(b"occupied".as_slice())
);
assert_eq!(
document.package.get_part("/word/embeddings/Workbook7.xlsx"),
Some(b"occupied".as_slice())
);
}
#[test]
fn invalid_chart_package_assembly_is_atomic() {
let mut document = Document::new();
let before = document
.to_bytes()
.expect("serialize before failed mutation");
let chart = CT_ChartSpace::from_xml(
format!(
r#"<c:chartSpace xmlns:c="{}" xmlns:a="{}" xmlns:r="{}" xmlns:q="{}"><c:chart><c:plotArea/></c:chart><q:externalData r:id="rId99"/></c:chartSpace>"#,
oxml_chart::C_NS,
oxml_chart::A_NS,
oxml_chart::R_NS,
oxml_chart::C_NS,
)
.as_bytes(),
)
.expect("valid chart with an occupied workbook relationship");
let error = document
.add_chart_package(
ChartPackageSource::Typed {
chart: &chart,
workbook: &minimal_chart_workbook(),
},
Length::inches(5.0),
Length::inches(3.0),
)
.expect_err("a second workbook relationship is invalid");
assert!(error.to_string().contains("already contains"));
assert_eq!(
document
.to_bytes()
.expect("serialize after failed mutation"),
before
);
}
#[test]
fn nested_external_data_lookalike_does_not_occupy_workbook_slot() {
let chart = CT_ChartSpace::from_xml(
format!(
r#"<c:chartSpace xmlns:c="{}" xmlns:a="{}" xmlns:r="{}" xmlns:q="{}"><c:chart><c:plotArea/></c:chart><c:extLst><c:ext uri="urn:producer"><q:externalData r:id="rId99"/></c:ext></c:extLst></c:chartSpace>"#,
oxml_chart::C_NS,
oxml_chart::A_NS,
oxml_chart::R_NS,
oxml_chart::C_NS,
)
.as_bytes(),
)
.expect("valid chart with a nested producer lookalike");
let xml = chart_with_workbook_relationship(&chart, "rId1")
.expect("nested lookalike leaves the workbook slot available");
let xml = std::str::from_utf8(&xml).expect("chart XML is utf8");
assert!(xml.contains(r#"<q:externalData r:id="rId99"/>"#));
assert!(xml.contains(r#"<c:externalData r:id="rId1">"#));
}
fn f158_chart_data(series_count: usize) -> ChartData {
ChartData {
categories: vec!["North".to_owned(), "South".to_owned(), "West".to_owned()],
series: vec![
("Revenue".to_owned(), vec![12.5, 19.0, 14.25]),
("Cost".to_owned(), vec![8.0, 11.5, 9.75]),
]
.into_iter()
.take(series_count)
.collect(),
number_format: Some("0.00".to_owned()),
..ChartData::default()
}
}
fn portable_chart_data(series_count: usize) -> ChartData {
ChartData {
categories: vec!["August".to_owned(), "September".to_owned()],
series: vec![
("Gold".to_owned(), vec![12.5, 19.2]),
("Silver".to_owned(), vec![8.0, 11.5]),
]
.into_iter()
.take(series_count)
.collect(),
number_format: Some(r"0.##\%".to_owned()),
category_axis_title: Some("Month".to_owned()),
value_axis_title: Some("Change".to_owned()),
palette: vec![
oxml_chart::RgbColor::parse("2B6FE3").unwrap(),
oxml_chart::RgbColor::parse("F0761F").unwrap(),
],
}
}
fn chart_part_names(document: &Document) -> Vec<String> {
let mut parts = document
.package
.content_types
.overrides
.iter()
.filter_map(|(part, content_type)| {
(content_type == content_types::CHART).then_some(part.clone())
})
.collect::<Vec<_>>();
parts.sort_unstable();
parts
}
fn assert_editable_chart_workbook(document: &Document, chart_part: &str) {
let relationship = document
.package
.get_part_rels(chart_part)
.and_then(|relationships| relationships.get_by_type(rel_types::PACKAGE))
.expect("editable workbook relationship");
let workbook_part = OpcPackage::resolve_rel_target(chart_part, &relationship.target);
let workbook = document
.package
.get_part(&workbook_part)
.expect("editable workbook part");
OpcPackage::from_reader(Cursor::new(workbook)).expect("valid editable workbook");
}
#[test]
fn word_authored_line_and_bar_charts_reopen_with_axes_and_palette() {
let data = portable_chart_data(2);
let mut document = Document::new();
for kind in [ChartKind::Line, ChartKind::Bar] {
document
.add_chart(kind, Length::inches(6.0), Length::inches(3.5), &data)
.expect("author portable axis chart");
}
let bytes = document.to_bytes().expect("save portable axis charts");
let reopened = Document::from_bytes(&bytes).expect("reopen portable axis charts");
let parts = chart_part_names(&reopened);
assert_eq!(parts.len(), 2);
for part in parts {
let xml = reopened.package.get_part(&part).expect("chart part");
let text = std::str::from_utf8(xml).expect("chart XML is utf8");
assert_eq!(text.matches(r#"<c:delete val="0"/>"#).count(), 2);
assert!(text.contains("<a:t>Month</a:t>"));
assert!(text.contains("<a:t>Change</a:t>"));
assert!(text.contains(r#"<c:numFmt formatCode="0.##\%" sourceLinked="0"/>"#));
assert!(text.contains(r#"<a:srgbClr val="2B6FE3"/>"#));
assert!(text.contains(r#"<a:srgbClr val="F0761F"/>"#));
let chart = CT_ChartSpace::from_xml(xml).expect("typed portable chart");
assert_authored_chart_matches(&chart, &data);
assert_editable_chart_workbook(&reopened, &part);
}
}
#[test]
fn word_authored_pie_and_doughnut_charts_reopen_with_point_colours() {
let data = portable_chart_data(1);
let mut document = Document::new();
for kind in [ChartKind::Pie, ChartKind::Doughnut] {
document
.add_chart(kind, Length::inches(6.0), Length::inches(3.5), &data)
.expect("author portable circular chart");
}
let bytes = document.to_bytes().expect("save portable circular charts");
let reopened = Document::from_bytes(&bytes).expect("reopen portable circular charts");
let parts = chart_part_names(&reopened);
assert_eq!(parts.len(), 2);
for part in parts {
let xml = reopened.package.get_part(&part).expect("chart part");
let text = std::str::from_utf8(xml).expect("chart XML is utf8");
assert!(text.contains("<c:legend"));
assert!(text.contains(r#"<c:showPercent val="1"/>"#));
assert_eq!(text.matches("<c:dPt>").count(), 2);
assert!(
text.contains(r#"<c:idx val="0"/><c:spPr><a:solidFill><a:srgbClr val="2B6FE3"/>"#)
);
assert!(
text.contains(r#"<c:idx val="1"/><c:spPr><a:solidFill><a:srgbClr val="F0761F"/>"#)
);
if text.contains("<c:doughnutChart>") {
assert!(text.contains(r#"<c:holeSize val="50"/>"#));
}
CT_ChartSpace::from_xml(xml).expect("typed portable circular chart");
assert_editable_chart_workbook(&reopened, &part);
}
}
#[test]
#[ignore = "requires pinned Microsoft Word and Pages Creator Studio interoperability evidence"]
fn word_and_pages_open_portable_authored_charts() {
let output_dir = std::env::var_os("RDOCX_FX087_ORACLE_DIR")
.map(std::path::PathBuf::from)
.expect("set RDOCX_FX087_ORACLE_DIR to an existing evidence directory");
let candidate = output_dir.join("portable-authored-charts.docx");
let pages_output = output_dir.join("portable-authored-charts-pages.docx");
let _ = fs::remove_file(&pages_output);
portable_chart_document()
.save(&candidate)
.expect("write portable chart candidate");
assert_eq!(sha256(&candidate), FX087_CANDIDATE_SHA256);
for (application, version, build) in [
("Microsoft Word", FX087_WORD_VERSION, FX087_WORD_BUILD),
(
"Pages Creator Studio",
FX087_PAGES_VERSION,
FX087_PAGES_BUILD,
),
] {
let plist = format!("/Applications/{application}.app/Contents/Info.plist");
assert_eq!(plist_value(&plist, "CFBundleShortVersionString"), version);
assert_eq!(plist_value(&plist, "CFBundleVersion"), build);
}
let word_script = format!(
"with timeout of 120 seconds\ntell application \"Microsoft Word\"\nactivate\nopen POSIX file \"{}\"\ndelay 5\nclose active document saving no\nend tell\nend timeout\n",
candidate.display()
);
let word = Command::new("osascript")
.args(["-e", &word_script])
.output()
.expect("launch Word chart oracle");
assert!(
word.status.success(),
"Word chart oracle failed: {}",
String::from_utf8_lossy(&word.stderr)
);
let pages_open = Command::new("/usr/bin/open")
.args(["-a", "/Applications/Pages Creator Studio.app"])
.arg(&candidate)
.output()
.expect("open chart candidate through LaunchServices");
assert!(
pages_open.status.success(),
"Pages Creator Studio launch failed: {}",
String::from_utf8_lossy(&pages_open.stderr)
);
let pages_script = format!(
"with timeout of 120 seconds\ntell application \"Pages Creator Studio\"\nactivate\ndelay 15\nset chartDocument to first document whose name is \"portable-authored-charts\"\nexport chartDocument to POSIX file \"{}\" as Microsoft Word\nclose chartDocument saving no\nend tell\nend timeout\n",
pages_output.display()
);
let pages = Command::new("osascript")
.args(["-e", &pages_script])
.output()
.expect("launch Pages Creator Studio chart oracle");
assert!(
pages.status.success(),
"Pages Creator Studio chart oracle failed: {}",
String::from_utf8_lossy(&pages.stderr)
);
eprintln!(
"Pages Creator Studio export SHA-256: {}",
sha256(&pages_output)
);
assert_portable_chart_data(
&Document::open(&pages_output).expect("open Pages Creator Studio DOCX"),
);
}
#[test]
#[ignore = "requires the SHA-recorded Pages Creator Studio export artifact"]
fn recorded_pages_export_preserves_portable_authored_charts() {
let candidate = std::env::var_os("RDOCX_FX087_ORACLE_CANDIDATE")
.map(std::path::PathBuf::from)
.expect("set RDOCX_FX087_ORACLE_CANDIDATE to the SHA-bound candidate");
let pages_output = std::env::var_os("RDOCX_FX087_PAGES_EXPORT")
.map(std::path::PathBuf::from)
.expect("set RDOCX_FX087_PAGES_EXPORT to the recorded Pages export");
let plist = "/Applications/Pages Creator Studio.app/Contents/Info.plist";
assert_eq!(sha256(&candidate), FX087_CANDIDATE_SHA256);
assert_eq!(
plist_value(plist, "CFBundleShortVersionString"),
FX087_PAGES_VERSION
);
assert_eq!(plist_value(plist, "CFBundleVersion"), FX087_PAGES_BUILD);
eprintln!(
"recorded Pages Creator Studio export SHA-256: {}",
sha256(&pages_output)
);
assert_portable_chart_data(
&Document::open(&pages_output).expect("open recorded Pages Creator Studio DOCX"),
);
}
fn portable_chart_document() -> Document {
let data = portable_chart_data(1);
let mut document = Document::new();
for kind in [
ChartKind::Line,
ChartKind::Bar,
ChartKind::Pie,
ChartKind::Doughnut,
] {
document
.add_chart(kind, Length::inches(6.0), Length::inches(3.5), &data)
.expect("author portable chart");
}
document
}
fn assert_portable_chart_data(document: &Document) {
let relationships = document
.package
.get_part_rels(&document.doc_part_name)
.expect("document relationships")
.get_all_by_type(rel_types::CHART);
assert_eq!(relationships.len(), 4);
let mut chart_families = std::collections::BTreeSet::new();
let mut workbook_parts = std::collections::BTreeSet::new();
for relationship in relationships {
let part =
OpcPackage::resolve_rel_target(&document.doc_part_name, &relationship.target);
let chart = std::str::from_utf8(
document
.package
.get_part(&part)
.expect("chart part after Pages Creator Studio"),
)
.expect("chart XML after Pages Creator Studio");
if let Err(error) = CT_ChartSpace::from_xml(chart.as_bytes()) {
assert!(
matches!(
error,
oxml_chart::ChartError::MissingElement(ref element)
if element == "c:val/c:numRef"
) && chart.contains(
r#"<c:val><c:numLit><c:ptCount val="2"/><c:pt idx="0"><c:v>12.500000</c:v></c:pt><c:pt idx="1"><c:v>19.200000</c:v></c:pt></c:numLit></c:val>"#
),
"unexpected typed chart result after Pages Creator Studio: {error}"
);
}
let family = [
("line", "<c:lineChart>"),
("bar", "<c:barChart>"),
("pie", "<c:pieChart>"),
("doughnut", "<c:doughnutChart>"),
]
.into_iter()
.find_map(|(family, marker)| chart.contains(marker).then_some(family))
.expect("known portable chart family after Pages Creator Studio");
assert!(chart_families.insert(family), "duplicate {family} chart");
for value in ["August", "September", "12.500000", "19.200000", "2B6FE3"] {
assert!(chart.contains(value), "missing {value} in {part}");
}
if family == "line" || family == "bar" {
for title in ["Month", "Change"] {
assert!(
chart.contains(title),
"missing {title} axis title in {part}"
);
}
}
if family != "line" {
assert!(
chart.contains("F0761F"),
"missing second point colour in {part}"
);
}
if family == "pie" || family == "doughnut" {
assert!(chart.contains("<c:legend"), "missing legend in {part}");
assert!(
chart.contains(r#"<c:showPercent val="1"/>"#),
"missing percentage labels in {part}"
);
}
if family == "doughnut" {
assert!(
chart.contains(r#"<c:holeSize val="50"/>"#),
"missing doughnut hole size in {part}"
);
}
let workbook_relationship = document
.package
.get_part_rels(&part)
.and_then(|relationships| relationships.get_by_type(rel_types::PACKAGE))
.expect("editable workbook relationship after Pages Creator Studio");
let workbook = OpcPackage::resolve_rel_target(&part, &workbook_relationship.target);
assert!(
workbook_parts.insert(workbook.clone()),
"duplicate editable workbook target {workbook}"
);
let workbook = OpcPackage::from_reader(Cursor::new(
document
.package
.get_part(&workbook)
.expect("editable workbook after Pages Creator Studio"),
))
.expect("open editable workbook after Pages Creator Studio");
for value in ["Gold", "August", "September", "12.5", "19.2"] {
assert!(
workbook
.parts
.values()
.any(|part| String::from_utf8_lossy(part).contains(value)),
"missing {value} from editable workbook"
);
}
}
assert_eq!(chart_families.len(), 4);
assert_eq!(workbook_parts.len(), 4);
}
fn assert_valid_related_theme(document: &Document) -> String {
let relationship = document
.package
.get_part_rels(&document.doc_part_name)
.and_then(|relationships| relationships.get_by_type(rel_types::THEME))
.expect("document theme relationship");
assert!(relationship_is_internal(relationship));
let part = OpcPackage::resolve_rel_target(&document.doc_part_name, &relationship.target);
assert_eq!(
document.package.content_types.content_type_for(&part),
Some(content_types::THEME)
);
let bytes = document.package.get_part(&part).expect("theme part");
oxml_drawing::theme::CT_OfficeStyleSheet::from_xml(bytes).expect("typed theme part");
part
}
#[test]
fn word_chart_theme_validation_is_atomic() {
let mut missing =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
missing
.add_chart(
ChartKind::Line,
Length::inches(5.0),
Length::inches(3.0),
&portable_chart_data(1),
)
.expect("replace missing theme");
assert_valid_related_theme(&missing);
for (bytes, content_type) in [
(b"mistyped retained theme".as_slice(), None),
(b"<a:theme>malformed".as_slice(), Some(content_types::THEME)),
] {
let mut document = Document::new_with_profile(WordCreationProfile::Minimal(
WordPackageClass::Document,
));
let source_part = "/word/theme/theme1.xml";
document.package.set_part(source_part, bytes.to_vec());
if let Some(content_type) = content_type {
document
.package
.content_types
.add_override(source_part, content_type);
}
document
.package
.get_or_create_part_rels(&document.doc_part_name)
.add(rel_types::THEME, "theme/theme1.xml");
let before = document.to_bytes().expect("serialize invalid-theme source");
let invalid = ChartData {
categories: Vec::new(),
..ChartData::default()
};
assert!(
document
.add_chart(
ChartKind::Line,
Length::inches(5.0),
Length::inches(3.0),
&invalid,
)
.is_err()
);
assert_eq!(
document.to_bytes().expect("serialize after failed chart"),
before
);
document
.add_chart(
ChartKind::Line,
Length::inches(5.0),
Length::inches(3.0),
&portable_chart_data(1),
)
.expect("replace invalid theme");
let replacement = assert_valid_related_theme(&document);
assert_ne!(replacement, source_part);
assert_eq!(document.package.get_part(source_part), Some(bytes));
}
}
#[test]
fn word_chart_theme_reuse_keeps_typed_document_state_consistent() {
let mut source =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
let part = "/word/theme/custom-chart-theme.xml";
let theme = oxml_drawing::theme::CT_OfficeStyleSheet::office_default();
let bytes = theme.to_xml().expect("serialize custom theme");
source.package.set_part(part, bytes.clone());
source
.package
.content_types
.add_override(part, content_types::THEME);
source
.package
.get_or_create_part_rels(&source.doc_part_name)
.add(rel_types::THEME, "theme/custom-chart-theme.xml");
source.theme = Some(theme.clone());
source.theme_part_name = Some(part.to_owned());
source
.identifiers
.observe_package_graph(&source.package)
.expect("observe themed source");
let mut document = source;
assert_eq!(assert_valid_related_theme(&document), part);
document
.set_theme(theme.clone())
.expect("stage replacement theme model");
assert_eq!(assert_valid_related_theme(&document), part);
document
.add_chart(
ChartKind::Bar,
Length::inches(5.0),
Length::inches(3.0),
&portable_chart_data(1),
)
.expect("reuse valid related theme");
assert_eq!(document.theme(), Some(&theme));
assert_eq!(assert_valid_related_theme(&document), part);
assert_eq!(document.package.get_part(part), Some(bytes.as_slice()));
let saved = document.to_bytes().expect("save reused theme");
let reopened = Document::from_bytes(&saved).expect("reopen reused theme");
assert_eq!(reopened.theme(), Some(&theme));
assert_eq!(assert_valid_related_theme(&reopened), part);
}
#[test]
fn word_chart_allocation_is_deterministic() {
fn construct(reverse: bool) -> Vec<u8> {
let mut document = Document::new_with_profile(WordCreationProfile::Minimal(
WordPackageClass::Document,
));
let mut occupied = [
("/word/charts/chart3.xml", b"chart".as_slice()),
("/word/embeddings/Workbook7.xlsx", b"workbook".as_slice()),
("/word/theme/theme3.xml", b"theme".as_slice()),
];
if reverse {
occupied.reverse();
}
for (part, bytes) in occupied {
document.package.set_part(part, bytes.to_vec());
}
document
.add_chart(
ChartKind::Doughnut,
Length::inches(5.0),
Length::inches(3.0),
&portable_chart_data(1),
)
.expect("deterministic chart allocation");
assert!(
document
.package
.get_part("/word/theme/theme1.xml")
.is_some()
);
assert!(
document
.package
.get_part("/word/charts/chart4.xml")
.is_some()
);
assert!(
document
.package
.get_part("/word/embeddings/Workbook8.xlsx")
.is_some()
);
document
.to_bytes()
.expect("serialize deterministic package")
}
assert_eq!(construct(false), construct(true));
}
fn assert_authored_chart_matches(chart: &CT_ChartSpace, data: &ChartData) {
let series = chart.chart.plot_area.series().expect("typed chart series");
assert_eq!(series.len(), data.series.len());
for (actual, (name, values)) in series.iter().zip(&data.series) {
assert_eq!(
actual.name.as_ref().expect("series name").values.as_slice(),
std::slice::from_ref(name)
);
let AxisData::String(categories) = actual.categories.as_ref().expect("categories")
else {
panic!("Word bar, line, and pie charts use string categories");
};
assert_eq!(categories.values, data.categories);
assert_eq!(actual.values.values, *values);
assert_eq!(
actual.values.format_code,
data.number_format.as_deref().unwrap_or("General")
);
}
}
fn deterministic_chart_layout(document: &Document) -> oxml_layout::LayoutResult {
rdocx_layout::layout_document_deterministic(&document.build_layout_input().unwrap())
.expect("deterministic Word chart layout")
}
fn compatibility_page_elements(
elements: &[oxml_layout::PositionedElement],
) -> Vec<&oxml_layout::PositionedElement> {
fn collect<'a>(
elements: &'a [oxml_layout::PositionedElement],
output: &mut Vec<&'a oxml_layout::PositionedElement>,
) {
for element in elements {
match element {
oxml_layout::PositionedElement::MarkedContent { children, .. } => {
collect(children, output);
}
other => output.push(other),
}
}
}
let mut output = Vec::new();
collect(elements, &mut output);
output
}
fn chart_leaf_counts(layout: &oxml_layout::LayoutResult) -> (usize, usize, usize) {
let mut paths = 0;
let mut text = 0;
let mut images = 0;
oxml_layout::walk(&layout.pages[0].elements, &mut |element, _| match element {
oxml_layout::PositionedElement::Path(_) => paths += 1,
oxml_layout::PositionedElement::Text(_) => text += 1,
oxml_layout::PositionedElement::Image { .. } => images += 1,
_ => {}
});
(paths, text, images)
}
#[test]
fn inline_word_chart_renders_backend_neutral_group() {
let mut document = Document::new();
document
.add_chart(
ChartKind::Bar,
Length::inches(5.0),
Length::inches(3.0),
&f158_chart_data(2),
)
.expect("author inline chart");
let layout = deterministic_chart_layout(&document);
assert!(layout.diagnostics.is_empty());
assert!(
compatibility_page_elements(&layout.pages[0].elements)
.iter()
.any(|element| matches!(element, oxml_layout::PositionedElement::Group(_)))
);
let (paths, text, images) = chart_leaf_counts(&layout);
assert!(paths > 0, "chart should lower to backend-neutral paths");
assert!(text > 0, "chart should lower labels to shaped text");
assert_eq!(images, 0, "chart must not be rasterized before pagination");
}
#[test]
fn anchored_word_chart_uses_existing_wrap_and_z_order() {
let mut document = Document::new();
document
.add_chart(
ChartKind::Line,
Length::inches(3.0),
Length::inches(2.0),
&f158_chart_data(2),
)
.expect("author chart");
let BodyContent::Paragraph(paragraph) = &mut document.document.body.content[0] else {
panic!("chart paragraph");
};
let RunContent::Drawing(drawing) = &mut paragraph.runs[0].content[0] else {
panic!("chart drawing");
};
let inline = drawing.inline.take().expect("authored inline chart");
let mut anchor = rdocx_oxml::drawing::CT_Anchor::new_chart(
inline.chart_rel_id.as_deref().expect("chart relationship"),
inline.extent_cx.0,
inline.extent_cy.0,
);
anchor.behind_doc = true;
anchor.wrap = rdocx_oxml::drawing::WrapType::Square;
anchor.pos_h_relative_from = rdocx_oxml::drawing::ST_RelativeFromH::Page;
anchor.pos_v_relative_from = rdocx_oxml::drawing::ST_RelativeFromV::Page;
anchor.pos_h_offset = Length::inches(1.0).as_emu();
anchor.pos_v_offset = Length::inches(0.5).as_emu();
anchor.dist_l = Length::pt(12.0).as_emu();
anchor.dist_r = Length::pt(12.0).as_emu();
drawing.anchor = Some(anchor);
paragraph.add_run("Foreground text");
let layout = deterministic_chart_layout(&document);
assert!(layout.diagnostics.is_empty());
let elements = compatibility_page_elements(&layout.pages[0].elements);
let group_index = elements
.iter()
.position(|element| matches!(element, oxml_layout::PositionedElement::Group(_)))
.expect("anchored chart group");
let text_index = elements
.iter()
.position(|element| matches!(element, oxml_layout::PositionedElement::Text(_)))
.expect("chart label text");
assert!(
group_index <= text_index,
"behind-text group must be emitted first"
);
let oxml_layout::PositionedElement::Group(group) = elements[group_index] else {
unreachable!()
};
assert_eq!((group.transform.e, group.transform.f), (72.0, 36.0));
let oxml_layout::PositionedElement::Text(foreground) = elements[text_index] else {
unreachable!()
};
assert!(
foreground.origin.x >= 300.0,
"12 point wrap distance should clear the chart's 288 point right edge"
);
}
#[test]
fn word_chart_uses_document_theme_and_default_color_map() {
let mut document = Document::new();
document
.add_chart(
ChartKind::Bar,
Length::inches(5.0),
Length::inches(3.0),
&f158_chart_data(1),
)
.expect("author themed chart");
let themed = oxml_drawing::theme::OFFICE_DEFAULT_XML.replace("156082", "12AB34");
document
.package
.set_part("/word/theme/custom-chart-theme.xml", themed.into_bytes());
document.package.parts.remove("/word/theme/theme1.xml");
document
.package
.get_or_create_part_rels("/word/document.xml")
.add(rel_types::THEME, "theme/custom-chart-theme.xml");
let input = document.build_layout_input().unwrap();
assert_eq!(
input.chart_color_map,
oxml_drawing::color::ColorMap::default()
);
let layout =
rdocx_layout::layout_document_deterministic(&input).expect("layout themed Word chart");
let expected = oxml_layout::Color::from_hex("12AB34");
let mut found = false;
oxml_layout::walk(&layout.pages[0].elements, &mut |element, _| {
if let oxml_layout::PositionedElement::Path(path) = element
&& path.fill == Some(oxml_layout::Paint::Solid(expected))
{
found = true;
}
});
assert!(found, "chart series should use the document theme accent");
}
#[test]
fn missing_or_malformed_word_chart_is_visible() {
for (label, mutate) in [
("missing", 0_u8),
("malformed", 1_u8),
("external", 2_u8),
("unsupported", 3_u8),
] {
let mut document = Document::new();
document
.add_chart(
ChartKind::Bar,
Length::inches(5.0),
Length::inches(3.0),
&f158_chart_data(1),
)
.expect("author chart before corrupting target");
match mutate {
0 => {
document.package.parts.remove("/word/charts/chart1.xml");
}
1 => document
.package
.set_part("/word/charts/chart1.xml", b"<c:chartSpace".to_vec()),
2 => {
let relationship = document
.package
.get_or_create_part_rels("/word/document.xml")
.items
.iter_mut()
.find(|relationship| relationship.rel_type == rel_types::CHART)
.expect("chart relationship");
relationship.target = "https://example.invalid/chart.xml".to_owned();
relationship.target_mode = Some("External".to_owned());
}
3 => document.package.set_part(
"/word/charts/chart1.xml",
format!(
r#"<c:chartSpace xmlns:c="{}"><c:chart><c:plotArea/></c:chart></c:chartSpace>"#,
oxml_chart::C_NS,
)
.into_bytes(),
),
_ => unreachable!(),
}
let layout = deterministic_chart_layout(&document);
assert_eq!(layout.diagnostics.len(), 1, "{label}");
assert!(
layout.diagnostics[0]
.message
.contains("Word chart relationship")
);
let (_, text, images) = chart_leaf_counts(&layout);
assert!(text > 0, "{label} chart fallback should be visible");
assert_eq!(images, 0, "{label} chart must not become an empty image");
}
}
#[test]
fn word_and_powerpoint_chart_pixels_are_identical() {
const DPI: &str = "150";
const RASTERIZER: &str = "pdftoppm version 26.01.0";
const CROP_X: &str = "150";
const CROP_Y: &str = "150";
const CROP_WIDTH: &str = "750";
const CROP_HEIGHT: &str = "450";
const WORD_SHA256: &str =
"5046f7a0f2305518ec8a0c439b2df482790f29c05e41f6f5ef1d49dbb5473348";
const POWERPOINT_SHA256: &str =
"79354125ec3c356ad4f5ff1fa3bb773b5938c57a92c249dc13770ab902c62ec6";
let data = f158_chart_data(2);
let evidence_dir = std::env::temp_dir();
let word_path =
evidence_dir.join(format!("rdocx-f159-word-chart-{}.docx", std::process::id()));
let powerpoint_path = evidence_dir.join(format!(
"rdocx-f159-powerpoint-chart-{}.pptx",
std::process::id()
));
let mut word =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
word.add_chart(
ChartKind::Bar,
Length::inches(5.0),
Length::inches(3.0),
&data,
)
.expect("author Word chart from shared data");
let mut powerpoint = rpptx::Presentation::new().expect("open bundled PowerPoint template");
powerpoint
.set_slide_size(Length::inches(8.5).as_emu(), Length::inches(11.0).as_emu())
.expect("match the Word page size");
powerpoint.add_slide(0).expect("add chart slide");
powerpoint
.add_chart(
0,
ChartKind::Bar,
Length::inches(1.0).as_emu(),
Length::inches(1.0).as_emu(),
Length::inches(5.0).as_emu(),
Length::inches(3.0).as_emu(),
&data,
)
.expect("author PowerPoint chart from shared data");
powerpoint
.save(&powerpoint_path)
.expect("save PowerPoint chart artifact");
let powerpoint_package = OpcPackage::from_reader(Cursor::new(
powerpoint
.to_bytes()
.expect("serialize PowerPoint artifact"),
))
.expect("open PowerPoint artifact package");
let effective_theme = powerpoint_package
.get_part("/ppt/theme/theme1.xml")
.expect("PowerPoint effective theme")
.to_vec();
word.package
.set_part("/word/theme/theme1.xml", effective_theme);
word.save(&word_path).expect("save Word chart artifact");
let word_sha = sha256(&word_path);
let powerpoint_sha = sha256(&powerpoint_path);
assert_eq!(word_sha, WORD_SHA256);
assert_eq!(powerpoint_sha, POWERPOINT_SHA256);
let version = Command::new("pdftoppm")
.arg("-v")
.output()
.expect("run pinned rasterizer");
let reported = String::from_utf8_lossy(&version.stderr);
assert!(
reported
.lines()
.next()
.is_some_and(|line| line == RASTERIZER)
);
let word_pdf =
std::env::temp_dir().join(format!("rdocx-f159-word-chart-{}.pdf", std::process::id()));
let powerpoint_pdf = std::env::temp_dir().join(format!(
"rdocx-f159-powerpoint-chart-{}.pdf",
std::process::id()
));
fs::write(
&word_pdf,
word.to_pdf_deterministic().expect("render Word chart PDF"),
)
.expect("write Word chart PDF");
fs::write(
&powerpoint_pdf,
powerpoint
.to_pdf_deterministic()
.expect("render PowerPoint chart PDF"),
)
.expect("write PowerPoint chart PDF");
let word_crop =
std::env::temp_dir().join(format!("rdocx-f159-word-chart-crop-{}", std::process::id()));
let powerpoint_crop = std::env::temp_dir().join(format!(
"rdocx-f159-powerpoint-chart-crop-{}",
std::process::id()
));
for (pdf, crop) in [(&word_pdf, &word_crop), (&powerpoint_pdf, &powerpoint_crop)] {
let output = Command::new("pdftoppm")
.args([
"-f",
"1",
"-l",
"1",
"-singlefile",
"-r",
DPI,
"-x",
CROP_X,
"-y",
CROP_Y,
"-W",
CROP_WIDTH,
"-H",
CROP_HEIGHT,
"-png",
])
.arg(pdf)
.arg(crop)
.output()
.expect("rasterize chart crop");
assert!(
output.status.success(),
"pdftoppm chart crop failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
let word_png = word_crop.with_extension("png");
let powerpoint_png = powerpoint_crop.with_extension("png");
let comparison = Command::new("python3")
.args([
"-c",
"import sys\nsys.path.insert(0, sys.argv[3])\nfrom scripts.golden_png_harness import decode_png\na=decode_png(__import__('pathlib').Path(sys.argv[1]))\nb=decode_png(__import__('pathlib').Path(sys.argv[2]))\nassert a[:2] == b[:2] == (750, 450), (a[:2], b[:2])\ndiff=sum(a[2][i:i+4] != b[2][i:i+4] for i in range(0, len(a[2]), 4))\nprint(f'{a[0]}x{a[1]} differing={diff}')\nassert diff == 0, diff",
])
.arg(&word_png)
.arg(&powerpoint_png)
.arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."))
.output()
.expect("decode and compare chart RGBA pixels");
assert!(
comparison.status.success(),
"chart pixel comparison failed: {}{}",
String::from_utf8_lossy(&comparison.stdout),
String::from_utf8_lossy(&comparison.stderr)
);
assert_eq!(
String::from_utf8(comparison.stdout)
.expect("pixel comparison output is utf8")
.trim(),
"750x450 differing=0"
);
for path in [
word_path,
powerpoint_path,
word_pdf,
powerpoint_pdf,
word_png,
powerpoint_png,
] {
fs::remove_file(path).expect("remove temporary golden evidence");
}
}
#[test]
fn added_bar_line_and_pie_charts_keep_source_data() {
let mut document = Document::new();
let expected = [
(ChartKind::Bar, f158_chart_data(2), "<c:barChart>"),
(ChartKind::Line, f158_chart_data(2), "<c:lineChart>"),
(ChartKind::Pie, f158_chart_data(1), "<c:pieChart>"),
];
for (kind, data, _) in &expected {
document
.add_chart(*kind, Length::inches(5.0), Length::inches(3.0), data)
.expect("public Word chart authoring");
}
let bytes = document.to_bytes().expect("save authored Word charts");
let reopened = Document::from_bytes(&bytes).expect("reopen authored Word charts");
let mut chart_parts = reopened
.package
.content_types
.overrides
.iter()
.filter_map(|(part, content_type)| {
(content_type == content_types::CHART).then_some(part.as_str())
})
.collect::<Vec<_>>();
chart_parts.sort_unstable();
assert_eq!(chart_parts.len(), expected.len());
for (part, (_, data, plot_tag)) in chart_parts.into_iter().zip(expected) {
let xml = reopened
.package
.get_part(part)
.expect("authored chart part");
assert!(
std::str::from_utf8(xml)
.expect("chart XML is utf8")
.contains(plot_tag)
);
let chart = CT_ChartSpace::from_xml(xml).expect("typed authored chart");
assert_authored_chart_matches(&chart, &data);
}
}
#[test]
fn word_add_chart_writes_cache_and_workbook_from_one_source() {
let data = f158_chart_data(2);
let mut document = Document::new();
document
.add_chart(
ChartKind::Bar,
Length::inches(5.0),
Length::inches(3.0),
&data,
)
.expect("author Word chart");
let bytes = document.to_bytes().expect("save Word chart");
let package = Document::from_bytes(&bytes)
.expect("reopen Word chart")
.package;
let chart_part = "/word/charts/chart1.xml";
let chart = CT_ChartSpace::from_xml(package.get_part(chart_part).expect("chart part"))
.expect("parse chart part");
assert_authored_chart_matches(&chart, &data);
let series = chart.chart.plot_area.series().expect("typed chart series");
for (actual, column) in series.iter().zip(['B', 'C']) {
assert_eq!(
actual.name.as_ref().expect("series name").formula,
format!("Sheet1!${column}$1")
);
let AxisData::String(categories) = actual.categories.as_ref().expect("categories")
else {
panic!("authored bar chart uses string categories");
};
assert_eq!(categories.formula, "Sheet1!$A$2:$A$4");
assert_eq!(
actual.values.formula,
format!("Sheet1!${column}$2:${column}$4")
);
}
let workbook_relationship = package
.get_part_rels(chart_part)
.and_then(|relationships| relationships.get_by_type(rel_types::PACKAGE))
.expect("chart workbook relationship");
let workbook_part =
OpcPackage::resolve_rel_target(chart_part, &workbook_relationship.target);
let workbook = OpcPackage::from_reader(Cursor::new(
package.get_part(&workbook_part).expect("workbook part"),
))
.expect("open embedded workbook");
let worksheet = std::str::from_utf8(
workbook
.get_part("/xl/worksheets/sheet1.xml")
.expect("worksheet"),
)
.expect("worksheet XML is utf8");
let shared_strings = std::str::from_utf8(
workbook
.get_part("/xl/sharedStrings.xml")
.expect("shared strings"),
)
.expect("shared strings XML is utf8");
let shared_values = shared_strings
.split("<si>")
.skip(1)
.map(|item| {
item.split_once("<t>")
.expect("shared string text start")
.1
.split_once("</t>")
.expect("shared string text end")
.0
})
.collect::<Vec<_>>();
for (address, value) in [
("A1", "Category"),
("A2", "North"),
("A3", "South"),
("A4", "West"),
("B1", "Revenue"),
("C1", "Cost"),
] {
let index = worksheet_cell_value(worksheet, address)
.parse::<usize>()
.expect("shared string index");
assert_eq!(shared_values[index], value);
}
for (address, value) in [
("B2", "12.5"),
("B3", "19"),
("B4", "14.25"),
("C2", "8"),
("C3", "11.5"),
("C4", "9.75"),
] {
assert_eq!(worksheet_cell_value(worksheet, address), value);
}
}
fn worksheet_cell_value<'a>(worksheet: &'a str, address: &str) -> &'a str {
let marker = format!(r#"<c r="{address}""#);
worksheet
.split_once(&marker)
.unwrap_or_else(|| panic!("missing worksheet cell {address}"))
.1
.split_once("</c>")
.expect("worksheet cell end")
.0
.split_once("<v>")
.expect("worksheet value start")
.1
.split_once("</v>")
.expect("worksheet value end")
.0
}
#[test]
fn word_add_chart_rejects_invalid_data_without_mutation() {
let invalid = [
ChartData {
categories: Vec::new(),
series: vec![("Revenue".to_owned(), Vec::new())],
number_format: None,
..ChartData::default()
},
ChartData {
categories: vec!["North".to_owned()],
series: Vec::new(),
number_format: None,
..ChartData::default()
},
ChartData {
categories: vec!["North".to_owned(), "South".to_owned()],
series: vec![("Revenue".to_owned(), vec![12.5])],
number_format: None,
..ChartData::default()
},
ChartData {
categories: vec!["North".to_owned()],
series: vec![("Revenue".to_owned(), vec![f64::NAN])],
number_format: None,
..ChartData::default()
},
ChartData {
categories: vec!["North".to_owned()],
series: vec![("Revenue".to_owned(), vec![12.5])],
number_format: Some(String::new()),
..ChartData::default()
},
];
for data in invalid {
let mut document = Document::new();
let before = document.to_bytes().expect("state before invalid chart");
assert!(
document
.add_chart(
ChartKind::Bar,
Length::inches(5.0),
Length::inches(3.0),
&data,
)
.is_err()
);
assert_eq!(
document.to_bytes().expect("state after invalid chart"),
before
);
}
let invalid_cases = [
(ChartKind::Pie, Length::inches(5.0), Length::inches(3.0)),
(ChartKind::Bar, Length::emu(0), Length::inches(3.0)),
(ChartKind::Bar, Length::emu(-1), Length::inches(3.0)),
(ChartKind::Bar, Length::inches(5.0), Length::emu(0)),
(ChartKind::Bar, Length::inches(5.0), Length::emu(-1)),
];
for (kind, width, height) in invalid_cases {
let mut document = Document::new();
let before = document.to_bytes().expect("state before invalid chart");
assert!(
document
.add_chart(kind, width, height, &f158_chart_data(2))
.is_err()
);
assert_eq!(
document.to_bytes().expect("state after invalid chart"),
before
);
}
}
#[test]
fn fx215_word_chart_after_direct_paragraph_is_atomic_and_ordered() {
let mut document = Document::new();
document.add_paragraph("before");
document.add_table(1, 1);
document.add_paragraph("after");
document
.add_chart_after(
0,
ChartKind::Bar,
Length::inches(4.0),
Length::inches(2.0),
&f158_chart_data(2),
)
.unwrap();
assert!(matches!(
document.document.body.content[1],
BodyContent::Paragraph(_)
));
assert!(matches!(
document.document.body.content[2],
BodyContent::Table(_)
));
assert_eq!(document.paragraph(0).unwrap().text(), "before");
assert_eq!(document.paragraph(2).unwrap().text(), "after");
let before = document.to_bytes().unwrap();
assert!(
document
.add_chart_after(
usize::MAX,
ChartKind::Line,
Length::inches(4.0),
Length::inches(2.0),
&f158_chart_data(2)
)
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), before);
let mut invalid = f158_chart_data(2);
invalid.series[0].1[0] = f64::NAN;
assert!(
document
.add_chart_after(
0,
ChartKind::Line,
Length::inches(4.0),
Length::inches(2.0),
&invalid
)
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), before);
let reopened = Document::from_bytes(&before).unwrap();
assert_eq!(reopened.paragraph_count(), 3);
assert_eq!(reopened.table_count(), 1);
}
#[test]
fn word_add_chart_uses_inline_flow_placement() {
let mut document = Document::new();
let width = Length::inches(5.0);
let height = Length::inches(3.0);
let paragraph = document
.add_chart(ChartKind::Line, width, height, &f158_chart_data(2))
.expect("author inline Word chart");
assert_eq!(paragraph.run_count(), 1);
assert_eq!(document.content_count(), 1);
let BodyContent::Paragraph(paragraph) = &document.document.body.content[0] else {
panic!("Word chart must be inline flow content");
};
let RunContent::Drawing(drawing) = ¶graph.runs[0].content[0] else {
panic!("Word chart paragraph must carry a drawing run");
};
let inline = drawing.inline.as_ref().expect("inline chart drawing");
assert!(drawing.anchor.is_none());
assert_eq!(inline.extent_cx.0, width.to_emu());
assert_eq!(inline.extent_cy.0, height.to_emu());
assert!(inline.chart_rel_id.is_some());
}
#[test]
#[ignore = "requires pinned Microsoft Word and human Edit Data evidence"]
fn word_opens_native_chart_without_repair() {
let output = std::env::var_os("RDOCX_WORD_CHART_GATE_OUTPUT")
.map(std::path::PathBuf::from)
.expect("set RDOCX_WORD_CHART_GATE_OUTPUT to the SHA-bound .docx path");
let mut document = document_with_minimal_chart();
document.save(&output).expect("write Word chart candidate");
assert_eq!(sha256(&output), WORD_CHART_CANDIDATE_SHA256);
let plist = "/Applications/Microsoft Word.app/Contents/Info.plist";
assert_eq!(
plist_value(plist, "CFBundleShortVersionString"),
WORD_VERSION
);
assert_eq!(plist_value(plist, "CFBundleVersion"), WORD_BUILD);
let script = format!(
"with timeout of 120 seconds\ntell application \"Microsoft Word\"\nactivate\nopen POSIX file \"{}\"\ndelay 3\nset gateDocument to active document\ntry\nset openedPath to POSIX path of (full name of gateDocument as alias)\nif openedPath is not \"{}\" then error \"Word chart candidate path mismatch\"\nclose gateDocument saving no\non error errorMessage number errorNumber\ntry\nclose gateDocument saving no\nend try\nerror errorMessage number errorNumber\nend try\nend tell\nend timeout\n",
output.display(),
output.display(),
);
let result = Command::new("osascript")
.args(["-e", &script])
.output()
.expect("launch Word acceptance script");
assert!(
result.status.success(),
"Microsoft Word F-157 acceptance failed: {}",
String::from_utf8_lossy(&result.stderr)
);
}
#[test]
fn word_chart_candidate_is_bound_to_recorded_sha() {
let output =
std::env::temp_dir().join(format!("rdocx-f157-word-chart-{}.docx", std::process::id()));
document_with_minimal_chart()
.save(&output)
.expect("write SHA-bound candidate");
assert_eq!(sha256(&output), WORD_CHART_CANDIDATE_SHA256);
fs::remove_file(output).expect("remove temporary candidate");
}
fn sha256(path: &Path) -> String {
let output = Command::new("shasum")
.args(["-a", "256"])
.arg(path)
.output()
.unwrap_or_else(|error| panic!("{}: run shasum: {error}", path.display()));
assert!(
output.status.success(),
"shasum failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout)
.expect("shasum output is utf8")
.split_whitespace()
.next()
.expect("shasum digest")
.to_owned()
}
fn extracted_logical_lines(label: &str, pdf: &[u8]) -> Vec<String> {
let path =
std::env::temp_dir().join(format!("rdocx-fx092-{label}-{}.pdf", std::process::id()));
fs::write(&path, pdf).expect("write logical-order PDF");
let output = Command::new("pdftotext")
.arg(&path)
.arg("-")
.output()
.expect("run pinned pdftotext");
fs::remove_file(path).expect("remove logical-order PDF");
assert!(
output.status.success(),
"pdftotext failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout)
.expect("pdftotext output is UTF-8")
.lines()
.map(str::trim)
.filter(|line| line.starts_with("FX092-"))
.map(str::to_owned)
.collect()
}
fn rendered_pdf_page_sha(label: &str, pdf: &[u8]) -> String {
let prefix =
std::env::temp_dir().join(format!("rdocx-fx092-{label}-render-{}", std::process::id()));
let pdf_path = prefix.with_extension("pdf");
let png_path = prefix.with_extension("png");
fs::write(&pdf_path, pdf).expect("write PDF raster candidate");
let output = Command::new("pdftoppm")
.args(["-png", "-r", "72", "-f", "1", "-l", "1", "-singlefile"])
.arg(&pdf_path)
.arg(&prefix)
.output()
.expect("run pinned pdftoppm");
assert!(
output.status.success(),
"pdftoppm failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let digest = sha256(&png_path);
fs::remove_file(pdf_path).expect("remove PDF raster candidate");
fs::remove_file(png_path).expect("remove PDF raster output");
digest
}
#[test]
fn large_word_and_presentation_pdfs_preserve_logical_reading_order() {
let version = Command::new("pdftotext")
.arg("-v")
.output()
.expect("run pinned pdftotext version check");
assert_eq!(
String::from_utf8_lossy(&version.stderr).lines().next(),
Some("pdftotext version 26.01.0")
);
let version = Command::new("pdftoppm")
.arg("-v")
.output()
.expect("run pinned pdftoppm version check");
assert_eq!(
String::from_utf8_lossy(&version.stderr).lines().next(),
Some("pdftoppm version 26.01.0")
);
let mut word = Document::new();
let mut expected_word = Vec::new();
for index in 0..120 {
let first = format!("FX092-WORD-{index:03} alpha ");
let expected = format!("{first}䏿–‡ beta gamma");
let mut paragraph = word.add_paragraph("");
paragraph.add_run(&first);
paragraph.add_run("䏿–‡ ").bold(index % 2 == 0);
paragraph.add_run("beta gamma").italic(index % 2 == 1);
expected_word.push(expected);
}
let mut presentation = rpptx::Presentation::new().expect("create presentation");
let mut expected_presentation = Vec::new();
for slide_index in 0..12 {
presentation.add_slide(6).expect("add blank slide");
let mut slide = presentation.slide_mut(slide_index).expect("new slide");
for row in 0..4 {
let line_index = slide_index * 4 + row;
let first = format!("FX092-PPT-{line_index:03} alpha ");
let expected = format!("{first}䏿–‡ beta gamma");
let mut shape = slide
.add_textbox(
Length::inches(0.75).as_emu(),
Length::inches(0.5 + row as f64 * 1.25).as_emu(),
Length::inches(8.5).as_emu(),
Length::inches(0.75).as_emu(),
)
.expect("add logical-order textbox");
shape.set_text(&first).expect("set first text run");
let mut frame = shape.text_frame().expect("textbox text frame");
let mut paragraph = frame.paragraph_mut(0).expect("textbox paragraph");
paragraph.add_run("䏿–‡ ");
paragraph.add_run("beta gamma");
expected_presentation.push(expected);
}
}
let word_pdf = word.to_pdf_deterministic().expect("render Word PDF");
assert_eq!(extracted_logical_lines("word", &word_pdf), expected_word);
// Requested bold variable CJK selects weight 700 instead of the default Thin face.
// Paired source-bound captures isolate the instance advance and outline change.
assert_eq!(
rendered_pdf_page_sha("word", &word_pdf),
"6b0dd949e017453379f25dde824d2d0dadd108afc3ce99a1c09dc81045960c16"
);
// The all-regular control retains the same three-run segmentation and default face.
let mut regular = Document::new();
for index in 0..120 {
let mut paragraph = regular.add_paragraph("");
paragraph.add_run(&format!("FX092-WORD-{index:03} alpha "));
paragraph.add_run("䏿–‡ ");
paragraph.add_run("beta gamma");
}
let regular_pdf = regular
.to_pdf_deterministic()
.expect("render regular Word PDF");
assert_eq!(
extracted_logical_lines("word-regular", ®ular_pdf),
expected_word
);
assert_eq!(
regular
.to_pdf_deterministic()
.expect("repeat regular Word PDF"),
regular_pdf
);
assert_eq!(
rendered_pdf_page_sha("word-regular", ®ular_pdf),
"f902ee1e9e2198ea4ebe172004d8e7f0e69ecf45d98c63fa02bbd93564e64a0b"
);
let presentation_pdf = presentation
.to_pdf_deterministic()
.expect("render presentation PDF");
assert_eq!(
extracted_logical_lines("presentation", &presentation_pdf),
expected_presentation
);
// Recorded after PowerPoint line pitch, baseline placement and UAX 14
// spans moved the slide text, with the pinned Poppler build.
assert_eq!(
rendered_pdf_page_sha("presentation", &presentation_pdf),
"963dc460e110aa260b7b0a4fbe4a77824b9cf9efe160ca45fdaf30ca08e31f52"
);
}
fn plist_value(path: &str, key: &str) -> String {
let output = Command::new("defaults")
.args(["read", path.trim_end_matches(".plist"), key])
.output()
.expect("read application plist");
assert!(output.status.success());
String::from_utf8(output.stdout)
.expect("plist value is utf8")
.trim()
.to_owned()
}
#[test]
fn html_and_layout_media_use_sniffed_content_type() {
let jpeg = [0xff, 0xd8, 0xff, 0xd9];
let mut document = Document::new();
document
.package
.set_part("/word/media/misleading.png", jpeg.to_vec());
let relationship_id = document
.package
.get_or_create_part_rels("/word/document.xml")
.add(rel_types::IMAGE, "media/misleading.png");
let html_input = document.build_html_input();
let layout_input = document.build_layout_input().unwrap();
assert_eq!(
html_input.images[&relationship_id].content_type,
"image/jpeg"
);
assert_eq!(
layout_input.images[&relationship_id].content_type,
"image/jpeg"
);
}
#[test]
fn deterministic_render_is_independent_of_system_fonts() {
let mut doc = Document::new();
doc.add_paragraph("Deterministic rendering");
let input = doc.build_layout_input().unwrap();
let layout = rdocx_layout::layout_document_deterministic(&input)
.expect("deterministic layout should succeed");
let bundled_fonts = oxml_layout::bundled_fonts::bundled_font_data();
assert!(!layout.fonts.is_empty());
for font in &layout.fonts {
assert!(!font.data.is_empty());
assert!(
bundled_fonts
.iter()
.any(|(_family, data)| *data == font.data.as_ref()),
"resolved font '{}' did not come from the bundled font set",
font.family
);
}
let inspected = oxml_pdf::render_page_to_png(&layout, 0, 150.0)
.expect("document should have a first page");
let facade = doc
.render_page_to_png_deterministic(0, 150.0)
.expect("deterministic layout should succeed")
.expect("document should have a first page");
assert!(!inspected.is_empty());
assert_eq!(facade, inspected);
}
#[test]
fn deterministic_pdf_facade_reuses_bundled_font_layout() {
let mut doc = Document::new();
doc.add_paragraph("Deterministic PDF rendering");
reset_layout_invocations();
let first = doc
.to_pdf_deterministic()
.expect("deterministic PDF rendering should succeed");
let second = doc
.to_pdf_deterministic()
.expect("cached deterministic PDF rendering should succeed");
assert!(first.starts_with(b"%PDF-"));
assert!(second.starts_with(b"%PDF-"));
assert_eq!(layout_invocations(), 1);
}
#[test]
fn create_new_document() {
let doc = Document::new();
assert_eq!(doc.paragraph_count(), 0);
assert!(doc.section_properties().is_some());
}
#[test]
fn add_paragraphs() {
let mut doc = Document::new();
doc.add_paragraph("First paragraph");
doc.add_paragraph("Second paragraph");
assert_eq!(doc.paragraph_count(), 2);
let paras = doc.paragraphs();
assert_eq!(paras[0].text(), "First paragraph");
assert_eq!(paras[1].text(), "Second paragraph");
}
#[test]
fn document_text_preserves_body_and_table_order() {
let mut doc = Document::new();
doc.add_paragraph("Before");
let mut table = doc.add_table(1, 2);
table.cell(0, 0).unwrap().set_text("Left");
table.cell(0, 1).unwrap().set_text("Right");
doc.add_paragraph("After");
assert_eq!(doc.text(), "Before\nLeft\tRight\t\nAfter\n");
}
#[test]
fn paragraph_formatting() {
let mut doc = Document::new();
doc.add_paragraph("Centered").alignment(Alignment::Center);
let paras = doc.paragraphs();
assert_eq!(paras[0].alignment(), Some(Alignment::Center));
}
#[test]
fn run_formatting() {
let mut doc = Document::new();
let mut para = doc.add_paragraph("");
para.add_run("Bold text").bold(true).size(14.0);
let paras = doc.paragraphs();
let runs: Vec<_> = paras[0].runs().collect();
assert!(runs[0].is_bold());
assert_eq!(runs[0].size(), Some(14.0));
}
#[test]
fn round_trip_in_memory() {
let mut doc = Document::new();
doc.add_paragraph("Hello, World!");
doc.add_paragraph("Second paragraph")
.alignment(Alignment::Center);
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraph_count(), 2);
let paras = doc2.paragraphs();
assert_eq!(paras[0].text(), "Hello, World!");
assert_eq!(paras[1].text(), "Second paragraph");
assert_eq!(paras[1].alignment(), Some(Alignment::Center));
}
#[test]
fn styles_present() {
let doc = Document::new();
assert!(doc.style("Normal").is_some());
assert!(doc.style("Heading1").is_some());
}
#[test]
fn paragraph_with_style() {
let mut doc = Document::new();
doc.add_paragraph("Title").style("Heading1");
let paras = doc.paragraphs();
assert_eq!(paras[0].style_id(), Some("Heading1"));
}
#[test]
fn multiple_runs_in_paragraph() {
let mut doc = Document::new();
let mut para = doc.add_paragraph("");
para.add_run("Normal ");
para.add_run("bold ").bold(true);
para.add_run("italic").italic(true);
let paras = doc.paragraphs();
assert_eq!(paras[0].text(), "Normal bold italic");
let runs: Vec<_> = paras[0].runs().collect();
assert_eq!(runs.len(), 3);
assert!(!runs[0].is_bold());
assert!(runs[1].is_bold());
assert!(runs[2].is_italic());
}
#[test]
fn add_custom_style() {
let mut doc = Document::new();
doc.add_style(StyleBuilder::paragraph("MyCustom", "My Custom Style").based_on("Normal"))
.unwrap();
assert!(doc.style("MyCustom").is_some());
let s = doc.style("MyCustom").unwrap();
assert_eq!(s.name(), Some("My Custom Style"));
assert_eq!(s.based_on(), Some("Normal"));
}
#[test]
fn resolve_style_properties() {
let doc = Document::new();
// Heading1 should inherit from docDefaults and have its own overrides
let ppr = doc.resolve_paragraph_properties(Some("Heading1"));
assert_eq!(ppr.keep_next, Some(true));
assert_eq!(ppr.space_before, Some(Twips(240)));
// Default (None) should apply Normal style
let ppr = doc.resolve_paragraph_properties(None);
assert_eq!(ppr.space_after, Some(Twips(160)));
}
#[test]
fn resolve_run_style_properties() {
let doc = Document::new();
let rpr = doc.resolve_run_properties(Some("Heading1"), None);
assert_eq!(rpr.bold, Some(true));
assert_eq!(rpr.sz, Some(HalfPoint(32)));
assert_eq!(rpr.font_ascii, Some("Calibri".to_string()));
}
#[test]
fn set_landscape() {
let mut doc = Document::new();
doc.set_landscape();
let sect = doc.section_properties().unwrap();
assert_eq!(sect.orientation, Some(ST_PageOrientation::Landscape));
// Width should be > height in landscape
assert!(sect.page_width.unwrap().0 > sect.page_height.unwrap().0);
}
#[test]
fn set_margins() {
let mut doc = Document::new();
doc.set_margins(
Length::inches(0.5),
Length::inches(0.75),
Length::inches(0.5),
Length::inches(0.75),
);
let sect = doc.section_properties().unwrap();
assert_eq!(sect.margin_top, Some(Twips(720)));
assert_eq!(sect.margin_right, Some(Twips(1080)));
}
#[test]
fn set_columns() {
let mut doc = Document::new();
doc.set_columns(2, Length::inches(0.5));
let sect = doc.section_properties().unwrap();
let cols = sect.columns.as_ref().unwrap();
assert_eq!(cols.num, Some(2));
assert_eq!(cols.space, Some(Twips(720)));
assert_eq!(cols.equal_width, Some(true));
}
#[test]
fn set_page_size() {
let mut doc = Document::new();
doc.set_page_size(Length::cm(21.0), Length::cm(29.7));
let sect = doc.section_properties().unwrap();
// A4: ~11906tw x ~16838tw
let w = sect.page_width.unwrap().0;
let h = sect.page_height.unwrap().0;
assert!((w - 11906).abs() < 5);
assert!((h - 16838).abs() < 5);
}
#[test]
fn set_different_first_page() {
let mut doc = Document::new();
doc.set_different_first_page(true);
assert_eq!(doc.section_properties().unwrap().title_pg, Some(true));
}
#[test]
fn content_insertion_api() {
let mut doc = Document::new();
doc.add_paragraph("First");
doc.add_paragraph("Third");
// Insert in middle
doc.insert_paragraph(1, "Second");
assert_eq!(doc.content_count(), 3);
let paras = doc.paragraphs();
assert_eq!(paras[0].text(), "First");
assert_eq!(paras[1].text(), "Second");
assert_eq!(paras[2].text(), "Third");
// Insert at beginning
doc.insert_paragraph(0, "Zeroth");
assert_eq!(doc.content_count(), 4);
assert_eq!(doc.paragraphs()[0].text(), "Zeroth");
}
#[test]
fn find_content_index_and_remove() {
let mut doc = Document::new();
doc.add_paragraph("Hello");
doc.add_paragraph("{{PLACEHOLDER}}");
doc.add_paragraph("World");
assert_eq!(doc.find_content_index("{{PLACEHOLDER}}"), Some(1));
assert_eq!(doc.find_content_index("NONEXISTENT"), None);
assert!(doc.remove_content(1));
assert_eq!(doc.content_count(), 2);
assert_eq!(doc.paragraphs()[1].text(), "World");
// Out of bounds
assert!(!doc.remove_content(10));
}
#[test]
fn insert_table_at_index() {
let mut doc = Document::new();
doc.add_paragraph("Before");
doc.add_paragraph("After");
doc.insert_table(1, 2, 3);
assert_eq!(doc.content_count(), 3);
assert_eq!(doc.table_count(), 1);
// Paragraphs are still in correct order
let paras = doc.paragraphs();
assert_eq!(paras[0].text(), "Before");
assert_eq!(paras[1].text(), "After");
}
#[test]
fn replace_text_in_body() {
let mut doc = Document::new();
doc.add_paragraph("Hello {{name}}!");
doc.add_paragraph("Welcome to {{company}}.");
let count = doc.replace_text("{{name}}", "Alice");
assert_eq!(count, 1);
assert_eq!(doc.paragraphs()[0].text(), "Hello Alice!");
let count = doc.replace_text("{{company}}", "Acme");
assert_eq!(count, 1);
assert_eq!(doc.paragraphs()[1].text(), "Welcome to Acme.");
}
#[test]
fn a_tag_split_across_five_formatted_runs_preserves_surrounding_formatting() {
let mut doc = Document::new();
let mut paragraph = CT_P::new();
for (text, properties) in [
(
"Before {",
CT_RPr {
bold: Some(true),
..Default::default()
},
),
(
"{ pro",
CT_RPr {
italic: Some(true),
..Default::default()
},
),
(
"file.",
CT_RPr {
color: Some("112233".to_owned()),
..Default::default()
},
),
(
"name ",
CT_RPr {
strike: Some(true),
..Default::default()
},
),
(
"}} after",
CT_RPr {
italic: Some(false),
..Default::default()
},
),
] {
let mut run = CT_R::new(text);
run.properties = Some(properties);
paragraph.runs.push(run);
}
doc.document
.body
.content
.push(BodyContent::Paragraph(paragraph));
let count = doc
.render_template(&serde_json::json!({"profile": {"name": "Ada"}}))
.expect("valid template should render");
assert_eq!(count, 1);
let paragraph = doc.paragraph(0).expect("rendered paragraph");
assert_eq!(paragraph.text(), "Before Ada after");
assert_eq!(paragraph.run_count(), 2);
assert_eq!(paragraph.run(0).unwrap().text(), "Before Ada");
assert!(paragraph.run(0).unwrap().is_bold());
assert_eq!(paragraph.run(1).unwrap().text(), " after");
assert_eq!(paragraph.run(1).unwrap().italic_value(), Some(false));
}
#[test]
fn dotted_scalar_paths_render_supported_json_leaves() {
let mut doc = Document::new();
doc.add_paragraph("{{ person.name }}");
doc.add_paragraph("{{person.age}}");
doc.add_paragraph("{{ person.active }}");
doc.add_paragraph("x{{person.middle}}y");
let count = doc
.render_template(&serde_json::json!({
"person": {
"name": "Ada",
"age": 37,
"active": true,
"middle": null
}
}))
.expect("scalar leaves should render");
assert_eq!(count, 4);
assert_eq!(
doc.paragraphs()
.into_iter()
.map(|paragraph| paragraph.text())
.collect::<Vec<_>>(),
["Ada", "37", "true", "xy"]
);
}
#[test]
fn invalid_template_input_leaves_the_document_unchanged() {
for (template, data) in [
(
"{{present}} {{missing}}",
serde_json::json!({"present": "value"}),
),
("{{value}}", serde_json::json!({"value": [1, 2]})),
("{{value}}", serde_json::json!({"value": {"nested": 1}})),
("{{ malformed", serde_json::json!({"malformed": "value"})),
] {
let mut doc = Document::new();
doc.add_paragraph(template);
let before_xml = doc.document.to_xml().expect("serialize before render");
let before_parts = doc.package.parts.clone();
reset_layout_invocations();
doc.render_page_to_png_deterministic(0, 1.0)
.expect("warm deterministic layout cache");
assert_eq!(layout_invocations(), 1);
assert!(doc.render_template(&data).is_err());
assert_eq!(
doc.document.to_xml().expect("serialize after rejection"),
before_xml
);
assert_eq!(doc.package.parts, before_parts);
doc.render_page_to_png_deterministic(0, 1.0)
.expect("reuse deterministic layout after rejection");
assert_eq!(layout_invocations(), 1);
}
}
#[test]
fn template_scalar_coverage_matches_literal_replacement() {
let mut doc = Document::new();
doc.add_paragraph("Body {{body}}");
doc.set_header("Header {{header}}");
doc.set_footer("Footer {{footer}}");
doc.add_table(1, 1)
.cell(0, 0)
.expect("template table cell")
.set_text("Table {{table}}");
doc.document.body.content.push(BodyContent::RawXml(
br#"<w:custom><w:txbxContent><w:p><w:r><w:t>Box {{box}}</w:t></w:r></w:p></w:txbxContent></w:custom>"#.to_vec(),
));
let chart_part = "/word/charts/chart99.xml";
doc.package.set_part(
chart_part,
br#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><c:chartSpace xmlns:c="http://schemas.openxmlformats.org/drawingml/2006/chart" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><c:title><a:p><a:r><a:t>{{chart}}</a:t></a:r></a:p></c:title><c:strCache><c:pt><c:v>{{cache}}</c:v></c:pt></c:strCache></c:chartSpace>"#.to_vec(),
);
doc.package
.get_or_create_part_rels(&doc.doc_part_name.clone())
.add(rel_types::CHART, "charts/chart99.xml");
let count = doc
.render_template(&serde_json::json!({
"body": "B",
"header": "H",
"footer": "F",
"table": "T",
"box": "X",
"chart": "C",
"cache": "K"
}))
.expect("all literal-replacement locations should render");
assert_eq!(count, 7);
assert_eq!(doc.paragraphs()[0].text(), "Body B");
assert_eq!(
doc.table(0)
.expect("template table")
.cell(0, 0)
.expect("rendered table cell")
.text(),
"Table T"
);
assert_eq!(doc.header_text().as_deref(), Some("Header H"));
assert_eq!(doc.footer_text().as_deref(), Some("Footer F"));
let document_xml = std::str::from_utf8(
doc.package
.get_part(&doc.doc_part_name)
.expect("main document package part"),
)
.expect("main document XML");
assert!(document_xml.contains("Box X"));
let chart_xml = std::str::from_utf8(
doc.package
.get_part(chart_part)
.expect("chart package part"),
)
.expect("chart XML");
assert!(chart_xml.contains("<a:t>C</a:t>"));
assert!(chart_xml.contains("<c:v>K</c:v>"));
}
#[test]
fn template_values_are_not_recursively_interpreted() {
let mut doc = Document::new();
doc.add_paragraph("{{first}} {{second}}");
doc.add_paragraph("{% if later %}");
doc.add_paragraph("hidden");
doc.add_paragraph("{% endif %}");
assert_eq!(
doc.render_template(&serde_json::json!({
"first": "{{second}}",
"second": "done",
"later": false
}))
.expect("scalar values and controls should render"),
2
);
assert_eq!(doc.paragraphs().len(), 1);
assert_eq!(doc.paragraphs()[0].text(), "{{second}} done");
}
#[test]
fn template_render_preserves_unmodelled_paragraph_xml() {
let raw = br#"<w:proofErr w:type="spellStart"/>"#.to_vec();
let mut doc = Document::new();
let mut paragraph = CT_P::new();
paragraph.runs.push(CT_R::new("Value: {{ value }}"));
paragraph.extra_xml.push((1, raw.clone()));
doc.document
.body
.content
.push(BodyContent::Paragraph(paragraph));
assert_eq!(
doc.render_template(&serde_json::json!({"value": "kept"}))
.expect("valid template should render"),
1
);
let reopened = Document::from_bytes(&doc.to_bytes().expect("save rendered document"))
.expect("reopen rendered document");
let BodyContent::Paragraph(paragraph) = &reopened.document.body.content[0] else {
panic!("expected paragraph");
};
assert_eq!(paragraph.text(), "Value: kept");
assert_eq!(paragraph.extra_xml, vec![(1, raw)]);
}
#[test]
fn mismatched_or_cross_container_blocks_fail_without_mutation() {
let cases = [
vec!["{% if show %}", "content"],
vec!["{% endif %}"],
vec![
"{% for item in items %}",
"{% if item.show %}",
"{% endfor %}",
"{% endif %}",
],
];
for paragraphs in cases {
let mut document = Document::new();
for paragraph in paragraphs {
document.add_paragraph(paragraph);
}
let before = document.document.to_xml().unwrap();
assert!(
document
.render_template(&serde_json::json!({
"show": true,
"items": [{"show": true}]
}))
.is_err()
);
assert_eq!(document.document.to_xml().unwrap(), before);
}
let mut document = Document::new();
document.add_paragraph("{% for item in items %}");
document
.add_table(1, 1)
.cell(0, 0)
.unwrap()
.set_text("{% endfor %}");
let before = document.document.to_xml().unwrap();
assert!(
document
.render_template(&serde_json::json!({"items": [1]}))
.is_err()
);
assert_eq!(document.document.to_xml().unwrap(), before);
}
#[test]
fn loop_scopes_shadow_root_values_and_restore_after_exit() {
let mut document = Document::new();
for text in [
"{% for item in items %}",
"outer {{ item.name }}",
"{% for item in item.children %}",
"inner {{ item.name }}",
"{% endfor %}",
"restored {{ item.name }}",
"{% endfor %}",
"root {{ item.name }}",
] {
document.add_paragraph(text);
}
document
.render_template(&serde_json::json!({
"item": {"name": "root"},
"items": [{
"name": "outer",
"children": [{"name": "inner-a"}, {"name": "inner-b"}]
}]
}))
.unwrap();
assert_eq!(
document
.paragraphs()
.into_iter()
.map(|paragraph| paragraph.text())
.collect::<Vec<_>>(),
[
"outer outer",
"inner inner-a",
"inner inner-b",
"restored outer",
"root root"
]
);
}
#[test]
fn structural_generation_preserves_schema_order_and_raw_xml() {
let mut document = Document::new();
document.add_paragraph("{% for item in items %}");
let mut generated = CT_P::new();
generated.runs.push(CT_R::new("section {{ item }}"));
generated
.extra_xml
.push((1, br#"<w:proofErr w:type="spellStart"/>"#.to_vec()));
generated.properties = Some(CT_PPr {
sect_pr: Some(CT_SectPr::default_letter()),
..Default::default()
});
document
.document
.body
.content
.push(BodyContent::Paragraph(generated));
document.add_paragraph("{% endfor %}");
let row = |text: &str, raw: Option<Vec<u8>>| {
let mut paragraph = CT_P::new();
paragraph.runs.push(CT_R::new(text));
let mut cell = CT_Tc::new();
cell.content = vec![CellContent::Paragraph(paragraph)];
let mut row = CT_Row::new();
row.cells.push(cell);
if let Some(raw) = raw {
row.extra_xml.push((0, raw));
}
row
};
let mut table = CT_Tbl::new();
table.rows.push(row("{% for item in items %}", None));
table.rows.push(row(
"row {{ item }}",
Some(br#"<w:customRow w:val="kept"/>"#.to_vec()),
));
table.rows.push(row("{% endfor %}", None));
document
.document
.body
.content
.push(BodyContent::Table(table));
document
.render_template(&serde_json::json!({"items": ["one", "two"]}))
.unwrap();
let bytes = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&bytes).unwrap();
assert_eq!(reopened.document.body.content.len(), 3);
for content in &reopened.document.body.content[..2] {
let BodyContent::Paragraph(paragraph) = content else {
panic!("expected generated section-ending paragraph");
};
assert_eq!(
paragraph.extra_xml,
vec![(1, br#"<w:proofErr w:type="spellStart"/>"#.to_vec())]
);
assert!(
paragraph
.properties
.as_ref()
.and_then(|properties| properties.sect_pr.as_ref())
.is_some()
);
}
let BodyContent::Table(table) = &reopened.document.body.content[2] else {
panic!("expected generated table");
};
assert_eq!(table.rows.len(), 2);
assert_eq!(
table
.rows
.iter()
.map(|row| row.cells[0].text())
.collect::<Vec<_>>(),
["row one", "row two"]
);
assert!(
table.rows.iter().all(|row| {
row.extra_xml == vec![(0, br#"<w:customRow w:val="kept"/>"#.to_vec())]
})
);
let package = OpcPackage::from_reader(std::io::Cursor::new(bytes)).unwrap();
let xml = std::str::from_utf8(package.get_part("/word/document.xml").unwrap()).unwrap();
assert!(xml.rfind("<w:p>").unwrap() < xml.rfind("<w:sectPr>").unwrap());
}
#[test]
fn repeated_rows_and_lists_preserve_properties_and_raw_xml() {
use rdocx_oxml::table::{
CT_TblGrid, CT_TblGridCol, CT_TblLook, CT_TblPr, CT_TcPr, CT_TrPr, VMerge,
};
let mut document = Document::new();
document.add_paragraph("{% for item in items %}");
document.add_numbered_list_item("List {{ item }}", 1);
document.add_paragraph("Detail {{ item }}");
document.add_paragraph("{% endfor %}");
let row_raw = br#"<q:rowData xmlns:q="urn:rdocx:f165" q:value="kept"/>"#.to_vec();
let cell_property_raw =
br#"<q:cellProperty xmlns:q="urn:rdocx:f165" q:value="kept"/>"#.to_vec();
let cell_raw = br#"<q:cellData xmlns:q="urn:rdocx:f165" q:value="kept"/>"#.to_vec();
let table_raw = br#"<q:tableData xmlns:q="urn:rdocx:f165" q:value="kept"/>"#.to_vec();
let control_xml = br#"<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:sdt><w:sdtPr><w:tag w:val="row-control"/></w:sdtPr><w:sdtContent><w:p><w:r><w:t>controlled</w:t></w:r></w:p></w:sdtContent></w:sdt><w:sectPr/></w:body></w:document>"#;
let mut control_document = CT_Document::from_xml(control_xml).unwrap();
let BodyContent::ContentControl(control) = control_document.body.content.remove(0) else {
panic!("expected parsed content control");
};
let marker_row = |text: &str| {
let mut paragraph = CT_P::new();
paragraph.runs.push(CT_R::new(text));
let mut cell = CT_Tc::new();
cell.content = vec![CellContent::Paragraph(paragraph)];
let mut row = CT_Row::new();
row.cells.push(cell);
row
};
let template_row = |text: &str, merge: VMerge, header: bool| {
let mut paragraph = CT_P::new();
paragraph.runs.push(CT_R::new(text));
let mut properties = CT_TcPr {
grid_span: Some(2),
v_merge: Some(merge),
..Default::default()
};
properties.extra_xml.push((3, cell_property_raw.clone()));
let mut cell = CT_Tc::new();
cell.properties = Some(properties);
cell.content = vec![
CellContent::Paragraph(paragraph),
CellContent::ContentControl(control.clone()),
];
cell.extra_xml.push((0, cell_raw.clone()));
let mut row = CT_Row::new();
row.properties = Some(CT_TrPr {
header: Some(header),
cnf_style: Some("100000000000".to_owned()),
..Default::default()
});
row.cells.push(cell);
row.extra_xml.push((0, row_raw.clone()));
row
};
let mut table = CT_Tbl::new();
table.properties = Some(CT_TblPr {
style_id: Some("BandedRows".to_owned()),
look: Some(CT_TblLook {
first_row: Some(true),
no_h_band: Some(false),
..Default::default()
}),
..Default::default()
});
table.grid = Some(CT_TblGrid {
columns: vec![
CT_TblGridCol { width: Twips(1800) },
CT_TblGridCol { width: Twips(1800) },
CT_TblGridCol { width: Twips(1800) },
],
..Default::default()
});
table.rows.push(marker_row("{% for item in items %}"));
table
.rows
.push(template_row("A {{ item }}", VMerge::Restart, true));
table
.rows
.push(template_row("B {{ item }}", VMerge::Continue, false));
table
.rows
.push(template_row("C {{ item }}", VMerge::Continue, false));
table.rows.push(marker_row("{% endfor %}"));
table.extra_xml.push((table.rows.len(), table_raw.clone()));
document
.document
.body
.content
.push(BodyContent::Table(table));
assert_eq!(
document
.render_template(&serde_json::json!({"items": ["one", "two"]}))
.unwrap(),
10
);
let bytes = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&bytes).unwrap();
let paragraphs = reopened.paragraphs();
let numbered = paragraphs
.iter()
.filter_map(|paragraph| paragraph.numbering())
.collect::<Vec<_>>();
assert_eq!(numbered.len(), 2);
assert_eq!(numbered[0], numbered[1]);
assert_eq!(numbered[0].1, 1);
let table = reopened.document.body.tables().next().unwrap();
assert_eq!(table.rows.len(), 6);
assert_eq!(table.grid.as_ref().unwrap().columns.len(), 3);
assert_eq!(
table
.properties
.as_ref()
.unwrap()
.look
.as_ref()
.unwrap()
.no_h_band,
Some(false)
);
assert_eq!(table.extra_xml, vec![(6, table_raw)]);
for (index, row) in table.rows.iter().enumerate() {
assert_eq!(row.extra_xml, vec![(0, row_raw.clone())]);
assert_eq!(
row.properties.as_ref().unwrap().header,
Some(index % 3 == 0)
);
let cell = &row.cells[0];
assert_eq!(cell.extra_xml, vec![(0, cell_raw.clone())]);
let CellContent::ContentControl(control) = &cell.content[1] else {
panic!("expected repeated cell content control");
};
assert_eq!(
control.properties.as_ref().unwrap().tag.as_deref(),
Some("row-control")
);
let paragraph = control
.content
.iter()
.find_map(|content| match content {
SdtContent::Paragraph(paragraph) => Some(paragraph),
_ => None,
})
.expect("expected controlled paragraph");
assert_eq!(paragraph.text(), "controlled");
let properties = cell.properties.as_ref().unwrap();
assert_eq!(properties.grid_span, Some(2));
assert_eq!(
properties.v_merge,
Some(if index % 3 == 0 {
VMerge::Restart
} else {
VMerge::Continue
})
);
assert_eq!(properties.extra_xml, vec![(3, cell_property_raw.clone())]);
}
let package = OpcPackage::from_reader(std::io::Cursor::new(bytes)).unwrap();
let xml = std::str::from_utf8(package.get_part("/word/document.xml").unwrap()).unwrap();
assert!(xml.find("<w:trPr>").unwrap() < xml.find("<q:rowData").unwrap());
assert!(xml.find("<w:gridSpan").unwrap() < xml.find("<q:cellProperty").unwrap());
assert!(xml.find("<q:cellProperty").unwrap() < xml.find("<w:vMerge").unwrap());
assert!(xml.rfind("<w:tr>").unwrap() < xml.rfind("<q:tableData").unwrap());
assert!(xml.rfind("<q:tableData").unwrap() < xml.rfind("</w:tbl>").unwrap());
}
#[test]
fn conditions_use_explicit_json_truthiness() {
for (value, included) in [
(serde_json::json!(false), false),
(serde_json::Value::Null, false),
(serde_json::json!(0), false),
(serde_json::json!(""), false),
(serde_json::json!([]), false),
(serde_json::json!({}), false),
(serde_json::json!(true), true),
(serde_json::json!(-1), true),
(serde_json::json!("x"), true),
(serde_json::json!([1]), true),
(serde_json::json!({"x": 1}), true),
] {
let mut document = Document::new();
document.add_paragraph("{% if value %}");
document.add_paragraph("included");
document.add_paragraph("{% endif %}");
assert_eq!(
document
.render_template(&serde_json::json!({"value": value}))
.unwrap(),
0
);
assert_eq!(document.paragraphs().len(), usize::from(included));
}
}
#[test]
fn structural_only_table_blocks_commit_the_candidate() {
let row = |text: &str| {
let mut cell = CT_Tc::new();
let mut paragraph = CT_P::new();
paragraph.runs.push(CT_R::new(text));
cell.content = vec![CellContent::Paragraph(paragraph)];
let mut row = CT_Row::new();
row.cells.push(cell);
row
};
let mut table = CT_Tbl::new();
table.rows.push(row("{% if show %}"));
table.rows.push(row("included"));
table.rows.push(row("{% endif %}"));
let mut document = Document::new();
document
.document
.body
.content
.push(BodyContent::Table(table));
assert_eq!(
document
.render_template(&serde_json::json!({"show": false}))
.unwrap(),
0
);
let BodyContent::Table(table) = &document.document.body.content[0] else {
panic!("expected table");
};
assert!(table.rows.is_empty());
}
#[test]
fn nested_table_controls_stay_with_their_direct_table() {
let row = |text: &str| {
let mut cell = CT_Tc::new();
let mut paragraph = CT_P::new();
paragraph.runs.push(CT_R::new(text));
cell.content = vec![CellContent::Paragraph(paragraph)];
let mut row = CT_Row::new();
row.cells.push(cell);
row
};
let mut nested = CT_Tbl::new();
nested.rows.push(row("{% if show %}"));
nested.rows.push(row("nested"));
nested.rows.push(row("{% endif %}"));
let mut outer_cell = CT_Tc::new();
let mut outer_paragraph = CT_P::new();
outer_paragraph.runs.push(CT_R::new("outer"));
outer_cell
.content
.push(CellContent::Paragraph(outer_paragraph));
outer_cell.content.push(CellContent::Table(nested));
let mut outer_row = CT_Row::new();
outer_row.cells.push(outer_cell);
let mut outer = CT_Tbl::new();
outer.rows.push(outer_row);
let mut document = Document::new();
document
.document
.body
.content
.push(BodyContent::Table(outer));
document
.render_template(&serde_json::json!({"show": true}))
.unwrap();
let BodyContent::Table(outer) = &document.document.body.content[0] else {
panic!("expected outer table");
};
let nested = outer.rows[0].cells[0]
.content
.iter()
.find_map(|content| match content {
CellContent::Table(table) => Some(table),
CellContent::Paragraph(_) | CellContent::ContentControl(_) => None,
})
.expect("expected nested table");
assert_eq!(nested.rows.len(), 1);
assert_eq!(nested.rows[0].cells[0].text(), "nested");
}
#[test]
fn direct_row_markers_reject_nested_table_content() {
let mut nested_cell = CT_Tc::new();
let mut nested_paragraph = CT_P::new();
nested_paragraph.runs.push(CT_R::new("must remain"));
nested_cell.content = vec![CellContent::Paragraph(nested_paragraph)];
let mut nested_row = CT_Row::new();
nested_row.cells.push(nested_cell);
let mut nested = CT_Tbl::new();
nested.rows.push(nested_row);
let mut marker_cell = CT_Tc::new();
let mut marker = CT_P::new();
marker.runs.push(CT_R::new("{% if show %}"));
marker_cell.content = vec![CellContent::Paragraph(marker)];
let mut nested_cell = CT_Tc::new();
nested_cell.content = vec![CellContent::Table(nested)];
let mut marker_row = CT_Row::new();
marker_row.cells.push(marker_cell);
marker_row.cells.push(nested_cell);
let mut table = CT_Tbl::new();
table.rows.push(marker_row);
for text in ["included", "{% endif %}"] {
let mut paragraph = CT_P::new();
paragraph.runs.push(CT_R::new(text));
let mut cell = CT_Tc::new();
cell.content = vec![CellContent::Paragraph(paragraph)];
let mut row = CT_Row::new();
row.cells.push(cell);
table.rows.push(row);
}
let mut document = Document::new();
document
.document
.body
.content
.push(BodyContent::Table(table));
let before = document.document.to_xml().unwrap();
assert!(
document
.render_template(&serde_json::json!({"show": true}))
.is_err()
);
assert_eq!(document.document.to_xml().unwrap(), before);
}
#[test]
fn false_condition_scalar_leaves_are_preflighted() {
let mut document = Document::new();
document.add_paragraph("{% if show %}");
document.add_paragraph("{{ missing }}");
document.add_paragraph("{% endif %}");
let before = document.document.to_xml().unwrap();
assert!(
document
.render_template(&serde_json::json!({"show": false}))
.is_err()
);
assert_eq!(document.document.to_xml().unwrap(), before);
}
#[test]
fn empty_loop_bodies_are_preflighted_without_requiring_an_item() {
let mut valid = Document::new();
valid.add_paragraph("{% for item in items %}");
valid.add_paragraph("{{ item.name }}");
valid.add_paragraph("{% endfor %}");
assert_eq!(
valid
.render_template(&serde_json::json!({"items": []}))
.unwrap(),
0
);
assert!(valid.paragraphs().is_empty());
for invalid_body in ["{{ item.name", "{{ config.missing }}"] {
let mut document = Document::new();
document.add_paragraph("{% for item in items %}");
document.add_paragraph(invalid_body);
document.add_paragraph("{% endfor %}");
let before = document.document.to_xml().unwrap();
assert!(
document
.render_template(&serde_json::json!({"items": [], "config": {}}))
.is_err()
);
assert_eq!(document.document.to_xml().unwrap(), before);
}
let mut nested = Document::new();
for text in [
"{% for item in items %}",
"{% for child in config.missing %}",
"{{ child.name }}",
"{% endfor %}",
"{% endfor %}",
] {
nested.add_paragraph(text);
}
let before = nested.document.to_xml().unwrap();
assert!(
nested
.render_template(&serde_json::json!({"items": [], "config": {}}))
.is_err()
);
assert_eq!(nested.document.to_xml().unwrap(), before);
let mut nested_scope = Document::new();
for text in [
"{% for item in items %}",
"{% for child in item.children %}",
"{{ settings.missing }}",
"{% endfor %}",
"{% endfor %}",
] {
nested_scope.add_paragraph(text);
}
let before = nested_scope.document.to_xml().unwrap();
assert!(
nested_scope
.render_template(&serde_json::json!({
"items": [{"children": [], "settings": {}}]
}))
.is_err()
);
assert_eq!(nested_scope.document.to_xml().unwrap(), before);
}
#[test]
fn repeated_table_level_controls_use_the_row_loop_scope() {
let xml = br#"<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:tbl><w:tr><w:tc><w:p><w:r><w:t>{% for item in items %}</w:t></w:r></w:p></w:tc></w:tr><w:sdt><w:sdtPr><w:tag w:val="row-control"/></w:sdtPr><w:sdtContent><w:tr><w:tc><w:p><w:r><w:t>control {{ item.name }}</w:t></w:r></w:p></w:tc></w:tr></w:sdtContent></w:sdt><w:tr><w:tc><w:p><w:r><w:t>row {{ item.name }}</w:t></w:r></w:p></w:tc></w:tr><w:tr><w:tc><w:p><w:r><w:t>{% endfor %}</w:t></w:r></w:p></w:tc></w:tr></w:tbl><w:sectPr/></w:body></w:document>"#;
let mut document = Document::new();
document.document = CT_Document::from_xml(xml).unwrap();
assert_eq!(
document
.render_template(&serde_json::json!({
"items": [{"name": "one"}, {"name": "two"}]
}))
.unwrap(),
4
);
let reopened = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
let BodyContent::Table(table) = &reopened.document.body.content[0] else {
panic!("expected table");
};
assert_eq!(
table
.rows
.iter()
.map(|row| row.cells[0].text())
.collect::<Vec<_>>(),
["row one", "row two"]
);
assert_eq!(table.content_controls.len(), 2);
assert_eq!(
table
.content_controls
.iter()
.map(|(_, _, control)| {
control
.content
.iter()
.find_map(|content| match content {
SdtContent::Row(row) => Some(row.cells[0].text()),
_ => None,
})
.expect("expected controlled row")
})
.collect::<Vec<_>>(),
["control one", "control two"]
);
}
fn template_test_row(text: &str) -> CT_Row {
let mut paragraph = CT_P::new();
paragraph.runs.push(CT_R::new(text));
let mut cell = CT_Tc::new();
cell.content.push(CellContent::Paragraph(paragraph));
let mut row = CT_Row::new();
row.cells.push(cell);
row
}
fn template_test_row_control(row: CT_Row) -> CT_Sdt {
let xml = br#"<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:sdt><w:sdtContent><w:p><w:r><w:t>placeholder</w:t></w:r></w:p></w:sdtContent></w:sdt><w:sectPr/></w:body></w:document>"#;
let mut parsed = CT_Document::from_xml(xml).unwrap();
let BodyContent::ContentControl(mut control) = parsed.body.content.remove(0) else {
panic!("expected parsed content control");
};
control.content = vec![SdtContent::Row(row)];
control
}
fn template_test_table_with_control(open: &str, control: CT_Sdt, close: &str) -> Document {
let mut table = CT_Tbl::new();
table.rows.push(template_test_row(open));
table.rows.push(template_test_row("source"));
table.rows.push(template_test_row(close));
table.content_controls.push((1, 0, control));
let mut document = Document::new();
document
.document
.body
.content
.push(BodyContent::Table(table));
document
}
#[test]
fn excluded_table_level_controls_are_preflighted() {
let cases = [
(
"{% for item in items %}",
"{% endfor %}",
"{{ config.missing }}",
serde_json::json!({"items": [], "config": {}}),
),
(
"{% if show %}",
"{% endif %}",
"{{ unclosed",
serde_json::json!({"show": false}),
),
];
for (open, close, control_text, data) in cases {
let control = template_test_row_control(template_test_row(control_text));
let mut document = template_test_table_with_control(open, control, close);
let before = document.document.to_xml().unwrap();
assert!(document.render_template(&data).is_err());
assert_eq!(document.document.to_xml().unwrap(), before);
}
}
#[test]
fn repeated_table_level_controls_validate_numbering() {
let mut numbered_row = template_test_row("{{ item.name }}");
let CellContent::Paragraph(paragraph) = &mut numbered_row.cells[0].content[0] else {
panic!("expected paragraph");
};
paragraph.properties = Some(CT_PPr {
num_id: Some(99),
num_ilvl: Some(0),
..Default::default()
});
let control = template_test_row_control(numbered_row);
let mut document =
template_test_table_with_control("{% for item in items %}", control, "{% endfor %}");
let before = document.document.to_xml().unwrap();
assert!(
document
.render_template(&serde_json::json!({"items": [{"name": "one"}]}))
.is_err()
);
assert_eq!(document.document.to_xml().unwrap(), before);
}
#[test]
fn replace_text_in_header_and_footer() {
let mut doc = Document::new();
assert!(!doc.has_header_footer_content());
doc.set_header("Header: {{title}}");
doc.set_footer("Footer: {{title}}");
doc.add_paragraph("Body: {{title}}");
assert!(doc.has_header_footer_content());
let count = doc.replace_text("{{title}}", "My Doc");
assert_eq!(count, 3);
assert_eq!(doc.paragraphs()[0].text(), "Body: My Doc");
assert_eq!(doc.header_text().unwrap(), "Header: My Doc");
assert_eq!(doc.footer_text().unwrap(), "Footer: My Doc");
let reopened = Document::from_bytes(&doc.to_bytes().unwrap()).unwrap();
assert!(reopened.has_header_footer_content());
}
fn authored_header_on_predecessor() -> (Document, HdrFtrRef) {
let mut document = Document::new();
document.set_header("valid inherited header");
document.insert_section(0).unwrap();
let valid = document
.section_mut(1)
.unwrap()
.properties_mut()
.header_refs
.pop()
.unwrap();
document
.section_mut(0)
.unwrap()
.properties_mut()
.header_refs
.push(valid.clone());
(document, valid)
}
#[test]
fn section_removal_ignores_each_unusable_following_header_override() {
for hazard in [
"missing relationship",
"external relationship",
"cross-type relationship",
"missing target",
"malformed target",
] {
let (mut document, valid) = authored_header_on_predecessor();
let hazardous_id = format!("hazard-{}", hazard.replace(' ', "-"));
match hazard {
"missing relationship" => {}
"external relationship" => document
.package
.get_or_create_part_rels("/word/document.xml")
.items
.push(oxml_opc::relationship::Relationship {
id: hazardous_id.clone(),
rel_type: rel_types::HEADER.to_owned(),
target: "https://example.test/header.xml".to_owned(),
target_mode: Some("External".to_owned()),
}),
"cross-type relationship" => document
.package
.get_or_create_part_rels("/word/document.xml")
.add_with_id(&hazardous_id, rel_types::FOOTER, "header1.xml"),
"missing target" => document
.package
.get_or_create_part_rels("/word/document.xml")
.add_with_id(&hazardous_id, rel_types::HEADER, "missing-header.xml"),
"malformed target" => {
document
.package
.get_or_create_part_rels("/word/document.xml")
.add_with_id(&hazardous_id, rel_types::HEADER, "malformed-header.xml");
document
.package
.set_part("/word/malformed-header.xml", b"<not-a-header/>".to_vec());
}
_ => unreachable!(),
}
document
.section_mut(1)
.unwrap()
.properties_mut()
.header_refs
.push(HdrFtrRef {
hdr_ftr_type: HdrFtrType::Default,
rel_id: hazardous_id,
});
document.remove_section(0).unwrap();
assert_eq!(
document.header_text().as_deref(),
Some("valid inherited header"),
"{hazard} displaced the inherited story"
);
assert!(
document
.section(0)
.unwrap()
.properties()
.header_refs
.iter()
.any(|reference| reference.rel_id == valid.rel_id),
"{hazard} prevented inheritance materialization"
);
assert!(document.package.get_part("/word/header1.xml").is_some());
}
}
#[test]
fn section_removal_keeps_the_first_usable_duplicate_as_effective() {
let (mut document, valid) = authored_header_on_predecessor();
let mut later_header = CT_HdrFtr::new();
let mut paragraph = CT_P::new();
paragraph.add_run("later duplicate header");
later_header.paragraphs.push(paragraph);
document.package.set_part(
"/word/header-later.xml",
later_header.to_xml_header().unwrap(),
);
document
.package
.get_or_create_part_rels("/word/document.xml")
.add_with_id("laterHeader", rel_types::HEADER, "header-later.xml");
document
.section_mut(0)
.unwrap()
.properties_mut()
.header_refs
.push(HdrFtrRef {
hdr_ftr_type: HdrFtrType::Default,
rel_id: "laterHeader".to_owned(),
});
document.remove_section(0).unwrap();
assert_eq!(
document.header_text().as_deref(),
Some("valid inherited header")
);
assert_eq!(
document.section(0).unwrap().properties().header_refs,
[valid]
);
}
#[test]
fn section_story_pruning_failure_is_atomic() {
let (mut document, _) = authored_header_on_predecessor();
document
.package
.set_part("/word/document.xml", b"<invalid".to_vec());
let before_document = document.document.to_xml().unwrap();
let before_main = document
.package
.get_part("/word/document.xml")
.unwrap()
.to_vec();
let before_relationships = document
.package
.get_part_rels("/word/document.xml")
.unwrap()
.items
.clone();
let before_authored_parts = document.identifiers.authored_story_parts.clone();
let mut pruning_probe = document.clone_for_staging();
let removed = pruning_probe.remove_section_in_place(0).unwrap();
assert!(
pruning_probe
.prune_unreachable_authored_section_stories(&removed)
.is_err()
);
assert!(document.remove_section(0).is_err());
assert_eq!(document.document.to_xml().unwrap(), before_document);
assert_eq!(
document.package.get_part("/word/document.xml").unwrap(),
before_main
);
assert_eq!(
document
.package
.get_part_rels("/word/document.xml")
.unwrap()
.items,
before_relationships
);
assert_eq!(
document.identifiers.authored_story_parts,
before_authored_parts
);
assert!(document.package.get_part("/word/header1.xml").is_some());
}
#[test]
fn header_footer_content_includes_earlier_sections_after_round_trip() {
let mut doc = Document::new();
doc.set_header("Earlier section header");
doc.add_paragraph("First section");
let header_reference = doc
.document
.body
.sect_pr
.as_mut()
.expect("final section")
.header_refs
.pop()
.expect("header reference");
let BodyContent::Paragraph(paragraph) = doc
.document
.body
.content
.last_mut()
.expect("first section paragraph")
else {
panic!("expected paragraph");
};
paragraph.properties = Some(CT_PPr {
sect_pr: Some(CT_SectPr {
header_refs: vec![header_reference],
..CT_SectPr::default_letter()
}),
..Default::default()
});
assert!(doc.has_header_footer_content());
let reopened = Document::from_bytes(&doc.to_bytes().unwrap()).unwrap();
assert!(reopened.has_header_footer_content());
}
#[test]
fn replace_all_batch() {
let mut doc = Document::new();
doc.add_paragraph("{{a}} and {{b}}");
let mut map = std::collections::HashMap::new();
map.insert("{{a}}", "X");
map.insert("{{b}}", "Y");
let count = doc.replace_all(&map);
assert_eq!(count, 2);
assert_eq!(doc.paragraphs()[0].text(), "X and Y");
}
#[test]
fn expected_replacement_batch_runs_pairs_in_order_and_counts_each() {
let mut doc = Document::new();
doc.add_paragraph("{{a}} and {{b}}");
doc.set_header("{{b}} header");
let counts = doc
.try_replace_all_expected(&[
("{{a}}", "{{b}}", Some(1)),
("{{b}}", "Y", Some(3)),
("{{missing}}", "Z", None),
])
.unwrap()
.unwrap();
assert_eq!(counts, [1, 3, 0]);
assert_eq!(doc.paragraphs()[0].text(), "Y and Y");
assert_eq!(doc.header_text().as_deref(), Some("Y header"));
}
#[test]
fn expected_replacement_batch_mismatch_names_the_pair_and_changes_nothing() {
let mut doc = Document::new();
doc.add_paragraph("{{a}} {{b}} {{b}} {{c}}");
let before = doc.to_bytes().unwrap();
let mismatch = doc
.try_replace_all_expected(&[
("{{a}}", "A", Some(1)),
("{{b}}", "B", Some(1)),
("{{c}}", "C", Some(1)),
])
.unwrap()
.unwrap_err();
assert_eq!(
mismatch,
ReplacementCountMismatch {
index: 1,
placeholder: "{{b}}".to_owned(),
expected: 1,
found: 2,
}
);
assert_eq!(
mismatch.to_string(),
"pair 1: expected 1 replacement(s) of \"{{b}}\", found 2"
);
assert_eq!(doc.to_bytes().unwrap(), before);
}
#[test]
fn replacement_flush_failures_leave_the_live_document_unchanged() {
fn exhausted() -> Document {
let mut source = Document::new_with_profile(WordCreationProfile::Minimal(
WordPackageClass::Document,
));
source.add_paragraph("before {{value}}");
let mut package =
OpcPackage::from_reader(Cursor::new(source.to_bytes().unwrap())).unwrap();
let relationships = package.get_or_create_part_rels("/word/document.xml");
relationships
.items
.retain(|relationship| relationship.rel_type != rel_types::STYLES);
relationships.add_with_id(&format!("rId{}", u32::MAX), "urn:producer", "producer.bin");
package.parts.remove(DEFAULT_STYLES_PART);
package.content_types.overrides.remove(DEFAULT_STYLES_PART);
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
Document::from_bytes(bytes.get_ref()).unwrap()
}
let mut literal = exhausted();
let before = literal.paragraphs()[0].text();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
literal.replace_text("{{value}}", "after");
}));
assert!(result.is_err());
assert_eq!(literal.paragraphs()[0].text(), before);
let mut counted = exhausted();
let before = counted.paragraphs()[0].text();
let error = counted
.try_replace_all_expected(&[("{{value}}", "after", Some(1))])
.unwrap_err();
assert!(
error
.to_string()
.contains("styles relationship allocation failed")
);
assert_eq!(counted.paragraphs()[0].text(), before);
let mut regex = exhausted();
let before = regex.paragraphs()[0].text();
let error = regex.replace_regex(r"\{\{value\}\}", "after").unwrap_err();
assert!(
error
.to_string()
.contains("styles relationship allocation failed")
);
assert_eq!(regex.paragraphs()[0].text(), before);
}
#[test]
fn header_footer_serialization_failures_abort_literal_and_regex_replacement() {
let mut literal = Document::new();
literal.set_header("before {{value}}");
FAIL_NEXT_HEADER_FOOTER_SERIALIZATION.set(true);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
literal.replace_text("{{value}}", "after");
}));
assert!(result.is_err());
assert_eq!(literal.header_text().as_deref(), Some("before {{value}}"));
let mut regex = Document::new();
regex.set_header("before {{value}}");
FAIL_NEXT_HEADER_FOOTER_SERIALIZATION.set(true);
let error = regex.replace_regex(r"\{\{value\}\}", "after").unwrap_err();
assert!(
error
.to_string()
.contains("injected header or footer serialization failure")
);
assert_eq!(regex.header_text().as_deref(), Some("before {{value}}"));
}
#[test]
fn template_workflow_round_trip() {
let mut doc = Document::new();
doc.add_paragraph("Company: {{company}}");
doc.add_paragraph("Date: {{date}}");
doc.replace_text("{{company}}", "Acme Corp");
doc.replace_text("{{date}}", "2026-02-22");
// Round-trip
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
assert_eq!(doc2.paragraphs()[0].text(), "Company: Acme Corp");
assert_eq!(doc2.paragraphs()[1].text(), "Date: 2026-02-22");
}
#[test]
fn template_commit_retains_the_complete_staged_bundle_state() {
let mut source =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
source.add_paragraph("Hello {{name}}");
let mut package = OpcPackage::from_reader(Cursor::new(source.to_bytes().unwrap())).unwrap();
package
.get_or_create_part_rels("/word/document.xml")
.items
.retain(|relationship| relationship.rel_type != rel_types::STYLES);
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
let mut document = Document::from_bytes(bytes.get_ref()).unwrap();
assert_eq!(
document
.render_template(&serde_json::json!({"name": "Ada"}))
.unwrap(),
1
);
let staged_target = document.styles_part_name.clone().unwrap();
let staged_relationships = document
.package
.get_part_rels("/word/document.xml")
.unwrap();
assert_eq!(
staged_relationships
.items
.iter()
.filter(|relationship| {
relationship.rel_type == rel_types::STYLES
&& relationship_is_internal(relationship)
})
.count(),
1
);
let saved = OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
let styles = saved
.get_part_rels("/word/document.xml")
.unwrap()
.items
.iter()
.filter(|relationship| {
relationship.rel_type == rel_types::STYLES && relationship_is_internal(relationship)
})
.collect::<Vec<_>>();
assert_eq!(styles.len(), 1);
assert_eq!(
OpcPackage::resolve_rel_target("/word/document.xml", &styles[0].target),
staged_target
);
assert_eq!(document.paragraphs()[0].text(), "Hello Ada");
}
#[test]
fn add_background_image_round_trip() {
// Create a minimal 1x1 PNG
let png_data: Vec<u8> = vec![
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, // PNG signature
0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, // 1x1
0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49,
0x44, 0x41, 0x54, // IDAT chunk
0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21,
0xbc, 0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, // IEND chunk
0xae, 0x42, 0x60, 0x82,
];
let mut doc = Document::new();
doc.add_paragraph("Hello World");
doc.add_background_image(&png_data, "bg.png");
// Background image paragraph should be at index 0
assert_eq!(doc.content_count(), 2);
// Round-trip
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
// Should still have 2 content items
assert_eq!(doc2.content_count(), 2);
// The second paragraph should have our text
assert_eq!(doc2.paragraphs().last().unwrap().text(), "Hello World");
}
#[test]
fn add_anchored_image() {
let png_data: Vec<u8> = vec![
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08,
0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc,
0x33, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
let mut doc = Document::new();
doc.add_paragraph("Content");
doc.add_anchored_image(
&png_data,
"overlay.png",
Length::inches(4.0),
Length::inches(3.0),
false,
);
assert_eq!(doc.content_count(), 2);
}
#[test]
fn new_document_has_common_word_styles() {
let doc = Document::new();
for id in [
"Heading2",
"Heading3",
"Heading4",
"Heading5",
"Heading6",
"Heading7",
"Heading8",
"Heading9",
"Title",
"Subtitle",
"NoSpacing",
"Quote",
"ListParagraph",
"Caption",
"TableGrid",
] {
assert!(doc.style(id).is_some(), "missing common style {id}");
}
}
#[test]
fn inserted_toc_can_rebuild_after_heading_changes() {
let mut doc = Document::new();
doc.add_paragraph("Original chapter").style("Heading1");
doc.insert_toc(0, 1).unwrap();
assert_eq!(doc.rebuild_toc().unwrap().entry_count, 1);
doc.add_paragraph("Added chapter").style("Heading1");
let report = doc.rebuild_toc().unwrap();
assert_eq!(report.entry_count, 2);
assert_eq!(
doc.paragraphs()
.iter()
.filter(|paragraph| paragraph.text().contains("Added chapter"))
.count(),
2
);
let reopened = Document::from_bytes(&doc.to_bytes().unwrap()).unwrap();
assert_eq!(
reopened
.paragraphs()
.iter()
.filter(|paragraph| paragraph.text().contains("Added chapter"))
.count(),
2
);
}
#[test]
fn insert_toc_basic() {
let mut doc = Document::new();
doc.add_paragraph("Introduction");
doc.add_paragraph("Chapter 1").style("Heading1");
doc.add_paragraph("Some text in chapter 1.");
doc.add_paragraph("Section 1.1").style("Heading2");
doc.add_paragraph("Text in section 1.1.");
doc.add_paragraph("Chapter 2").style("Heading1");
doc.add_paragraph("Text in chapter 2.");
// Before TOC: 7 content elements
assert_eq!(doc.content_count(), 7);
// Insert TOC at index 0 with max_level 2
doc.insert_toc(0, 2).unwrap();
// TOC adds: 1 title + 3 heading entries (Ch1, Sec1.1, Ch2) = 4 paragraphs
assert_eq!(doc.content_count(), 11);
// Verify TOC title
let paras = doc.paragraphs();
assert_eq!(paras[0].text(), "Table of Contents");
// Verify TOC entries contain heading text
assert_eq!(paras[1].text(), "Chapter 1\t");
assert_eq!(paras[2].text(), "Section 1.1\t");
assert_eq!(paras[3].text(), "Chapter 2\t");
assert_eq!(
doc.bookmarks()
.iter()
.filter_map(|bookmark| bookmark.name())
.collect::<Vec<_>>(),
vec!["_Toc1", "_Toc2", "_Toc3"]
);
// Verify round-trip: save and re-open
let bytes = doc.to_bytes().expect("should serialize");
let doc2 = Document::from_bytes(&bytes).expect("should open");
assert_eq!(doc2.content_count(), 11);
let paras2 = doc2.paragraphs();
assert_eq!(paras2[0].text(), "Table of Contents");
}
#[test]
fn insert_toc_handles_extreme_section_width_without_panicking() {
let mut doc = Document::new();
doc.add_paragraph("Chapter").style("Heading1");
let section = doc
.document
.body
.sect_pr
.get_or_insert_with(CT_SectPr::default_letter);
section.page_width = Some(rdocx_oxml::units::Twips(i32::MAX));
section.margin_left = Some(rdocx_oxml::units::Twips(i32::MIN));
assert_eq!(doc.text_width_twips(), 9360);
doc.insert_toc(0, 1).unwrap();
assert_eq!(doc.content_count(), 3);
}
#[test]
fn insert_toc_avoids_an_existing_bookmark_id() {
let mut doc = Document::new();
doc.add_paragraph("Chapter").style("Heading1");
let BodyContent::Paragraph(paragraph) = &mut doc.document.body.content[0] else {
panic!("heading paragraph");
};
assert!(paragraph.insert_bookmark_start(0, 100, "existing"));
assert!(paragraph.insert_bookmark_end(1, 100));
doc.insert_toc(0, 1).unwrap();
let bookmarks = doc.bookmarks();
let existing = bookmarks
.iter()
.find(|bookmark| bookmark.name() == Some("existing"))
.expect("existing bookmark");
let toc = bookmarks
.iter()
.find(|bookmark| bookmark.name() == Some("_Toc1"))
.expect("TOC bookmark");
assert_eq!(existing.id(), Some(100));
assert_ne!(toc.id(), existing.id());
assert_eq!(toc.id(), Some(0));
}
#[test]
fn insert_toc_handles_a_maximum_numeric_suffix_without_panicking() {
let mut doc = Document::new();
doc.add_paragraph("Chapter").style("Heading1");
let BodyContent::Paragraph(paragraph) = &mut doc.document.body.content[0] else {
panic!("heading paragraph");
};
assert!(paragraph.insert_bookmark_start(0, 9, "_Toc18446744073709551615"));
assert!(paragraph.insert_bookmark_end(1, 9));
doc.insert_toc(0, 1).unwrap();
assert!(
doc.bookmarks()
.iter()
.any(|bookmark| bookmark.name() == Some("_Toc1"))
);
}
#[test]
fn append_documents() {
let mut doc_a = Document::new();
doc_a.add_paragraph("Paragraph A1");
doc_a.add_paragraph("Paragraph A2");
let mut doc_b = Document::new();
doc_b.add_paragraph("Paragraph B1");
doc_b.add_paragraph("Paragraph B2");
doc_b.add_paragraph("Paragraph B3");
assert_eq!(doc_a.content_count(), 2);
doc_a.append(&doc_b);
assert_eq!(doc_a.content_count(), 5);
let paras = doc_a.paragraphs();
assert_eq!(paras[0].text(), "Paragraph A1");
assert_eq!(paras[1].text(), "Paragraph A2");
assert_eq!(paras[2].text(), "Paragraph B1");
assert_eq!(paras[3].text(), "Paragraph B2");
assert_eq!(paras[4].text(), "Paragraph B3");
// Verify round-trip
let bytes = doc_a.to_bytes().expect("serialize");
let reopened = Document::from_bytes(&bytes).expect("open");
assert_eq!(reopened.content_count(), 5);
}
#[test]
fn append_variants_panic_before_mutation_when_bundle_preflight_fails() {
fn receiver() -> Document {
let mut source = Document::new_with_profile(WordCreationProfile::Minimal(
WordPackageClass::Document,
));
source.add_paragraph("receiver");
let mut package =
OpcPackage::from_reader(Cursor::new(source.to_bytes().unwrap())).unwrap();
let relationships = package.get_or_create_part_rels("/word/document.xml");
relationships
.items
.retain(|relationship| relationship.rel_type != rel_types::STYLES);
relationships.add_with_id(&format!("rId{}", u32::MAX), "urn:producer", "producer.bin");
package.parts.remove(DEFAULT_STYLES_PART);
package.content_types.overrides.remove(DEFAULT_STYLES_PART);
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
Document::from_bytes(bytes.get_ref()).unwrap()
}
let mut other = Document::new();
other
.add_style(StyleBuilder::paragraph("Merged", "Merged"))
.unwrap();
other.add_paragraph("other").style("Merged");
for mutation in [
(|document: &mut Document, other: &Document| document.append(other))
as fn(&mut Document, &Document),
|document, other| document.append_with_break(other, crate::SectionBreak::NextPage),
|document, other| document.insert_document(0, other),
] {
let mut document = receiver();
let before = document.paragraphs()[0].text();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
mutation(&mut document, &other);
}));
assert!(result.is_err());
assert_eq!(document.content_count(), 1);
assert_eq!(document.paragraphs()[0].text(), before);
}
}
fn assert_style_graph_merge_failure_is_atomic(destination: &Document, source: &Document) {
destination.validate_style_graph().unwrap();
source.validate_style_graph().unwrap();
for mutation in [
(|document: &mut Document, other: &Document| document.append(other))
as fn(&mut Document, &Document),
|document, other| document.append_with_break(other, crate::SectionBreak::NextPage),
|document, other| document.insert_document(0, other),
] {
let mut document = destination.clone_for_staging();
let before = document.to_bytes().unwrap();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
mutation(&mut document, source);
}));
assert!(result.is_err());
assert_eq!(document.to_bytes().unwrap(), before);
document.validate_style_graph().unwrap();
}
}
#[test]
fn append_variants_reject_conflicting_style_graphs_before_mutation() {
let mut destination = Document::new();
destination.add_paragraph("destination");
let mut conflicting_default = Document::new();
conflicting_default
.add_style(StyleBuilder::paragraph("SourceDefault", "Source Default"))
.unwrap();
conflicting_default
.set_default_style(StyleType::Paragraph, "SourceDefault")
.unwrap();
conflicting_default
.add_paragraph("source default")
.style("SourceDefault");
assert_style_graph_merge_failure_is_atomic(&destination, &conflicting_default);
destination
.add_style(StyleBuilder::paragraph(
"CollisionParagraph",
"Destination Collision",
))
.unwrap();
let mut conflicting_link = Document::new();
conflicting_link
.add_style(StyleBuilder::character(
"CollisionCharacter",
"Collision Character",
))
.unwrap();
conflicting_link
.add_style(
StyleBuilder::paragraph("CollisionParagraph", "Source Collision")
.linked_style("CollisionCharacter"),
)
.unwrap();
conflicting_link
.add_paragraph("source link")
.style("CollisionParagraph");
assert_style_graph_merge_failure_is_atomic(&destination, &conflicting_link);
}
#[test]
fn append_with_section_break() {
let mut doc_a = Document::new();
doc_a.add_paragraph("A1");
let mut doc_b = Document::new();
doc_b.add_paragraph("B1");
doc_a.append_with_break(&doc_b, crate::SectionBreak::Continuous);
// 1 original + 1 section break paragraph + 1 merged = 3
assert_eq!(doc_a.content_count(), 3);
}
#[test]
fn insert_document_at_index() {
let mut doc_a = Document::new();
doc_a.add_paragraph("First");
doc_a.add_paragraph("Last");
let mut doc_b = Document::new();
doc_b.add_paragraph("Middle 1");
doc_b.add_paragraph("Middle 2");
doc_a.insert_document(1, &doc_b);
assert_eq!(doc_a.content_count(), 4);
let paras = doc_a.paragraphs();
assert_eq!(paras[0].text(), "First");
assert_eq!(paras[1].text(), "Middle 1");
assert_eq!(paras[2].text(), "Middle 2");
assert_eq!(paras[3].text(), "Last");
}
#[test]
fn merge_deduplicates_styles() {
let mut doc_a = Document::new();
doc_a.add_paragraph("A").style("Heading1");
let mut doc_b = Document::new();
doc_b.add_paragraph("B").style("Heading1");
doc_b
.add_style(
crate::style::StyleBuilder::paragraph("CustomB", "Custom B").based_on("Normal"),
)
.unwrap();
doc_b.add_paragraph("C").style("CustomB");
let styles_before = doc_a.styles.styles.len();
doc_a.append(&doc_b);
let styles_after = doc_a.styles.styles.len();
doc_a.validate_style_graph().unwrap();
// Heading1 already existed, so only CustomB should be added
assert_eq!(styles_after, styles_before + 1);
}
#[test]
fn headings_and_outline() {
let mut doc = Document::new();
doc.add_paragraph("Intro");
doc.add_paragraph("Chapter 1").style("Heading1");
doc.add_paragraph("Section 1.1").style("Heading2");
doc.add_paragraph("Section 1.2").style("Heading2");
doc.add_paragraph("Chapter 2").style("Heading1");
doc.add_paragraph("Section 2.1").style("Heading2");
doc.add_paragraph("Sub 2.1.1").style("Heading3");
let headings = doc.headings();
assert_eq!(headings.len(), 6);
assert_eq!(headings[0], (1, "Chapter 1".to_string()));
assert_eq!(headings[1], (2, "Section 1.1".to_string()));
assert_eq!(headings[5], (3, "Sub 2.1.1".to_string()));
let outline = doc.document_outline();
assert_eq!(outline.len(), 2); // Two h1 nodes
assert_eq!(outline[0].text, "Chapter 1");
assert_eq!(outline[0].children.len(), 2); // 1.1 and 1.2
assert_eq!(outline[1].text, "Chapter 2");
assert_eq!(outline[1].children.len(), 1); // 2.1
assert_eq!(outline[1].children[0].children.len(), 1); // 2.1.1
}
#[test]
fn word_count_basic() {
let mut doc = Document::new();
doc.add_paragraph("Hello world");
doc.add_paragraph("Three more words");
assert_eq!(doc.word_count(), 5);
}
#[test]
fn audit_accessibility_missing_metadata() {
let doc = Document::new();
let issues = doc.audit_accessibility();
// New document has no title or author
assert!(issues.iter().any(|i| i.message.contains("no title")));
assert!(issues.iter().any(|i| i.message.contains("no author")));
}
#[test]
fn fx248_identified_block_control_scope_matches_model_and_preserves_source() {
let producer = "http://schemas.microsoft.com/office/word/2010/wordml";
let opaque = r#"<x:witness x:value="&">keep & exact</x:witness>"#;
for attributes in [
r#"p:paraId="00AABBCC""#,
r#"p:textId="00DDEEFF""#,
r#"p:paraId="00AABBCC" p:textId="00DDEEFF""#,
] {
let xml = format!(
r#"<w:document xmlns:w="{W_NS}" xmlns:p="{producer}" xmlns:x="urn:opaque"><w:body><w:p {attributes}><w:r><w:t>ordinary</w:t></w:r></w:p><w:sdt><w:sdtPr>{opaque}</w:sdtPr><w:sdtContent><w:p {attributes}><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText> TOC </w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>contents</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p><w:sdt><w:sdtPr/><w:sdtContent><w:p {attributes}><w:r><w:t>nested</w:t></w:r></w:p></w:sdtContent></w:sdt></w:sdtContent></w:sdt><w:sectPr/></w:body></w:document>"#
);
let mut original = Document::new();
original
.package
.set_part(&original.doc_part_name, xml.into_bytes());
let mut input = std::io::Cursor::new(Vec::new());
original.package.write_to(&mut input).unwrap();
let mut document = Document::from_bytes(input.get_ref()).unwrap();
assert_eq!(document.paragraph_count(), 3, "{attributes}");
assert_eq!(document.paragraph_visibility().unwrap(), vec![true; 3]);
document.validate().unwrap();
let bytes = document.to_bytes().unwrap();
let package = OpcPackage::from_reader(std::io::Cursor::new(&bytes)).unwrap();
let saved =
std::str::from_utf8(package.get_part(&document.doc_part_name).unwrap()).unwrap();
assert!(saved.contains(opaque), "{saved}");
for attribute in attributes.split_whitespace() {
assert_eq!(saved.matches(attribute).count(), 3, "{saved}");
}
let reopened = Document::from_bytes(&bytes).unwrap();
assert_eq!(reopened.paragraph_visibility().unwrap(), vec![true; 3]);
reopened.validate().unwrap();
}
}
#[test]
fn fx248_effective_namespace_scope_preserves_producer_attributes() {
let xml = format!(
r#"<q:document xmlns:q="{W_NS}" xmlns="urn:unused" xmlns:id="urn:outer&producer"><q:body><q:sdt xmlns=""><q:sdtPr/><q:sdtContent><q:p id:stamp="outer"><q:r><q:t>outer</q:t></q:r></q:p><q:sdt xmlns:id="urn:inner&producer"><q:sdtPr/><q:sdtContent><q:p id:stamp="inner"><q:r><q:t>inner</q:t></q:r></q:p></q:sdtContent></q:sdt><q:p id:stamp="restored"><q:r><q:t>restored</q:t></q:r></q:p></q:sdtContent></q:sdt><q:sectPr/></q:body></q:document>"#
);
let mut original = Document::new();
original
.package
.set_part(&original.doc_part_name, xml.into_bytes());
let mut input = std::io::Cursor::new(Vec::new());
original.package.write_to(&mut input).unwrap();
let mut document = Document::from_bytes(input.get_ref()).unwrap();
assert_eq!(document.paragraph_count(), 3);
assert_eq!(document.paragraph_visibility().unwrap(), vec![true; 3]);
document.validate().unwrap();
let bytes = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&bytes).unwrap();
assert_eq!(reopened.paragraph_visibility().unwrap(), vec![true; 3]);
reopened.validate().unwrap();
let package = OpcPackage::from_reader(std::io::Cursor::new(&bytes)).unwrap();
let saved =
std::str::from_utf8(package.get_part(&document.doc_part_name).unwrap()).unwrap();
assert!(saved.contains("urn:outer&producer"), "{saved}");
assert!(saved.contains("urn:inner&producer"), "{saved}");
assert!(!saved.contains("&amp;"), "{saved}");
let mut reader = NsReader::from_reader(saved.as_bytes());
let mut buffer = Vec::new();
let mut stamps = Vec::new();
loop {
match reader.read_event_into(&mut buffer).unwrap() {
Event::Start(element) | Event::Empty(element) => {
for attribute in element.attributes() {
let attribute = attribute.unwrap();
let (namespace, local) = reader.resolver().resolve_attribute(attribute.key);
if local.as_ref() == b"stamp" {
let ResolveResult::Bound(Namespace(namespace)) = namespace else {
panic!("saved producer attribute lost its namespace");
};
let value = attribute
.decoded_and_normalized_value(
XmlVersion::Implicit1_0,
element.decoder(),
)
.unwrap();
// This parser's namespace resolver exposes raw URI bytes.
stamps.push((namespace.to_vec(), value.into_owned()));
}
}
}
Event::Eof => break,
_ => {}
}
buffer.clear();
}
assert_eq!(
stamps,
vec![
(b"urn:outer&producer".to_vec(), "outer".to_owned()),
(b"urn:inner&producer".to_vec(), "inner".to_owned()),
(b"urn:outer&producer".to_vec(), "restored".to_owned()),
]
);
}
#[test]
fn visible_content_does_not_hide_malformed_remaining_xml() {
for ending in ["", "</w:r></w:body></w:document>"] {
let xml = format!(
r#"<w:document xmlns:w="{W_NS}"><w:body><w:p><w:r><w:drawing/></w:r></w:p>{ending}"#
);
assert!(
paragraph_visibility_from_main_source(
&Document::new(),
xml.as_bytes(),
ParagraphVisibilityScope::FacadeRecursive
)
.is_err(),
"{xml}"
);
}
}
#[test]
fn paragraph_visibility_uses_current_picture_source_without_mutating_live_document() {
let mut document = Document::new();
document.add_paragraph("before");
document.add_picture(
super::watermark_tests::PNG,
"visible.png",
Length::inches(1.0),
Length::inches(1.0),
);
document.add_paragraph("");
let before_model = document.document.to_xml().unwrap();
let before_parts = document.package.parts.clone();
assert_eq!(
document.paragraph_visibility().unwrap(),
vec![true, true, false]
);
assert_eq!(
document.paragraph_visibility().unwrap(),
vec![true, true, false]
);
assert_eq!(document.document.to_xml().unwrap(), before_model);
assert_eq!(document.package.parts, before_parts);
document.paragraph_mut(2).unwrap().add_run("unsaved");
let edited_model = document.document.to_xml().unwrap();
let edited_parts = document.package.parts.clone();
assert_eq!(
document.paragraph_visibility().unwrap(),
vec![true, true, true]
);
assert_eq!(document.document.to_xml().unwrap(), edited_model);
assert_eq!(document.package.parts, edited_parts);
}
#[test]
fn paragraph_visibility_checks_owner_slots_and_audit_direct_body_scope() {
let body = concat!(
"<w:p/><w:p/>",
"<w:sdt><w:sdtPr/><w:sdtContent><w:p><w:r><w:t>controlled</w:t></w:r></w:p><w:sdt><w:sdtPr/><w:sdtContent><w:p/></w:sdtContent></w:sdt></w:sdtContent></w:sdt>",
"<w:tbl><w:tblPr/><w:tblGrid/><w:tr><w:tc><w:tcPr/><w:p><w:r><w:t>excluded</w:t></w:r></w:p></w:tc></w:tr></w:tbl>",
"<w:p/><w:p/><w:p/>",
);
let xml = format!(r#"<w:document xmlns:w="{W_NS}"><w:body>{body}</w:body></w:document>"#);
let mut document = Document::new();
document.document = rdocx_oxml::document::CT_Document::from_xml(xml.as_bytes()).unwrap();
assert_eq!(document.paragraphs().len(), 7);
assert_eq!(
paragraph_visibility_from_main_source(
&document,
xml.as_bytes(),
ParagraphVisibilityScope::FacadeRecursive
)
.unwrap(),
vec![false, false, true, false, false, false, false]
);
assert_eq!(
paragraph_visibility_from_main_source(
&document,
xml.as_bytes(),
ParagraphVisibilityScope::DirectBody
)
.unwrap(),
vec![false; 5]
);
let direct = "<w:p><w:r><w:t>controlled</w:t></w:r></w:p><w:p/>";
let shifted = xml.replace("<w:sdt><w:sdtPr/><w:sdtContent><w:p><w:r><w:t>controlled</w:t></w:r></w:p><w:sdt><w:sdtPr/><w:sdtContent><w:p/></w:sdtContent></w:sdt></w:sdtContent></w:sdt>", direct);
// Equal paragraph count cannot substitute for the original owners.
assert_eq!(
rdocx_oxml::document::CT_Document::from_xml(shifted.as_bytes())
.unwrap()
.body
.paragraphs()
.count(),
document.paragraphs().len()
);
assert!(
paragraph_visibility_from_main_source(
&document,
shifted.as_bytes(),
ParagraphVisibilityScope::FacadeRecursive
)
.is_err()
);
let issues = document.audit_accessibility();
assert_eq!(
issues
.iter()
.filter(|issue| issue.message.contains("consecutive empty paragraphs"))
.map(|issue| issue.message.as_str())
.collect::<Vec<_>>(),
vec!["3 consecutive empty paragraphs (consider using spacing instead)"]
);
assert!(!issues.iter().any(|issue| {
issue
.message
.starts_with("Paragraph visibility could not be determined:")
}));
}
#[test]
fn audit_picture_paragraphs_do_not_count_as_consecutive_empty_paragraphs() {
let mut document = Document::new();
for index in 0..3 {
document.add_picture(
super::watermark_tests::PNG,
&format!("picture{index}.png"),
Length::inches(1.0),
Length::inches(1.0),
);
}
let before_model = document.document.to_xml().unwrap();
let before_parts = document.package.parts.clone();
let issues = document.audit_accessibility();
assert_eq!(document.document.to_xml().unwrap(), before_model);
assert_eq!(document.package.parts, before_parts);
assert!(
issues
.iter()
.any(|issue| issue.message.contains("no title"))
);
assert!(
issues
.iter()
.any(|issue| issue.message.contains("no author"))
);
assert_eq!(
issues
.iter()
.filter(|issue| issue.message.contains("has no alt text"))
.count(),
3,
);
assert!(
!issues
.iter()
.any(|issue| issue.message.contains("consecutive empty paragraphs")),
"{issues:?}",
);
for _ in 0..4 {
document.add_paragraph("");
}
let before_model = document.document.to_xml().unwrap();
let before_parts = document.package.parts.clone();
let issues = document.audit_accessibility();
assert_eq!(document.document.to_xml().unwrap(), before_model);
assert_eq!(document.package.parts, before_parts);
let empty: Vec<_> = issues
.iter()
.filter(|issue| issue.message.contains("consecutive empty paragraphs"))
.map(|issue| issue.message.as_str())
.collect();
assert_eq!(
empty,
vec![
"3 consecutive empty paragraphs (consider using spacing instead)",
"4 consecutive empty paragraphs (consider using spacing instead)",
]
);
}
#[test]
fn audit_qualified_display_content_and_field_controls_preserve_source() {
let cases = [
(
"simple-cr",
r#"<w:fldSimple w:instr="PAGE"><w:r><w:cr/></w:r></w:fldSimple>"#,
true,
),
(
"complex-cr",
r#"<w:r><w:fldChar w:fldCharType="begin"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType="separate"/><w:cr/><w:fldChar w:fldCharType="end"/></w:r>"#,
true,
),
(
"simple-no-break-hyphen",
r#"<w:fldSimple w:instr="PAGE"><w:r><w:noBreakHyphen/></w:r></w:fldSimple>"#,
true,
),
(
"complex-no-break-hyphen",
r#"<w:r><w:fldChar w:fldCharType="begin"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType="separate"/><w:noBreakHyphen/><w:fldChar w:fldCharType="end"/></w:r>"#,
true,
),
(
"simple-positional-tab",
r#"<w:fldSimple w:instr="PAGE"><w:r><w:ptab w:alignment="left" w:relativeTo="margin" w:leader="none"/></w:r></w:fldSimple>"#,
true,
),
(
"complex-positional-tab",
r#"<w:r><w:fldChar w:fldCharType="begin"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType="separate"/><w:ptab w:alignment="left" w:relativeTo="margin" w:leader="none"/><w:fldChar w:fldCharType="end"/></w:r>"#,
true,
),
(
"simple-soft-hyphen",
r#"<w:fldSimple w:instr="PAGE"><w:r><w:softHyphen/></w:r></w:fldSimple>"#,
false,
),
(
"complex-soft-hyphen",
r#"<w:r><w:fldChar w:fldCharType="begin"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType="separate"/><w:softHyphen/><w:fldChar w:fldCharType="end"/></w:r>"#,
false,
),
(
"simple-leaf-deleted-only",
r#"<w:fldSimple w:instr="PAGE"><w:r><w:delText>gone</w:delText></w:r></w:fldSimple>"#,
false,
),
(
"simple-outer-deleted-only",
r#"<w:fldSimple w:instr="UNKNOWN"><w:fldSimple w:instr="PAGE"><w:r><w:delText>gone</w:delText></w:r></w:fldSimple></w:fldSimple>"#,
false,
),
(
"complex-outer-deleted-only",
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>UNKNOWN</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:fldSimple w:instr="PAGE"><w:r><w:delText>gone</w:delText></w:r></w:fldSimple><w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
false,
),
(
"simple-leaf-deleted-accepted",
r#"<w:fldSimple w:instr="PAGE"><w:r><w:delText>gone</w:delText></w:r><w:r><w:t>accepted</w:t></w:r></w:fldSimple>"#,
true,
),
(
"simple-outer-deleted-accepted",
r#"<w:fldSimple w:instr="UNKNOWN"><w:fldSimple w:instr="PAGE"><w:r><w:delText>gone</w:delText></w:r><w:r><w:t>accepted</w:t></w:r></w:fldSimple></w:fldSimple>"#,
true,
),
(
"complex-outer-deleted-accepted",
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>UNKNOWN</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:fldSimple w:instr="PAGE"><w:r><w:delText>gone</w:delText></w:r><w:r><w:t>accepted</w:t></w:r></w:fldSimple><w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
true,
),
(
"alias-pict",
r#"<q:r xmlns:q="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><q:pict/></q:r>"#,
true,
),
(
"default-object",
r#"<r xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><object/></r>"#,
true,
),
(
"foreign-pict",
r#"<w:r><x:pict xmlns:x="urn:producer"/></w:r>"#,
false,
),
(
"rebound-object",
r#"<w:r><w:object xmlns:w="urn:producer"/></w:r>"#,
false,
),
("soft-hyphen", "<w:r><w:softHyphen/></w:r>", false),
(
"producer-hint",
"<w:r><w:lastRenderedPageBreak/></w:r>",
false,
),
(
"deleted-text",
"<w:r><w:delText>deleted</w:delText></w:r>",
false,
),
(
"deleted-drawing",
"<w:del><w:r><w:drawing/></w:r></w:del>",
false,
),
(
"moved-away",
"<w:moveFrom><w:r><w:drawing/></w:r></w:moveFrom>",
false,
),
(
"inserted-drawing",
"<w:ins><w:r><w:drawing/></w:r></w:ins>",
true,
),
(
"equation",
r#"<m:oMath xmlns:m="http://schemas.openxmlformats.org/officeDocument/2006/math"><m:r><m:t>x</m:t></m:r></m:oMath>"#,
true,
),
(
"loose-begin",
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r>"#,
false,
),
(
"loose-separate",
r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r>"#,
false,
),
(
"loose-end",
r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
false,
),
(
"instruction",
"<w:r><w:instrText>PAGE</w:instrText></w:r>",
false,
),
(
"deleted-instruction",
"<w:r><w:delInstrText>PAGE</w:delInstrText></w:r>",
false,
),
("simple-empty", r#"<w:fldSimple w:instr="PAGE"/>"#, false),
(
"simple-text",
r#"<w:fldSimple w:instr="PAGE"><w:r><w:t>4</w:t></w:r></w:fldSimple>"#,
true,
),
(
"simple-whitespace",
r#"<w:fldSimple w:instr="PAGE"><w:r><w:t xml:space="preserve"> </w:t></w:r></w:fldSimple>"#,
false,
),
(
"simple-tab",
r#"<w:fldSimple w:instr="PAGE"><w:r><w:tab/></w:r></w:fldSimple>"#,
true,
),
(
"complex-text",
r#"<w:r><w:fldChar w:fldCharType="begin"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType="separate"/><w:t>4</w:t><w:fldChar w:fldCharType="end"/></w:r>"#,
true,
),
(
"complex-break",
r#"<w:r><w:fldChar w:fldCharType="begin"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType="separate"/><w:br/><w:fldChar w:fldCharType="end"/></w:r>"#,
true,
),
(
"complex-whitespace",
r#"<w:r><w:fldChar w:fldCharType="begin"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType="separate"/><w:t xml:space="preserve"> </w:t><w:fldChar w:fldCharType="end"/></w:r>"#,
false,
),
(
"incomplete-complex",
r#"<w:r><w:fldChar w:fldCharType="begin"/><w:instrText>PAGE</w:instrText></w:r>"#,
false,
),
(
"simple-symbol",
r#"<w:fldSimple w:instr="PAGE"><w:r><w:sym w:font="Symbol" w:char="F041"/></w:r></w:fldSimple>"#,
true,
),
(
"simple-drawing",
r#"<w:fldSimple w:instr="PAGE"><w:r><w:drawing xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><wp:inline><wp:extent cx="914400" cy="914400"/><wp:docPr id="1" name="Actual"/><a:graphic><a:graphicData uri="urn:producer"/></a:graphic></wp:inline></w:drawing></w:r></w:fldSimple>"#,
true,
),
(
"complex-symbol",
r#"<w:r><w:fldChar w:fldCharType="begin"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType="separate"/><w:sym w:font="Symbol" w:char="F041"/><w:fldChar w:fldCharType="end"/></w:r>"#,
true,
),
(
"complex-drawing",
r#"<w:r><w:fldChar w:fldCharType="begin"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType="separate"/><w:drawing xmlns:wp="http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing" xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"><wp:inline><wp:extent cx="914400" cy="914400"/><wp:docPr id="1" name="Actual"/><a:graphic><a:graphicData uri="urn:producer"/></a:graphic></wp:inline></w:drawing><w:fldChar w:fldCharType="end"/></w:r>"#,
true,
),
(
"spanning-complex",
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>NUMPAGES</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:t>4</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
true,
),
(
"nested-complex",
r#"<w:r><w:fldChar w:fldCharType="begin"/><w:instrText>IF</w:instrText><w:fldChar w:fldCharType="separate"/><w:fldChar w:fldCharType="begin"/><w:instrText>PAGE</w:instrText><w:fldChar w:fldCharType="separate"/><w:t>4</w:t><w:fldChar w:fldCharType="end"/><w:fldChar w:fldCharType="end"/></w:r>"#,
true,
),
(
"unmatched-delimiters",
r#"<w:r><w:fldChar w:fldCharType="separate"/><w:fldChar w:fldCharType="end"/></w:r>"#,
false,
),
(
"default-loose-marker",
r#"<r xmlns="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><fldChar fldCharType="begin"/></r>"#,
false,
),
(
"alias-loose-marker",
r#"<q:r xmlns:q="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><q:fldChar q:fldCharType="begin"/></q:r>"#,
false,
),
(
"foreign-marker",
r#"<w:r><x:fldChar xmlns:x="urn:producer" x:fldCharType="begin"/></w:r>"#,
false,
),
(
"inserted-loose-marker",
r#"<w:ins><w:r><w:fldChar w:fldCharType="begin"/></w:r></w:ins>"#,
false,
),
(
"deleted-loose-marker",
r#"<w:del><w:r><w:fldChar w:fldCharType="begin"/></w:r></w:del>"#,
false,
),
(
"duplicate-inline-slot",
"<w:sdt><w:sdtContent><w:r/></w:sdtContent><w:sdtContent><w:r><w:drawing/></w:r></w:sdtContent></w:sdt>",
false,
),
(
"invalid-opaque-control",
r#"<w:sdt><w:sdtPr><w:id w:val="invalid"/></w:sdtPr><w:sdtContent><w:r><w:drawing/></w:r></w:sdtContent></w:sdt>"#,
false,
),
(
"admitted-inline-control",
"<w:sdt><w:sdtPr/><w:sdtContent><w:r><w:drawing/></w:r></w:sdtContent></w:sdt>",
true,
),
];
let mut failures = Vec::new();
for (label, fragment, visible) in cases {
let mut seed = Document::new();
seed.add_paragraph("CONTENT");
let mut package =
OpcPackage::from_reader(Cursor::new(seed.to_bytes().unwrap())).unwrap();
let part = package.main_document_part().unwrap();
let xml = String::from_utf8(package.get_part(&part).unwrap().to_vec()).unwrap();
let start = xml.find("<w:p>").unwrap();
let end = start + xml[start..].find("</w:p>").unwrap() + "</w:p>".len();
let mut xml = xml;
xml.replace_range(start..end, &format!("<w:p>{fragment}</w:p>").repeat(3));
package.set_part(&part, xml.into_bytes());
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
let document = Document::from_bytes(bytes.get_ref()).unwrap();
assert_eq!(document.paragraph_count(), 3, "{label}");
let before_model = document.document.to_xml().unwrap();
let before_parts = document.package.parts.clone();
match document.paragraph_visibility() {
Ok(flags) if flags == vec![visible; 3] => {}
actual => failures.push(format!(
"{label}: native visibility={actual:?}, expected={visible}"
)),
}
let issues = document.audit_accessibility();
assert_eq!(document.document.to_xml().unwrap(), before_model, "{label}");
assert_eq!(document.package.parts, before_parts, "{label}");
assert!(
issues
.iter()
.any(|issue| issue.message.contains("no title")),
"{label}"
);
assert!(
issues
.iter()
.any(|issue| issue.message.contains("no author")),
"{label}"
);
let empty_count = issues
.iter()
.filter(|issue| issue.message.contains("consecutive empty paragraphs"))
.count();
if empty_count != usize::from(!visible) {
failures.push(format!("{label}: visible={visible}, issues={issues:?}"));
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn audit_current_field_cache_rejects_stale_nontext_results_without_mutation() {
// A non-placeholder instruction distinguishes stale cache emptiness.
// Empty PAGE/NUMPAGES caches deliberately serialize their visible "1".
let mut failures = Vec::new();
for instruction in ["DOCPROPERTY Title", "PAGE", "NUMPAGES"] {
let mut result = CT_R::new("");
result.content.push(RunContent::Tab);
let field = Field::from_raw(
instruction,
rdocx_oxml::text::FieldForm::Complex,
vec![result],
)
.unwrap();
assert_eq!(field.cached_result, "\t");
assert!(field.cached_result_runs().is_some());
let mut paragraph = CT_P::new();
let mut run = CT_R::new("");
run.content = vec![RunContent::Field(field)];
paragraph.runs.push(run);
for (cache, cached_visible) in
[("\t", true), ("changed", true), ("", false), (" ", false)]
{
let placeholder = cache.is_empty() && instruction != "DOCPROPERTY Title";
let visible = cached_visible || placeholder;
let mut document = Document::new();
for _ in 0..3 {
let mut paragraph = paragraph.clone();
let mut fields = 0;
for run in &mut paragraph.runs {
for item in &mut run.content {
if let RunContent::Field(field) = item {
fields += 1;
assert!(field.cached_result_runs().is_some());
field.cached_result = cache.to_owned();
assert_eq!(field.cached_result_runs().is_some(), cache == "\t");
}
}
}
assert_eq!(fields, 1, "actual accepted complex-field projection");
document
.document
.body
.content
.push(BodyContent::Paragraph(paragraph));
}
let before_model = document.document.to_xml().unwrap();
let serialized = String::from_utf8(before_model.clone()).unwrap();
let before_parts = document.package.parts.clone();
assert_eq!(
serialized.matches("<w:tab").count(),
if cache == "\t" { 3 } else { 0 },
"{instruction}: stale typed result must not survive serialization"
);
assert_eq!(
serialized.matches(">1</w:t>").count(),
if placeholder { 3 } else { 0 },
"{instruction}: actual empty-cache placeholder precondition"
);
assert_eq!(
document.paragraph_visibility().unwrap(),
vec![visible; 3],
"{instruction}: cache={cache:?}"
);
let issues = document.audit_accessibility();
assert_eq!(document.document.to_xml().unwrap(), before_model);
assert_eq!(document.package.parts, before_parts);
let empty = issues
.iter()
.any(|issue| issue.message.contains("consecutive empty paragraphs"));
if empty == visible {
failures.push(format!(
"{instruction}: cache={cache:?}, visible={visible}: {issues:?}"
));
}
}
}
assert!(failures.is_empty(), "{}", failures.join("\n"));
}
#[test]
fn audit_indeterminate_current_source_retains_other_findings_and_omits_empty_streaks() {
let mut seed = Document::new();
let mut package = OpcPackage::from_reader(Cursor::new(seed.to_bytes().unwrap())).unwrap();
let part = package.main_document_part().unwrap();
let xml = concat!(
r#"<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns="urn:producer">"#,
"<w:body><w:p/><w:p/><w:p/><producer/></w:body></w:document>",
);
package.set_part(&part, xml.as_bytes().to_vec());
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
let mut document = Document::from_bytes(bytes.get_ref()).unwrap();
// A genuine unsaved edit forces current-source construction to refuse
// the producer's ambiguous default namespace, rather than reuse source.
document.add_paragraph("");
let before_model = document.document.to_xml().unwrap();
let before_parts = document.package.parts.clone();
let mut candidate = document.clone_for_staging();
let error = candidate
.flush_document_to_package()
.unwrap_err()
.to_string();
assert!(error.contains("shadowed `default` namespace"), "{error}");
let issues = document.audit_accessibility();
assert_eq!(document.document.to_xml().unwrap(), before_model);
assert_eq!(document.package.parts, before_parts);
assert!(
issues
.iter()
.any(|issue| issue.message.contains("no title"))
);
assert!(
issues
.iter()
.any(|issue| issue.message.contains("no author"))
);
let indeterminate: Vec<_> = issues
.iter()
.filter(|issue| {
issue
.message
.starts_with("Paragraph visibility could not be determined:")
})
.collect();
assert_eq!(indeterminate.len(), 1, "{issues:?}");
assert_eq!(indeterminate[0].severity, IssueSeverity::Warning);
assert!(indeterminate[0].message.contains(&error));
assert!(
!issues
.iter()
.any(|issue| issue.message.contains("consecutive empty paragraphs")),
"{issues:?}"
);
}
#[test]
fn audit_heading_level_gap() {
let mut doc = Document::new();
doc.set_title("Test");
doc.set_author("Test");
doc.add_paragraph("Ch 1").style("Heading1");
doc.add_paragraph("Skip to 3").style("Heading3");
let issues = doc.audit_accessibility();
assert!(
issues
.iter()
.any(|i| i.message.contains("Heading level gap"))
);
}
#[test]
fn links_returns_empty_for_no_hyperlinks() {
let mut doc = Document::new();
doc.add_paragraph("No links here.");
assert!(doc.links().is_empty());
}
#[test]
fn links_exposes_a_complex_field_hyperlink_target() {
let mut doc = Document::new();
let xml = concat!(
r#"<w:p>"#,
r#"<w:r><w:fldChar w:fldCharType="begin"/></w:r>"#,
r#"<w:r><w:instrText> HYPERLINK "https://example.test" </w:instrText></w:r>"#,
r#"<w:r><w:fldChar w:fldCharType="separate"/></w:r>"#,
r#"<w:r><w:t>Cached link</w:t></w:r>"#,
r#"<w:r><w:fldChar w:fldCharType="end"/></w:r>"#,
r#"</w:p>"#,
);
let mut reader = quick_xml::Reader::from_str(xml);
let mut buffer = Vec::new();
let paragraph = loop {
match reader.read_event_into(&mut buffer).unwrap() {
Event::Start(element) if matches_local_name(element.name().as_ref(), b"p") => {
break CT_P::from_xml(&mut reader).unwrap();
}
Event::Eof => panic!("missing paragraph"),
_ => {}
}
buffer.clear();
};
doc.document
.body
.content
.push(BodyContent::Paragraph(paragraph));
assert_eq!(
doc.links(),
vec![LinkInfo {
text: "Cached link".to_owned(),
url: Some("https://example.test".to_owned()),
anchor: None,
rel_id: None,
}]
);
}
#[test]
fn images_returns_empty_for_text_only() {
let mut doc = Document::new();
doc.add_paragraph("Just text.");
assert!(doc.images().is_empty());
}
#[test]
fn numbering_getter_round_trips() {
let mut doc = Document::new();
doc.add_bullet_list_item("bullet item", 0);
doc.add_numbered_list_item("numbered item", 0);
doc.add_paragraph("plain");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
let (bullet_id, bullet_lvl) = paras[0].numbering().expect("bullet numbering");
assert_eq!(bullet_lvl, 0);
assert_eq!(doc2.numbering_is_bullet(bullet_id), Some(true));
let (num_id, _) = paras[1].numbering().expect("numbered numbering");
assert_eq!(doc2.numbering_is_bullet(num_id), Some(false));
assert!(paras[2].numbering().is_none());
}
#[test]
fn highlight_getter_round_trips() {
let mut doc = Document::new();
{
let mut p = doc.add_paragraph("");
let mut r = p.add_run("glowing");
r = r.highlight("yellow");
let _ = r;
}
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
let run = paras[0].runs().next().expect("run");
assert_eq!(run.highlight().as_deref(), Some("yellow"));
}
#[test]
fn run_style_id_getter_round_trips() {
let mut doc = Document::new();
{
let mut p = doc.add_paragraph("");
let mut r = p.add_run("code text");
r = r.style("SourceText");
let _ = r;
}
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
let run = paras[0].runs().next().expect("run");
assert_eq!(run.style_id(), Some("SourceText"));
}
#[test]
fn append_hyperlink_round_trips() {
let mut doc = Document::new();
doc.add_paragraph("visit ");
doc.append_hyperlink("GNOME", "https://gnome.org");
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let links = doc2.links();
assert_eq!(links.len(), 1);
assert_eq!(links[0].text, "GNOME");
assert_eq!(links[0].url.as_deref(), Some("https://gnome.org"));
assert_eq!(doc2.paragraphs()[0].text(), "visit GNOME");
let paras = doc2.paragraphs();
let spans = paras[0].hyperlink_spans();
assert_eq!(spans.len(), 1);
let (start, end, rel_id) = (spans[0].0, spans[0].1, spans[0].2);
assert_eq!(end - start, 1);
let url = doc2.hyperlink_url(rel_id.expect("rel id"));
assert_eq!(url.as_deref(), Some("https://gnome.org"));
}
#[test]
fn paragraph_hard_break_and_table_cell_hyperlink_round_trip() {
let mut doc = Document::new();
let relationship_id = doc.add_hyperlink_relationship("https://example.com/table");
let mut paragraph = doc.add_paragraph("");
paragraph.add_run("before");
paragraph.add_line_break();
paragraph.add_run("after");
let mut table = doc.add_table(1, 1);
let mut cell = table.cell(0, 0).expect("cell");
cell.add_paragraph("")
.add_hyperlink("table link", &relationship_id)
.bold(true);
cell.remove_first_empty_paragraph();
let bytes = doc.to_bytes().unwrap();
let reopened = Document::from_bytes(&bytes).unwrap();
assert_eq!(reopened.paragraphs()[0].text(), "before\nafter");
let tables = reopened.tables();
let cell = tables[0].cell(0, 0).expect("cell");
let paragraph = cell.paragraphs().next().expect("paragraph");
assert_eq!(paragraph.text(), "table link");
assert!(paragraph.runs().next().expect("run").is_bold());
let spans = paragraph.hyperlink_spans();
assert_eq!(spans.len(), 1);
assert_eq!(
reopened.hyperlink_url(spans[0].2.expect("relationship id")),
Some("https://example.com/table".to_string())
);
}
#[test]
fn writer_hyperlink_tooltip_and_table_indent_round_trip() {
let mut doc = Document::new();
let relationship_id = doc.add_hyperlink_relationship("https://example.com");
doc.add_paragraph("")
.add_hyperlink_with_tooltip("linked", &relationship_id, Some("Example site"))
.unwrap();
doc.add_table(1, 1).set_indent(Length::twips(720));
let bytes = doc.to_bytes().expect("document writes");
let reopened = Document::from_bytes(&bytes).expect("document reopens");
let BodyContent::Paragraph(paragraph) = &reopened.document.body.content[0] else {
panic!("expected paragraph");
};
assert_eq!(
paragraph.hyperlinks[0].tooltip.as_deref(),
Some("Example site")
);
assert!(paragraph.hyperlinks[0].extra_attributes.is_empty());
let BodyContent::Table(table) = &reopened.document.body.content[1] else {
panic!("expected table");
};
assert_eq!(
table
.properties
.as_ref()
.and_then(|properties| properties.indent.as_ref()),
Some(&rdocx_oxml::table::CT_TblWidth::dxa(720))
);
}
#[test]
fn authored_hyphenation_setting_and_run_language_round_trip_into_layout() {
let mut document = Document::new();
document.set_auto_hyphenation(true).unwrap();
document
.add_paragraph("")
.add_run("representation")
.language("en-US");
let bytes = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&bytes).unwrap();
assert!(reopened.build_layout_input().unwrap().automatic_hyphenation);
assert_eq!(
reopened.paragraphs()[0].run(0).unwrap().language(),
Some("en-US")
);
let settings_part = reopened.settings_part_name.as_deref().unwrap();
let settings_xml =
std::str::from_utf8(reopened.package.get_part(settings_part).unwrap()).unwrap();
assert!(settings_xml.contains("<w:autoHyphenation/>"));
}
#[test]
fn authored_settings_do_not_overwrite_an_unrelated_conventional_part() {
let mut document =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
let unrelated = br#"<producer:metadata xmlns:producer="urn:producer"/>"#.to_vec();
document
.package
.set_part(DEFAULT_SETTINGS_PART, unrelated.clone());
document
.package
.content_types
.add_override(DEFAULT_SETTINGS_PART, "application/example+xml");
document.set_auto_hyphenation(true).unwrap();
let bytes = document.to_bytes().unwrap();
let package = OpcPackage::from_reader(std::io::Cursor::new(bytes)).unwrap();
assert_eq!(
package.get_part(DEFAULT_SETTINGS_PART),
Some(unrelated.as_slice())
);
assert_eq!(
package
.content_types
.content_type_for(DEFAULT_SETTINGS_PART),
Some("application/example+xml")
);
let relationship = package
.get_part_rels("/word/document.xml")
.and_then(|relationships| relationships.get_by_type(rel_types::SETTINGS))
.expect("authored settings relationship");
let settings_part =
OpcPackage::resolve_rel_target("/word/document.xml", &relationship.target);
assert_ne!(settings_part, DEFAULT_SETTINGS_PART);
assert!(
std::str::from_utf8(package.get_part(&settings_part).unwrap())
.unwrap()
.contains("<w:autoHyphenation/>")
);
}
#[test]
fn pending_settings_part_name_is_reserved_against_rich_fragment_imports() {
let mut document =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
document.set_auto_hyphenation(true).unwrap();
let settings_part = document.settings_part_name.clone().unwrap();
let imported = document
.identifiers
.reserve_fragment_part_name(&settings_part)
.unwrap();
assert_ne!(imported, settings_part);
assert!(imported.contains("-merge-"));
}
#[test]
fn settings_mutations_roll_back_relationship_exhaustion() {
fn exhausted_document() -> Document {
let mut source = Document::new_with_profile(WordCreationProfile::Minimal(
WordPackageClass::Document,
));
let bytes = source.to_bytes().unwrap();
let mut package = OpcPackage::from_reader(Cursor::new(bytes)).unwrap();
let relationships = package.get_or_create_part_rels("/word/document.xml");
relationships.add_with_id(
&format!("rId{}", u32::MAX),
"urn:exhaustion",
"unchanged.bin",
);
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
Document::from_bytes(bytes.get_ref()).unwrap()
}
let package_bytes = |document: &Document| {
let mut bytes = Cursor::new(Vec::new());
document.package.write_to(&mut bytes).unwrap();
bytes.into_inner()
};
let mut hyphenation = exhausted_document();
let before = package_bytes(&hyphenation);
let error = hyphenation.set_auto_hyphenation(true).unwrap_err();
assert!(
error
.to_string()
.contains("settings relationship allocation failed")
);
assert_eq!(package_bytes(&hyphenation), before);
assert!(hyphenation.settings_part_name.is_none());
let mut math = exhausted_document();
let before = package_bytes(&math);
let error = math
.set_math_properties(MathProperties::default())
.unwrap_err();
assert!(
error
.to_string()
.contains("settings relationship allocation failed")
);
assert_eq!(package_bytes(&math), before);
assert!(math.settings_part_name.is_none());
let mut update_fields = exhausted_document();
let before = package_bytes(&update_fields);
update_fields.set_update_fields_on_open(None).unwrap();
assert_eq!(package_bytes(&update_fields), before);
assert!(update_fields.settings_part_name.is_none());
let mut update_fields = exhausted_document();
let before = package_bytes(&update_fields);
let error = update_fields
.set_update_fields_on_open(Some(true))
.unwrap_err();
assert!(
error
.to_string()
.contains("settings relationship allocation failed")
);
assert_eq!(package_bytes(&update_fields), before);
assert!(update_fields.settings_part_name.is_none());
}
#[test]
fn serialization_reports_checked_styles_and_comments_relationship_exhaustion() {
let mut style_source =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
let source_bytes = style_source.to_bytes().unwrap();
let mut style_package = OpcPackage::from_reader(Cursor::new(source_bytes)).unwrap();
let relationships = style_package.get_or_create_part_rels("/word/document.xml");
relationships
.items
.retain(|relationship| relationship.rel_type != rel_types::STYLES);
relationships.add_with_id(
&format!("rId{}", u32::MAX),
"urn:exhaustion",
"unchanged.bin",
);
style_package.parts.remove(DEFAULT_STYLES_PART);
style_package
.content_types
.overrides
.remove(DEFAULT_STYLES_PART);
let mut bytes = Cursor::new(Vec::new());
style_package.write_to(&mut bytes).unwrap();
let mut style_less = Document::from_bytes(bytes.get_ref()).unwrap();
let error = style_less.to_bytes().unwrap_err();
assert!(
error
.to_string()
.contains("styles relationship allocation failed")
);
let mut comments_source =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
let source_bytes = comments_source.to_bytes().unwrap();
let mut comments_package = OpcPackage::from_reader(Cursor::new(source_bytes)).unwrap();
comments_package
.get_or_create_part_rels("/word/document.xml")
.add_with_id(
&format!("rId{}", u32::MAX),
"urn:exhaustion",
"unchanged.bin",
);
let mut bytes = Cursor::new(Vec::new());
comments_package.write_to(&mut bytes).unwrap();
let mut comments = Document::from_bytes(bytes.get_ref()).unwrap();
comments.comments = Some(rdocx_oxml::comments::CT_Comments::new());
comments.comments_part_name = Some("/word/comments.xml".to_owned());
let error = comments.to_bytes().unwrap_err();
assert!(
error
.to_string()
.contains("comments relationship allocation failed")
);
}
#[test]
fn all_public_numbering_level_properties_survive_reopen() {
let formats = [
ListNumberFormat::Decimal,
ListNumberFormat::UpperRoman,
ListNumberFormat::LowerRoman,
ListNumberFormat::UpperLetter,
ListNumberFormat::LowerLetter,
ListNumberFormat::Ordinal,
ListNumberFormat::CardinalText,
ListNumberFormat::OrdinalText,
ListNumberFormat::Hex,
ListNumberFormat::Chicago,
ListNumberFormat::IdeographDigital,
ListNumberFormat::JapaneseCounting,
ListNumberFormat::Aiueo,
ListNumberFormat::Iroha,
ListNumberFormat::DecimalFullWidth,
ListNumberFormat::DecimalHalfWidth,
ListNumberFormat::JapaneseLegal,
ListNumberFormat::JapaneseDigitalTenThousand,
ListNumberFormat::DecimalEnclosedCircle,
ListNumberFormat::DecimalFullWidth2,
ListNumberFormat::AiueoFullWidth,
ListNumberFormat::IrohaFullWidth,
ListNumberFormat::DecimalZero,
ListNumberFormat::Bullet,
ListNumberFormat::Ganada,
ListNumberFormat::Chosung,
ListNumberFormat::DecimalEnclosedFullstop,
ListNumberFormat::DecimalEnclosedParen,
ListNumberFormat::DecimalEnclosedCircleChinese,
ListNumberFormat::IdeographEnclosedCircle,
ListNumberFormat::IdeographTraditional,
ListNumberFormat::IdeographZodiac,
ListNumberFormat::IdeographZodiacTraditional,
ListNumberFormat::TaiwaneseCounting,
ListNumberFormat::IdeographLegalTraditional,
ListNumberFormat::TaiwaneseCountingThousand,
ListNumberFormat::TaiwaneseDigital,
ListNumberFormat::ChineseCounting,
ListNumberFormat::ChineseLegalSimplified,
ListNumberFormat::ChineseCountingThousand,
ListNumberFormat::KoreanDigital,
ListNumberFormat::KoreanCounting,
ListNumberFormat::KoreanLegal,
ListNumberFormat::KoreanDigital2,
ListNumberFormat::Hebrew1,
ListNumberFormat::ArabicAlpha,
ListNumberFormat::Hebrew2,
ListNumberFormat::ArabicAbjad,
ListNumberFormat::HindiVowels,
ListNumberFormat::HindiConsonants,
ListNumberFormat::HindiNumbers,
ListNumberFormat::HindiCounting,
ListNumberFormat::ThaiLetters,
ListNumberFormat::ThaiNumbers,
ListNumberFormat::ThaiCounting,
ListNumberFormat::VietnameseCounting,
ListNumberFormat::NumberInDash,
ListNumberFormat::RussianLower,
ListNumberFormat::RussianUpper,
ListNumberFormat::None,
];
let mut document = Document::new();
for chunk in formats.chunks(9) {
let levels = chunk
.iter()
.cloned()
.map(ListLevel::new)
.collect::<Vec<_>>();
document.add_numbering_definition(&levels).unwrap();
}
let bytes = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&bytes).unwrap();
let reopened_formats = reopened
.numbering_definitions()
.into_iter()
.flat_map(|definition| definition.levels)
.map(|level| level.properties.format)
.collect::<Vec<_>>();
assert_eq!(reopened_formats, formats);
let mut document = Document::new();
let levels = [
ListLevel::decimal(),
ListLevel::new(ListNumberFormat::LowerRoman)
.start(4)
.level_text("%1.%2)")
.suffix(ListLevelSuffix::Space)
.alignment(Alignment::Center)
.indentation(Some(Twips(1440)), Some(Twips(240)), None)
.marker_properties(CT_RPr {
bold: Some(true),
color: Some("2B6FE3".to_owned()),
..CT_RPr::default()
})
.legal_numbering(true)
.restart(ListLevelRestart::After(0))
.template_code("A1B2C3D4")
.tentative(false),
];
let definition_id = document.add_numbering_definition(&levels).unwrap();
let instance_id = document.add_numbering_instance(definition_id, &[]).unwrap();
let bytes = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&bytes).unwrap();
let level = reopened.numbering_level(instance_id, 1).unwrap();
assert_eq!(level.format, NumberingFormat::LowerRoman);
assert_eq!(level.start, 4);
assert_eq!(level.level_text, Some("%1.%2)"));
assert_eq!(level.suffix, ListLevelSuffix::Space);
assert_eq!(level.alignment, Some(Alignment::Center));
assert_eq!(level.indent_left, Some(Twips(1440)));
assert_eq!(level.indent_hanging, Some(Twips(240)));
assert_eq!(level.legal_numbering, Some(true));
assert_eq!(level.restart, Some(ListLevelRestart::After(0)));
assert_eq!(level.template_code, Some("A1B2C3D4"));
assert_eq!(level.tentative, Some(false));
assert_eq!(level.marker_properties.unwrap().bold, Some(true));
assert_eq!(
level.marker_properties.unwrap().color.as_deref(),
Some("2B6FE3")
);
let mut imported = Document::new();
let definition_id = imported
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
let instance_id = imported.add_numbering_instance(definition_id, &[]).unwrap();
let xml = format!(
r#"<n:numbering xmlns:n="{WORD_NAMESPACE}"><n:abstractNum n:abstractNumId="{definition_id}"><n:lvl n:ilvl="0"><n:start n:val="1"/><n:numFmt n:val="decimal"/><n:pStyle n:val="Normal"></n:pStyle><n:lvlText n:val="%1."/></n:lvl></n:abstractNum><n:num n:numId="{instance_id}"><n:abstractNumId n:val="{definition_id}"/></n:num></n:numbering>"#
);
let bytes = replace_numbering_xml(&mut imported, xml.into_bytes());
let imported = Document::from_bytes(&bytes).unwrap();
let definition = imported.numbering_definition(definition_id).unwrap();
assert_eq!(
definition.paragraph_style_links,
vec![Some("Normal".to_owned())]
);
assert!(!definition.has_unmodeled_properties);
let level = imported.numbering_level(instance_id, 0).unwrap();
assert_eq!(level.paragraph_style, Some("Normal"));
assert!(!level.has_unmodeled_properties);
}
#[test]
fn list_level_semantic_equality_ignores_source_provenance() {
let authored = ListLevel::new(ListNumberFormat::LowerRoman)
.start(4)
.level_text("%1.%2)")
.suffix(ListLevelSuffix::Space)
.alignment(Alignment::Center)
.indentation(Some(Twips(1440)), Some(Twips(240)), None)
.marker_properties(CT_RPr {
bold: Some(true),
color: Some("2B6FE3".to_owned()),
..CT_RPr::default()
})
.legal_numbering(true)
.restart(ListLevelRestart::After(0))
.template_code("A1B2C3D4")
.tentative(false);
let source = ct_level_from_list(1, &authored).unwrap();
let inspected = list_level_from_ct(&source);
let reconstructed = list_level_values_from_ct(&source);
assert_eq!(inspected, reconstructed);
assert_ne!(inspected, reconstructed.clone().start(9));
}
#[test]
fn numbering_instances_and_overrides_round_trip_in_schema_order() {
let mut document = Document::new();
let definition_id = document
.add_numbering_definition(&[ListLevel::decimal(), ListLevel::decimal()])
.unwrap();
let instance_id = document
.add_numbering_instance(
definition_id,
&[NumberingLevelOverride::new(1)
.start(5)
.replacement(ListLevel::new(ListNumberFormat::UpperRoman).level_text("%2)"))],
)
.unwrap();
let bytes = document.to_bytes().unwrap();
let package = OpcPackage::from_reader(Cursor::new(&bytes)).unwrap();
let xml =
String::from_utf8(package.get_part("/word/numbering.xml").unwrap().to_vec()).unwrap();
let instance = xml
.find(&format!("<w:num w:numId=\"{instance_id}\""))
.unwrap();
let abstract_id = xml[instance..].find("<w:abstractNumId ").unwrap();
let level_override = xml[instance..].find("<w:lvlOverride ").unwrap();
let start_override = xml[instance + level_override..]
.find("<w:startOverride ")
.unwrap();
let replacement = xml[instance + level_override..]
.find("<w:lvl w:ilvl=\"1\"")
.unwrap();
assert!(abstract_id < level_override);
assert!(start_override < replacement);
let reopened = Document::from_bytes(&bytes).unwrap();
let instance = reopened.numbering_instance(instance_id).unwrap();
assert_eq!(instance.definition_id, definition_id);
assert_eq!(instance.level_overrides.len(), 1);
assert_eq!(instance.level_overrides[0].level, 1);
assert_eq!(instance.level_overrides[0].start, Some(5));
assert_eq!(
instance.level_overrides[0]
.replacement
.as_ref()
.unwrap()
.format,
ListNumberFormat::UpperRoman
);
}
#[test]
fn public_authored_numbering_reports_no_unmodeled_properties() {
let mut document = Document::new();
let definition_id = document
.add_numbering_definition(&[
ListLevel::decimal().level_text("%1."),
ListLevel::new(ListNumberFormat::LowerLetter)
.level_text("%1.%2.")
.legal_numbering(false),
])
.unwrap();
let instance_id = document
.add_numbering_instance(definition_id, &[NumberingLevelOverride::new(1).start(3)])
.unwrap();
let bytes = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&bytes).unwrap();
assert!(
!reopened
.numbering_definition(definition_id)
.unwrap()
.has_unmodeled_properties
);
assert!(
!reopened
.numbering_instance(instance_id)
.unwrap()
.has_unmodeled_properties
);
assert!(
!reopened
.numbering_level(instance_id, 1)
.unwrap()
.has_unmodeled_properties
);
}
#[test]
fn imported_numbering_projection_update_preserves_unknown_format_and_omissions() {
let mut source = Document::new();
let definition_id = source
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
let instance_id = source.add_numbering_instance(definition_id, &[]).unwrap();
let xml = format!(
r#"<w:numbering xmlns:w="{WORD_NAMESPACE}"><w:abstractNum w:abstractNumId="{definition_id}"><w:lvl w:ilvl="0"><w:numFmt w:val="producerFormat"/></w:lvl></w:abstractNum><w:num w:numId="{instance_id}"><w:abstractNumId w:val="{definition_id}"/></w:num></w:numbering>"#
);
let imported = replace_numbering_xml(&mut source, xml.into_bytes());
let mut document = Document::from_bytes(&imported).unwrap();
let canonical = document.to_bytes().unwrap();
let before = OpcPackage::from_reader(Cursor::new(&canonical))
.unwrap()
.get_part(DEFAULT_NUMBERING_PART)
.unwrap()
.to_vec();
let definition = document.numbering_definition(definition_id).unwrap();
assert_eq!(
definition.levels[0].properties.format,
ListNumberFormat::Other("producerFormat".to_owned())
);
assert_eq!(definition.levels[0].properties.start, None);
assert_eq!(
definition.levels[0].properties.indentation_value(),
(None, None, None)
);
document
.update_numbering_definition(definition_id, &definition.levels)
.unwrap();
let after_bytes = document.to_bytes().unwrap();
let after = OpcPackage::from_reader(Cursor::new(after_bytes))
.unwrap()
.get_part(DEFAULT_NUMBERING_PART)
.unwrap()
.to_vec();
assert_eq!(after, before);
}
#[test]
fn numbering_crud_updates_and_removes_unreferenced_values() {
let mut document = Document::new();
let first_definition = document
.add_numbering_definition(&[ListLevel::decimal(), ListLevel::decimal()])
.unwrap();
let second_definition = document
.add_numbering_definition(&[ListLevel::decimal(), ListLevel::decimal()])
.unwrap();
assert_ne!(first_definition, second_definition);
let instance_id = document
.add_numbering_instance(first_definition, &[NumberingLevelOverride::new(1).start(3)])
.unwrap();
let mut definition = document.numbering_definition(first_definition).unwrap();
definition.levels[1].properties = definition.levels[1]
.properties
.clone()
.start(7)
.level_text("%1.%2)")
.suffix(ListLevelSuffix::Space);
document
.update_numbering_definition(first_definition, &definition.levels)
.unwrap();
let mut instance = document.numbering_instance(instance_id).unwrap();
instance.level_overrides[0].start = Some(9);
document
.update_numbering_instance(instance_id, second_definition, &instance.level_overrides)
.unwrap();
let bytes = document.to_bytes().unwrap();
let mut reopened = Document::from_bytes(&bytes).unwrap();
assert_eq!(
reopened
.numbering_definition(first_definition)
.unwrap()
.levels[1]
.properties
.start,
Some(7)
);
let instance = reopened.numbering_instance(instance_id).unwrap();
assert_eq!(instance.definition_id, second_definition);
assert_eq!(instance.level_overrides[0].start, Some(9));
assert!(reopened.remove_numbering_instance(instance_id).unwrap());
assert!(
reopened
.remove_numbering_definition(first_definition)
.unwrap()
);
assert!(
reopened
.remove_numbering_definition(second_definition)
.unwrap()
);
assert!(!reopened.remove_numbering_instance(instance_id).unwrap());
let reopened = Document::from_bytes(&reopened.to_bytes().unwrap()).unwrap();
assert!(reopened.numbering_definitions().is_empty());
assert!(reopened.numbering_instances().is_empty());
}
#[test]
fn moving_a_live_numbering_instance_cannot_orphan_its_paragraph_level() {
let mut document = Document::new();
let complete = document
.add_numbering_definition(&[
ListLevel::decimal(),
ListLevel::decimal(),
ListLevel::decimal(),
])
.unwrap();
let incomplete = document
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
let instance = document.add_numbering_instance(complete, &[]).unwrap();
assert!(
document
.add_paragraph("deep item")
.set_numbering(instance, 2)
);
let baseline = document.to_bytes().unwrap();
assert!(
document
.update_numbering_instance(instance, incomplete, &[])
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), baseline);
assert_eq!(
document.numbering_instance(instance).unwrap().definition_id,
complete
);
}
#[test]
fn moving_an_instance_cannot_orphan_a_style_inherited_paragraph_level() {
let mut document = Document::new();
let complete = document
.add_numbering_definition(&[
ListLevel::decimal(),
ListLevel::decimal(),
ListLevel::decimal(),
])
.unwrap();
let incomplete = document
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
let instance = document.add_numbering_instance(complete, &[]).unwrap();
document
.add_style(StyleBuilder::paragraph("DeepListBase", "Deep List Base"))
.unwrap();
document
.link_style_to_numbering("DeepListBase", instance, 2)
.unwrap();
document
.add_style(
StyleBuilder::paragraph("DeepListChild", "Deep List Child")
.based_on("DeepListBase"),
)
.unwrap();
document.add_paragraph("deep item").style("DeepListChild");
let baseline = document.to_bytes().unwrap();
assert!(
document
.update_numbering_instance(instance, incomplete, &[])
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), baseline);
assert_eq!(
document.numbering_instance(instance).unwrap().definition_id,
complete
);
}
#[test]
fn moving_an_instance_cannot_orphan_a_related_story_paragraph_level() {
let mut document = Document::new();
let complete = document
.add_numbering_definition(&[
ListLevel::decimal(),
ListLevel::decimal(),
ListLevel::decimal(),
])
.unwrap();
let incomplete = document
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
let instance = document.add_numbering_instance(complete, &[]).unwrap();
document.set_header("header");
let (relationship_id, _) = document.header_footer_rel_ids().into_iter().next().unwrap();
let part_name = {
let relationship = document
.package
.get_part_rels(&document.doc_part_name)
.unwrap()
.get_by_id(&relationship_id)
.unwrap();
OpcPackage::resolve_rel_target(&document.doc_part_name, &relationship.target)
};
document.package.set_part(
&part_name,
format!(
r#"<n:hdr xmlns:n="{WORD_NAMESPACE}"><n:p><n:pPr><n:numPr><n:ilvl n:val="2"/><n:numId n:val="{instance}"/></n:numPr></n:pPr><n:r><n:t>header</n:t></n:r></n:p></n:hdr>"#
)
.into_bytes(),
);
let baseline = document.to_bytes().unwrap();
assert!(
document
.update_numbering_instance(instance, incomplete, &[])
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), baseline);
assert_eq!(
document.numbering_instance(instance).unwrap().definition_id,
complete
);
}
#[test]
fn paragraph_numbering_scan_does_not_pair_different_paragraphs() {
let xml = format!(
r#"<n:root xmlns:n="{WORD_NAMESPACE}"><n:p><n:pPr><n:numPr><n:ilvl n:val="2"/></n:numPr></n:pPr></n:p><n:p><n:pPr><n:numPr><n:numId n:val="7"/></n:numPr></n:pPr></n:p></n:root>"#
);
let properties = xml_paragraph_numbering_properties(xml.as_bytes()).unwrap();
assert_eq!(properties.len(), 2);
assert_eq!(properties[0].num_id, None);
assert_eq!(properties[0].num_ilvl, Some(2));
assert_eq!(properties[1].num_id, Some(7));
assert_eq!(properties[1].num_ilvl, None);
}
#[test]
fn opaque_custom_xml_numbering_data_is_not_a_live_story_reference() {
const CUSTOM_XML_RELATIONSHIP: &str =
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/customXml";
const CUSTOM_XML_PART: &str = "/customXml/item1.xml";
let mut document = Document::new();
let complete = document
.add_numbering_definition(&[
ListLevel::decimal(),
ListLevel::decimal(),
ListLevel::decimal(),
])
.unwrap();
let incomplete = document
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
let instance = document.add_numbering_instance(complete, &[]).unwrap();
document.package.set_part(
CUSTOM_XML_PART,
format!(
r#"<w:p xmlns:w="{WORD_NAMESPACE}"><w:pPr><w:numPr><w:ilvl w:val="2"/><w:numId w:val="{instance}"/></w:numPr></w:pPr></w:p>"#
)
.into_bytes(),
);
document
.package
.content_types
.add_override(CUSTOM_XML_PART, "application/xml");
document
.package
.get_or_create_part_rels(&document.doc_part_name)
.add_with_id(
"producerCustomXml",
CUSTOM_XML_RELATIONSHIP,
"../customXml/item1.xml",
);
document
.update_numbering_instance(instance, incomplete, &[])
.unwrap();
assert_eq!(
document.numbering_instance(instance).unwrap().definition_id,
incomplete
);
assert!(document.remove_numbering_instance(instance).unwrap());
let saved = OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
assert!(saved.get_part(CUSTOM_XML_PART).is_some());
}
#[test]
fn numbering_instance_removal_respects_style_references() {
let mut document = Document::new();
let definition = document
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
let instance = document.add_numbering_instance(definition, &[]).unwrap();
document
.add_style(StyleBuilder::paragraph(
"UnusedListStyle",
"Unused List Style",
))
.unwrap();
document
.link_style_to_numbering("UnusedListStyle", instance, 0)
.unwrap();
assert!(document.remove_numbering_instance(instance).is_err());
assert!(document.numbering_instance(instance).is_some());
}
#[test]
fn style_numbering_link_is_atomic_in_both_directions() {
let mut document = Document::new();
document
.add_style(StyleBuilder::paragraph(
"NumberedHeading",
"Numbered Heading",
))
.unwrap();
let definition = document
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
let instance = document.add_numbering_instance(definition, &[]).unwrap();
let baseline = document.to_bytes().unwrap();
assert!(
document
.add_style(
StyleBuilder::paragraph("OneSidedAdd", "One-sided add").paragraph_properties(
CT_PPr {
num_id: Some(instance),
num_ilvl: Some(0),
..CT_PPr::default()
}
),
)
.is_err()
);
assert!(document.style("OneSidedAdd").is_none());
assert!(
document
.set_style(
StyleBuilder::paragraph("NumberedHeading", "Numbered Heading")
.paragraph_properties(CT_PPr {
num_id: Some(instance),
num_ilvl: Some(0),
..CT_PPr::default()
}),
)
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), baseline);
assert!(
document
.link_style_to_numbering("MissingStyle", instance, 0)
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), baseline);
assert!(
document
.link_style_to_numbering("NumberedHeading", instance, 8)
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), baseline);
document
.link_style_to_numbering("NumberedHeading", instance, 0)
.unwrap();
let style = document.style("NumberedHeading").unwrap();
let properties = style.paragraph_properties().unwrap();
assert_eq!(properties.num_id, Some(instance));
assert_eq!(properties.num_ilvl, Some(0));
assert_eq!(
document
.numbering_definition(definition)
.unwrap()
.paragraph_style_links,
vec![Some("NumberedHeading".to_owned())]
);
let linked = document.to_bytes().unwrap();
assert!(
document
.unlink_style_from_numbering("NumberedHeading", instance, 1)
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), linked);
document
.unlink_style_from_numbering("NumberedHeading", instance, 0)
.unwrap();
let style = document.style("NumberedHeading").unwrap();
assert_eq!(style.paragraph_properties().unwrap().num_id, None);
assert_eq!(style.paragraph_properties().unwrap().num_ilvl, None);
assert_eq!(
document
.numbering_definition(definition)
.unwrap()
.paragraph_style_links,
vec![None]
);
}
#[test]
fn numbering_graph_rejects_each_one_sided_style_link() {
let mut document = Document::new();
let normal = document
.styles
.styles
.iter_mut()
.find(|style| style.style_id == "Normal")
.unwrap();
normal.ppr = Some(CT_PPr {
num_id: Some(99),
..CT_PPr::default()
});
assert!(document.validate_numbering_graph().is_err());
document
.styles
.styles
.iter_mut()
.find(|style| style.style_id == "Normal")
.unwrap()
.ppr = None;
let definition = document
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
let instance = document.add_numbering_instance(definition, &[]).unwrap();
document.numbering.as_mut().unwrap().abstract_nums[0].levels[0].p_style =
Some("Normal".to_owned());
assert!(document.validate_numbering_graph().is_err());
let baseline = document.to_bytes().unwrap();
assert!(
document
.link_style_to_numbering("Heading1", instance, 0)
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), baseline);
}
#[test]
fn style_numbering_link_overlays_extended_num_pr_payload() {
let mut source = Document::new();
source
.add_style(StyleBuilder::paragraph("ExtLinked", "Extended Linked"))
.unwrap();
let definition = source
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
let instance = source.add_numbering_instance(definition, &[]).unwrap();
let mut package = OpcPackage::from_reader(Cursor::new(source.to_bytes().unwrap())).unwrap();
let mut styles =
String::from_utf8(package.get_part(DEFAULT_STYLES_PART).unwrap().to_vec()).unwrap();
let style_start = styles.find(r#"w:styleId="ExtLinked""#).unwrap();
let style_end = styles[style_start..].find("</w:style>").unwrap() + style_start;
styles.insert_str(
style_end,
r#"<w:pPr><w:numPr xmlns:ext="urn:producer" ext:root="a b"><ext:before/><w:ilvl ext:leaf="level"><ext:level-child/></w:ilvl><w:numId ext:leaf="id"><ext:id-child/></w:numId><ext:after/></w:numPr></w:pPr>"#,
);
package.set_part(DEFAULT_STYLES_PART, styles.into_bytes());
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
let mut document = Document::from_bytes(bytes.get_ref()).unwrap();
document
.link_style_to_numbering("ExtLinked", instance, 0)
.unwrap();
let linked = OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
let linked =
String::from_utf8(linked.get_part(DEFAULT_STYLES_PART).unwrap().to_vec()).unwrap();
for retained in [
r#"ext:root="a b""#,
"<ext:before",
"<ext:level-child/>",
"<ext:id-child/>",
"<ext:after",
] {
assert!(linked.contains(retained), "{linked}");
}
assert!(linked.contains(r#"w:val="0""#), "{linked}");
assert!(
linked.contains(&format!(r#"w:val="{instance}""#)),
"{linked}"
);
document
.unlink_style_from_numbering("ExtLinked", instance, 0)
.unwrap();
let unlinked = OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
let unlinked =
String::from_utf8(unlinked.get_part(DEFAULT_STYLES_PART).unwrap().to_vec()).unwrap();
let num_pr_start = unlinked.find("<w:numPr").unwrap();
let num_pr_end = unlinked[num_pr_start..].find("</w:numPr>").unwrap()
+ num_pr_start
+ "</w:numPr>".len();
assert_eq!(
&unlinked[num_pr_start..num_pr_end],
"<w:numPr xmlns:ext=\"urn:producer\" ext:root=\"a b\"><ext:before/><w:ilvl ext:leaf=\"level\"><ext:level-child/></w:ilvl><w:numId ext:leaf=\"id\"><ext:id-child/></w:numId><ext:after/></w:numPr>"
);
}
#[test]
fn style_linked_numbering_survives_reopen_and_rebuild() {
let mut document = Document::new();
document
.add_style(StyleBuilder::paragraph(
"NumberedHeading",
"Numbered Heading",
))
.unwrap();
let definition = document
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
let instance = document.add_numbering_instance(definition, &[]).unwrap();
document
.link_style_to_numbering("NumberedHeading", instance, 0)
.unwrap();
document.add_paragraph("First").style("NumberedHeading");
document
.add_bookmark(
"numbered_target",
crate::comments::RunRange {
start: crate::comments::RunPosition {
body_index: 0,
run_index: 0,
},
end: crate::comments::RunPosition {
body_index: 0,
run_index: 1,
},
},
)
.unwrap();
let mut reference = CT_P::new();
reference.runs.push(CT_R {
properties: None,
content: vec![RunContent::Field(Field::new(
r"REF numbered_target \w \t",
"stale REF",
))],
extra_xml: Vec::new(),
extra_xml_positions: Vec::new(),
alt_drawings: Vec::new(),
});
document
.document
.body
.content
.push(BodyContent::Paragraph(reference));
let mut package =
OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
let document_part = document.doc_part_name.clone();
let xml = std::str::from_utf8(package.get_part(&document_part).unwrap()).unwrap();
let toc = r#"<w:p><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>TOC \t "Numbered Heading,1"</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r></w:p><w:p><w:r><w:t>stale TOC</w:t></w:r></w:p><w:p><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p>"#;
let xml = xml.replacen("<w:body>", &format!("<w:body>{toc}"), 1);
package.set_part(&document_part, xml.into_bytes());
let mut authored = Cursor::new(Vec::new());
package.write_to(&mut authored).unwrap();
document = Document::from_bytes(authored.get_ref()).unwrap();
assert_eq!(document.rebuild_toc().unwrap().entry_count, 1);
document
.update_fields(&FieldEvaluationContext::default())
.unwrap();
assert!(
document
.evaluate_fields(&FieldEvaluationContext::default())
.unwrap()
.iter()
.any(|field| field.outcome == crate::FieldOutcome::Resolved("1".to_owned()))
);
let bytes = document.to_bytes().unwrap();
let mut reopened = Document::from_bytes(&bytes).unwrap();
let reopened_bytes = reopened.to_bytes().unwrap();
if reopened_bytes != bytes {
let original = OpcPackage::from_reader(Cursor::new(&bytes)).unwrap();
let rebuilt = OpcPackage::from_reader(Cursor::new(&reopened_bytes)).unwrap();
let mut changed = original
.parts
.iter()
.filter_map(|(name, contents)| {
(rebuilt.parts.get(name) != Some(contents)).then_some(name.as_str())
})
.collect::<Vec<_>>();
changed.sort_unstable();
let extract_style = |package: &OpcPackage| {
let styles =
std::str::from_utf8(package.get_part(DEFAULT_STYLES_PART).unwrap()).unwrap();
let start = styles.find(r#"w:styleId="NumberedHeading""#).unwrap();
let start = styles[..start].rfind("<w:style").unwrap();
let end = styles[start..].find("</w:style>").unwrap() + start + "</w:style>".len();
styles[start..end].to_owned()
};
panic!(
"reopened package changed parts: {changed:?}\noriginal: {}\nrebuilt: {}",
extract_style(&original),
extract_style(&rebuilt)
);
}
let style = reopened.style("NumberedHeading").unwrap();
let properties = style.paragraph_properties().unwrap();
assert_eq!(
(properties.num_id, properties.num_ilvl),
(Some(instance), Some(0))
);
assert_eq!(
reopened
.numbering_definition(definition)
.unwrap()
.paragraph_style_links,
vec![Some("NumberedHeading".to_owned())]
);
assert_eq!(reopened.rebuild_toc().unwrap().entry_count, 1);
reopened
.update_fields(&FieldEvaluationContext::default())
.unwrap();
assert_eq!(reopened.to_bytes().unwrap(), bytes);
}
#[test]
fn style_linking_accepts_three_distinct_levels() {
let mut document = Document::new();
for (style_id, name) in [
("NumberedHeading1", "Numbered Heading 1"),
("NumberedHeading2", "Numbered Heading 2"),
("NumberedHeading3", "Numbered Heading 3"),
] {
document
.add_style(StyleBuilder::paragraph(style_id, name))
.unwrap();
}
let definition = document
.add_numbering_definition(&[
ListLevel::decimal().level_text("%1."),
ListLevel::decimal().level_text("%1.%2."),
ListLevel::decimal().level_text("%1.%2.%3."),
])
.unwrap();
let first = document.add_numbering_instance(definition, &[]).unwrap();
let second = document.add_numbering_instance(definition, &[]).unwrap();
for (style_id, level) in [
("NumberedHeading1", 0),
("NumberedHeading2", 1),
("NumberedHeading3", 2),
] {
document
.link_style_to_numbering(style_id, first, level)
.unwrap();
}
assert_ne!(first, second);
assert_eq!(
document
.numbering_definition(definition)
.unwrap()
.paragraph_style_links,
vec![
Some("NumberedHeading1".to_owned()),
Some("NumberedHeading2".to_owned()),
Some("NumberedHeading3".to_owned()),
]
);
}
#[test]
fn fresh_level_attributes_conflicting_with_retained_aliases_are_atomic() {
let mut source = Document::new();
let definition_id = source
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
let xml = format!(
r#"<n:numbering xmlns:n="{WORD_NAMESPACE}"><n:abstractNum n:abstractNumId="{definition_id}"><n:lvl n:ilvl="0" n:tplc="bad" n:tentative="maybe"><n:numFmt n:val="decimal"/></n:lvl></n:abstractNum></n:numbering>"#
);
let imported = replace_numbering_xml(&mut source, xml.into_bytes());
let mut document = Document::from_bytes(&imported).unwrap();
let baseline = document.to_bytes().unwrap();
let mut definition = document.numbering_definition(definition_id).unwrap();
definition.levels[0].properties = ListLevel::decimal().template_code("A1B2C3D4");
assert!(
document
.update_numbering_definition(definition_id, &definition.levels)
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), baseline);
definition.levels[0].properties = ListLevel::decimal().tentative(true);
assert!(
document
.update_numbering_definition(definition_id, &definition.levels)
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), baseline);
let unchanged = document.numbering_definition(definition_id).unwrap();
assert_eq!(unchanged.levels[0].properties.template_code_value(), None);
assert_eq!(unchanged.levels[0].properties.tentative_value(), None);
}
#[test]
fn sparse_numbering_definition_levels_keep_identifiers_through_update() {
let mut source = Document::new();
let definition_id = source
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
let xml = format!(
r#"<w:numbering xmlns:w="{WORD_NAMESPACE}"><w:abstractNum w:abstractNumId="{definition_id}"><w:lvl w:ilvl="2"><w:numFmt w:val="decimal"/><w:lvlRestart w:val="1"/><w:lvlText w:val="%3."/></w:lvl></w:abstractNum></w:numbering>"#
);
let imported = replace_numbering_xml(&mut source, xml.into_bytes());
let mut document = Document::from_bytes(&imported).unwrap();
let canonical = document.to_bytes().unwrap();
let definition = document.numbering_definition(definition_id).unwrap();
assert_eq!(definition.levels[0].level, 2);
assert_eq!(
definition.levels[0].properties.level_text_value(),
Some("%3.")
);
document
.update_numbering_definition(definition_id, &definition.levels)
.unwrap();
assert_eq!(document.to_bytes().unwrap(), canonical);
let replacement = [NumberingDefinitionLevel {
level: 2,
properties: ListLevel::decimal()
.level_text("%3)")
.restart(ListLevelRestart::After(0)),
}];
document
.update_numbering_definition(definition_id, &replacement)
.unwrap();
let saved = document.to_bytes().unwrap();
let package = OpcPackage::from_reader(Cursor::new(&saved)).unwrap();
let numbering =
String::from_utf8(package.get_part(DEFAULT_NUMBERING_PART).unwrap().to_vec()).unwrap();
assert!(numbering.contains(r#"<w:lvl w:ilvl="2""#), "{numbering}");
assert!(numbering.contains(r#"w:val="%3)""#), "{numbering}");
}
#[test]
fn invalid_numbering_mutation_is_atomic() {
let mut document = Document::new();
let definition_id = document
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
let instance_id = document.add_numbering_instance(definition_id, &[]).unwrap();
assert!(document.add_paragraph("item").set_numbering(instance_id, 0));
let baseline = document.to_bytes().unwrap();
for invalid in [
ListLevel::decimal().level_text("%2."),
ListLevel::decimal().restart(ListLevelRestart::After(0)),
ListLevel::decimal().template_code("xyz"),
] {
assert!(document.add_numbering_definition(&[invalid]).is_err());
assert_eq!(document.to_bytes().unwrap(), baseline);
}
assert!(document.add_numbering_instance(u32::MAX, &[]).is_err());
assert_eq!(document.to_bytes().unwrap(), baseline);
assert!(
document
.add_numbering_instance(definition_id, &[NumberingLevelOverride::new(9)])
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), baseline);
assert!(
document
.add_numbering_instance(definition_id, &[NumberingLevelOverride::new(1)])
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), baseline);
assert!(
document
.add_numbering_instance(
definition_id,
&[
NumberingLevelOverride::new(0),
NumberingLevelOverride::new(0),
],
)
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), baseline);
assert!(document.remove_numbering_instance(instance_id).is_err());
assert_eq!(document.to_bytes().unwrap(), baseline);
assert!(document.remove_numbering_definition(definition_id).is_err());
assert_eq!(document.to_bytes().unwrap(), baseline);
document.numbering.as_mut().unwrap().abstract_nums[0].levels[0].p_style =
Some("MissingStyle".to_owned());
let invalid_graph = document.to_bytes().unwrap();
assert!(
document
.add_numbering_definition(&[ListLevel::decimal()])
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), invalid_graph);
document.numbering.as_mut().unwrap().abstract_nums[0].levels[0].p_style = None;
let next_definition = document
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
assert_eq!(next_definition, definition_id + 1);
let next_instance = document
.add_numbering_instance(next_definition, &[])
.unwrap();
assert_eq!(next_instance, instance_id + 1);
}
#[test]
fn imported_numbering_extensions_remain_byte_identical() {
let mut document = Document::new();
let definition_id = document
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
let instance_id = document
.add_numbering_instance(definition_id, &[NumberingLevelOverride::new(0).start(4)])
.unwrap();
document
.styles
.styles
.iter_mut()
.find(|style| style.style_id == "Normal")
.unwrap()
.ppr = Some(CT_PPr {
num_id: Some(instance_id),
num_ilvl: Some(0),
..CT_PPr::default()
});
let numbering = document.numbering.as_mut().unwrap();
numbering
.root_attributes
.push(("xmlns:ext".to_owned(), "urn:producer".to_owned()));
let definition = numbering
.abstract_nums
.iter_mut()
.find(|value| value.abstract_num_id == definition_id)
.unwrap();
definition
.extra_xml
.push((7, b"<ext:definition/>".to_vec()));
definition.levels[0].p_style = Some("Normal".to_owned());
definition.levels[0]
.extra_xml
.push((7, b"<ext:level value=\"kept\"/>".to_vec()));
let instance = numbering
.nums
.iter_mut()
.find(|value| value.num_id == instance_id)
.unwrap();
instance
.extra_xml
.push((1, b"<ext:instance value=\"kept\"/>".to_vec()));
instance.level_overrides[0]
.extra_xml
.push((1, b"<ext:override value=\"kept\"/>".to_vec()));
let before = numbering.to_xml().unwrap();
let definition = document.numbering_definition(definition_id).unwrap();
document
.update_numbering_definition(definition_id, &definition.levels)
.unwrap();
let instance = document.numbering_instance(instance_id).unwrap();
document
.update_numbering_instance(
instance_id,
instance.definition_id,
&instance.level_overrides,
)
.unwrap();
let after = document.numbering.as_ref().unwrap().to_xml().unwrap();
assert_eq!(after, before);
assert_eq!(
document
.numbering_definition(definition_id)
.unwrap()
.paragraph_style_links,
vec![Some("Normal".to_owned())]
);
let mut source = Document::new();
let definition_id = source
.add_numbering_definition(&[ListLevel::decimal()])
.unwrap();
let instance_id = source.add_numbering_instance(definition_id, &[]).unwrap();
source
.styles
.styles
.iter_mut()
.find(|style| style.style_id == "Normal")
.unwrap()
.ppr = Some(CT_PPr {
num_id: Some(instance_id),
num_ilvl: Some(0),
..CT_PPr::default()
});
let xml = format!(
r#"<n:numbering xmlns:n="{WORD_NAMESPACE}" xmlns:ext="urn:producer"><ext:root value="kept"/><n:abstractNum n:abstractNumId="{definition_id}" ext:definition="kept"><ext:definition-child/><n:multiLevelType n:val="hybridMultilevel" ext:leaf="type"><ext:type-child/></n:multiLevelType><n:lvl n:ilvl="0"><n:start n:val="1" ext:leaf="start"><ext:start-child/></n:start><n:numFmt n:val="producerFormat" ext:leaf="format"><ext:format-child/></n:numFmt><n:lvlRestart n:val="0" ext:restart="kept"></n:lvlRestart><n:pStyle n:val="Normal"></n:pStyle><n:isLgl n:val="false"/><n:suff n:val="space" ext:leaf="suffix"><ext:suffix-child/></n:suff><ext:level-child/><n:lvlText n:val="%1." ext:leaf="text"><ext:text-child/></n:lvlText><n:lvlJc n:val="left" ext:leaf="alignment"><ext:alignment-child/></n:lvlJc></n:lvl></n:abstractNum><n:num n:numId="{instance_id}" ext:instance="kept"><n:abstractNumId n:val="{definition_id}" ext:reference="kept"><ext:reference-child/></n:abstractNumId><ext:instance-child/><n:lvlOverride n:ilvl="0" ext:override="kept"><n:startOverride n:val="4" ext:start="kept"></n:startOverride><ext:override-child/><n:lvl n:ilvl="0"><n:numFmt n:val="decimal"/><n:pStyle n:val="Normal"/><n:lvlText n:val="%1)"/><ext:replacement-child/></n:lvl></n:lvlOverride></n:num></n:numbering>"#
);
let imported = replace_numbering_xml(&mut source, xml.into_bytes());
let mut imported = Document::from_bytes(&imported).unwrap();
let canonical_bytes = imported.to_bytes().unwrap();
let canonical = OpcPackage::from_reader(Cursor::new(&canonical_bytes))
.unwrap()
.get_part(DEFAULT_NUMBERING_PART)
.unwrap()
.to_vec();
let canonical_xml = String::from_utf8(canonical.clone()).unwrap();
assert!(canonical_xml.contains("<ext:type-child/>"));
assert!(canonical_xml.contains("<n:lvlOverride"));
assert!(canonical_xml.contains("<n:startOverride"));
assert!(canonical_xml.contains("<ext:reference-child/>"));
for payload in [
"<ext:start-child/>",
"<ext:format-child/>",
"<ext:suffix-child/>",
"<ext:text-child/>",
"<ext:alignment-child/>",
] {
assert!(canonical_xml.contains(payload), "{canonical_xml}");
}
let abstract_reference = canonical_xml.find("<n:abstractNumId ").unwrap();
let override_position = canonical_xml.find("<n:lvlOverride ").unwrap();
let start_position = canonical_xml.find("<n:startOverride ").unwrap();
let replacement_position = canonical_xml[override_position..]
.find("<n:lvl n:ilvl=\"0\"")
.unwrap()
+ override_position;
assert!(abstract_reference < override_position);
assert!(start_position < replacement_position);
let definition = imported.numbering_definition(definition_id).unwrap();
assert_eq!(
definition.paragraph_style_links,
vec![Some("Normal".to_owned())]
);
assert!(definition.has_unmodeled_properties);
imported
.update_numbering_definition(definition_id, &definition.levels)
.unwrap();
let instance = imported.numbering_instance(instance_id).unwrap();
assert!(instance.has_unmodeled_properties);
imported
.update_numbering_instance(
instance_id,
instance.definition_id,
&instance.level_overrides,
)
.unwrap();
let after = OpcPackage::from_reader(Cursor::new(imported.to_bytes().unwrap()))
.unwrap()
.get_part(DEFAULT_NUMBERING_PART)
.unwrap()
.to_vec();
assert_eq!(after, canonical);
}
#[test]
fn rejected_list_level_update_does_not_materialize_numbering() {
let mut doc = Document::new();
assert!(doc.numbering.is_none());
assert!(!doc.set_list_level(999, 1, ListLevel::decimal()));
assert!(
doc.numbering.is_none(),
"a rejected setter must not add an empty numbering part"
);
}
#[test]
fn custom_list_and_paragraph_numbering_enforce_the_nine_level_contract() {
let mut doc = Document::new();
let levels = vec![ListLevel::decimal(); 10];
let num_id = doc.add_list_definition(&levels);
assert_eq!(
doc.numbering.as_ref().unwrap().abstract_nums[0]
.levels
.len(),
9
);
let mut paragraph = doc.add_paragraph("item");
assert!(!paragraph.set_numbering(num_id, 9));
assert_eq!(
paragraph.inner.properties.as_ref().and_then(|p| p.num_id),
None
);
assert!(paragraph.set_numbering(num_id, 8));
assert_eq!(
paragraph.inner.properties.as_ref().unwrap().num_ilvl,
Some(8)
);
}
#[test]
fn reader_exposes_complete_numbering_level_facts() {
let mut doc = Document::new();
let num_id = doc.add_list_definition(&[ListLevel::decimal()]);
let none_id = doc.add_list_definition(&[ListLevel::decimal()]);
let level = &mut doc.numbering.as_mut().unwrap().abstract_nums[0].levels[0];
level.num_fmt = Some(ST_NumberFormat::Other("producerFormat".to_owned()));
level.start = Some(4);
level.suffix = Some(ST_LvlSuffix::Space);
level.lvl_jc = Some(ST_Jc::Center);
level.p_style = Some("Normal".to_owned());
let level = doc.numbering_level(num_id, 0).expect("numbering level");
assert_eq!(level.format, NumberingFormat::Other("producerFormat"));
assert_eq!(level.format_name, "producerFormat");
assert_eq!(level.start, 4);
assert_eq!(level.suffix, ListLevelSuffix::Space);
assert_eq!(level.alignment, Some(Alignment::Center));
assert_eq!(level.paragraph_style, Some("Normal"));
assert!(!level.has_unmodeled_properties);
assert!(!level.has_paragraph_presentation);
assert!(!level.has_marker_presentation);
let level = &mut doc.numbering.as_mut().unwrap().abstract_nums[0].levels[0];
level.ppr = Some(CT_PPr {
ind_left: Some(Twips(360)),
..Default::default()
});
level.rpr = Some(CT_RPr {
bold: Some(true),
..Default::default()
});
level.extra_xml.push((0, b"<w:unknown/>".to_vec()));
let level = doc.numbering_level(num_id, 0).expect("numbering level");
assert!(level.has_unmodeled_properties);
assert!(level.has_paragraph_presentation);
assert!(level.has_marker_presentation);
doc.numbering.as_mut().unwrap().abstract_nums[1].levels[0].num_fmt =
Some(ST_NumberFormat::None);
assert_eq!(
doc.numbering_level(none_id, 0)
.expect("no-numbering level")
.format,
NumberingFormat::None
);
}
#[test]
fn numbering_level_namespace_declarations_do_not_report_unmodeled_properties() {
let mut mismatches = Vec::new();
for owner in ["abstractNum", "lvl", "num"] {
for (name, value) in [
("xmlns:w", WORD_NAMESPACE),
("xmlns:q", WORD_NAMESPACE),
("xmlns", "urn:producer:default"),
("xmlns:producer", "urn:producer"),
] {
let mut source = Document::new();
let num_id = source.add_list_definition(&[ListLevel::bullet()]);
let definition_id = source.numbering.as_ref().unwrap().nums[0].abstract_num_id;
let declaration = format!(r#" {name}="{value}""#);
let xml = format!(
r#"<q:numbering xmlns:q="{WORD_NAMESPACE}"><q:abstractNum q:abstractNumId="{definition_id}"{}><q:lvl q:ilvl="0"{}><q:numFmt q:val="bullet"/><q:lvlText q:val="•"/></q:lvl></q:abstractNum><q:num q:numId="{num_id}"{}><q:abstractNumId q:val="{definition_id}"/></q:num></q:numbering>"#,
if owner == "abstractNum" {
&declaration
} else {
""
},
if owner == "lvl" { &declaration } else { "" },
if owner == "num" { &declaration } else { "" },
);
let bytes = replace_numbering_xml(&mut source, xml.into_bytes());
let mut imported = Document::from_bytes(&bytes).unwrap();
let saved = imported.to_bytes().unwrap();
let reopened = Document::from_bytes(&saved).unwrap();
for document in [&imported, &reopened] {
let level = document.numbering_level(num_id, 0).unwrap();
assert_eq!(level.format, NumberingFormat::Bullet, "{owner}: {name}");
assert_eq!(level.start, 1, "{owner}: {name}");
assert_eq!(level.suffix, ListLevelSuffix::Tab, "{owner}: {name}");
if level.has_unmodeled_properties {
mismatches.push(format!("{owner}: {name}"));
}
let numbering = document.numbering.as_ref().unwrap();
let attributes = match owner {
"abstractNum" => &numbering.abstract_nums[0].extra_attributes,
"lvl" => &numbering.abstract_nums[0].levels[0].extra_attributes,
"num" => &numbering.nums[0].extra_attributes,
_ => unreachable!(),
};
assert!(attributes.contains(&(name.to_owned(), value.to_owned())));
}
}
}
assert!(
mismatches.is_empty(),
"namespace-only reader cases: {mismatches:?}"
);
}
#[test]
fn numbering_level_unknown_attributes_and_xml_remain_unmodeled_after_reopen() {
for owner in ["abstractNum", "lvl", "num"] {
for attribute in [
r#" producer:flag="kept""#,
r#" producer:xmlns="kept""#,
r#" xmlnsLike="kept""#,
] {
let mut source = Document::new();
let num_id = source.add_list_definition(&[ListLevel::bullet()]);
let definition_id = source.numbering.as_ref().unwrap().nums[0].abstract_num_id;
let xml = format!(
r#"<w:numbering xmlns:w="{WORD_NAMESPACE}"><w:abstractNum w:abstractNumId="{definition_id}" xmlns:producer="urn:producer"{}><w:lvl w:ilvl="0" xmlns:producer="urn:producer"{}><w:numFmt w:val="bullet"/><producer:fact producer:flag="kept"/></w:lvl></w:abstractNum><w:num w:numId="{num_id}" xmlns:producer="urn:producer"{}><w:abstractNumId w:val="{definition_id}"/></w:num></w:numbering>"#,
if owner == "abstractNum" {
attribute
} else {
""
},
if owner == "lvl" { attribute } else { "" },
if owner == "num" { attribute } else { "" },
);
let bytes = replace_numbering_xml(&mut source, xml.into_bytes());
let mut imported = Document::from_bytes(&bytes).unwrap();
// Check the attribute independently of the retained child.
let raw_children = std::mem::take(
&mut imported.numbering.as_mut().unwrap().abstract_nums[0].levels[0].extra_xml,
);
assert!(
imported
.numbering_level(num_id, 0)
.unwrap()
.has_unmodeled_properties
);
imported.numbering.as_mut().unwrap().abstract_nums[0].levels[0].extra_xml =
raw_children.clone();
let saved = imported.to_bytes().unwrap();
let reopened = Document::from_bytes(&saved).unwrap();
assert!(
reopened
.numbering_level(num_id, 0)
.unwrap()
.has_unmodeled_properties
);
let before = imported.numbering.as_ref().unwrap();
let after = reopened.numbering.as_ref().unwrap();
assert_eq!(
before.abstract_nums[0].extra_attributes,
after.abstract_nums[0].extra_attributes
);
assert_eq!(
before.abstract_nums[0].levels[0].extra_attributes,
after.abstract_nums[0].levels[0].extra_attributes
);
assert_eq!(
before.nums[0].extra_attributes,
after.nums[0].extra_attributes
);
assert_eq!(raw_children, after.abstract_nums[0].levels[0].extra_xml);
}
}
let mut source = Document::new();
let num_id = source.add_list_definition(&[ListLevel::bullet()]);
let definition_id = source.numbering.as_ref().unwrap().nums[0].abstract_num_id;
let xml = format!(
r#"<w:numbering xmlns:w="{WORD_NAMESPACE}" xmlns:q="{WORD_NAMESPACE}"><w:abstractNum w:abstractNumId="{definition_id}"><w:lvl w:ilvl="0" xmlns:q="urn:foreign" q:ilvl="7"><w:numFmt w:val="bullet"/></w:lvl></w:abstractNum><w:num w:numId="{num_id}"><w:abstractNumId w:val="{definition_id}"/></w:num></w:numbering>"#
);
let bytes = replace_numbering_xml(&mut source, xml.into_bytes());
let mut imported = Document::from_bytes(&bytes).unwrap();
assert!(
imported
.numbering_level(num_id, 0)
.unwrap()
.has_unmodeled_properties
);
let saved = imported.to_bytes().unwrap();
let reopened = Document::from_bytes(&saved).unwrap();
assert!(
reopened
.numbering_level(num_id, 0)
.unwrap()
.has_unmodeled_properties
);
assert_eq!(
imported.numbering.as_ref().unwrap().abstract_nums[0].levels[0].extra_attributes,
reopened.numbering.as_ref().unwrap().abstract_nums[0].levels[0].extra_attributes,
);
}
#[test]
fn numbering_level_raw_children_remain_unmodeled_on_each_owner() {
for owner in ["abstractNum", "lvl", "num"] {
let mut source = Document::new();
let num_id = source.add_list_definition(&[ListLevel::bullet()]);
let definition_id = source.numbering.as_ref().unwrap().nums[0].abstract_num_id;
let payload = r#"<producer:fact producer:flag="kept"><producer:inner>opaque payload</producer:inner></producer:fact>"#;
let xml = format!(
r#"<w:numbering xmlns:w="{WORD_NAMESPACE}"><w:abstractNum w:abstractNumId="{definition_id}" xmlns:producer="urn:producer"><w:lvl w:ilvl="0" xmlns:producer="urn:producer"><w:numFmt w:val="bullet"/>{}</w:lvl>{}</w:abstractNum><w:num w:numId="{num_id}" xmlns:producer="urn:producer"><w:abstractNumId w:val="{definition_id}"/>{}</w:num></w:numbering>"#,
if owner == "lvl" { payload } else { "" },
if owner == "abstractNum" { payload } else { "" },
if owner == "num" { payload } else { "" },
);
let bytes = replace_numbering_xml(&mut source, xml.into_bytes());
let mut imported = Document::from_bytes(&bytes).unwrap();
let saved = imported.to_bytes().unwrap();
let reopened = Document::from_bytes(&saved).unwrap();
let before = imported.numbering.as_ref().unwrap();
let after = reopened.numbering.as_ref().unwrap();
let before_children = match owner {
"abstractNum" => &before.abstract_nums[0].extra_xml,
"lvl" => &before.abstract_nums[0].levels[0].extra_xml,
"num" => &before.nums[0].extra_xml,
_ => unreachable!(),
};
let after_children = match owner {
"abstractNum" => &after.abstract_nums[0].extra_xml,
"lvl" => &after.abstract_nums[0].levels[0].extra_xml,
"num" => &after.nums[0].extra_xml,
_ => unreachable!(),
};
assert_eq!(before_children, after_children, "{owner}");
assert_eq!(before_children.len(), 1, "{owner}");
assert_eq!(before_children[0].1, payload.as_bytes(), "{owner}");
for document in [&imported, &reopened] {
let level = document.numbering_level(num_id, 0).unwrap();
assert_eq!(level.format, NumberingFormat::Bullet, "{owner}");
assert!(level.has_unmodeled_properties, "{owner}");
}
}
}
#[test]
fn numbering_level_unmodeled_fact_contract_is_limited_to_extras() {
let mut doc = Document::new();
let modeled_and_raw_overlay_id = doc.add_list_definition(&[ListLevel::decimal()]);
let retained_extra_id = doc.add_list_definition(&[ListLevel::decimal()]);
let raw_leaf_metadata_id = doc.add_list_definition(&[ListLevel::decimal()]);
let definition = &mut doc.numbering.as_mut().unwrap().abstract_nums[0];
definition.nsid = Some("12345678".to_owned());
definition.tmpl = Some("87654321".to_owned());
definition.multi_level_type = Some("hybridMultilevel".to_owned());
let level = &mut doc.numbering.as_mut().unwrap().abstract_nums[0].levels[0];
level.ppr_raw = Some((
CT_PPr::default(),
b"<w:pPr><producer:property/></w:pPr>".to_vec(),
vec!["producer".to_owned()],
));
level.rpr_raw = Some((
CT_RPr::default(),
b"<w:rPr><producer:property/></w:rPr>".to_vec(),
vec!["producer".to_owned()],
));
doc.numbering.as_mut().unwrap().abstract_nums[1].levels[0]
.extra_xml
.push((0, b"<w:lvlRestart w:val=\"0\"/>".to_vec()));
let definition = &mut doc.numbering.as_mut().unwrap().abstract_nums[2];
definition.levels[0].start = Some(1);
definition.levels[0].start_raw = Some((
Some(1),
b"<w:start w:val=\"1\"><producer:fact/></w:start>".to_vec(),
vec!["w".to_owned(), "producer".to_owned()],
));
assert!(
!doc.numbering_level(modeled_and_raw_overlay_id, 0)
.expect("modeled metadata and raw property overlays")
.has_unmodeled_properties
);
assert!(
doc.numbering_level(retained_extra_id, 0)
.expect("retained extra level")
.has_unmodeled_properties
);
assert!(
!doc.numbering_level(raw_leaf_metadata_id, 0)
.expect("raw leaf metadata level")
.has_unmodeled_properties
);
assert!(
doc.numbering_definition(
doc.numbering_instance(raw_leaf_metadata_id)
.expect("raw leaf metadata instance")
.definition_id,
)
.expect("raw leaf metadata definition")
.has_unmodeled_properties
);
}
#[test]
fn fragment_numbering_equivalence_compares_owned_leaf_projection() {
let xml = format!(
r#"<w:numbering xmlns:w="{WORD_NAMESPACE}" xmlns:f="urn:fragment"><w:abstractNum w:abstractNumId="7"><w:lvl w:ilvl="0"><w:start w:val="1" f:keep="A"><f:payload value="kept exact"/></w:start><w:numFmt w:val="decimal"/><w:lvlText w:val="%1."/></w:lvl></w:abstractNum><w:num w:numId="9"><w:abstractNumId w:val="7" f:keep="A"><f:payload value="kept exact"/></w:abstractNumId></w:num></w:numbering>"#
);
let parsed = CT_Numbering::from_xml(xml.as_bytes()).unwrap();
let original = parsed.clone();
let mut remapped = parsed.clone();
remapped.abstract_nums[0].abstract_num_id = 12;
remapped.nums[0].num_id = 14;
remapped.nums[0].abstract_num_id = 12;
// Reparsing refreshes snapshots, but keeps the same output XML facts.
let remapped = CT_Numbering::from_xml(&remapped.to_xml().unwrap()).unwrap();
assert!(equivalent_abstract_numbering(
&parsed.abstract_nums[0],
&remapped.abstract_nums[0]
));
assert!(equivalent_numbering_instance(
&parsed.nums[0],
&remapped.nums[0],
7
));
let mut changed = remapped.clone();
changed.abstract_nums[0].levels[0].start = Some(2);
assert!(!equivalent_abstract_numbering(
&parsed.abstract_nums[0],
&changed.abstract_nums[0]
));
let mut changed = remapped.clone();
let raw = &mut changed.nums[0].abstract_num_id_raw.as_mut().unwrap().1;
*raw = String::from_utf8(raw.clone())
.unwrap()
.replace("f:keep=\"A\"", "f:keep=\"B\"")
.into_bytes();
assert!(!equivalent_numbering_instance(
&parsed.nums[0],
&changed.nums[0],
7
));
let mut changed = remapped.clone();
changed.nums[0].abstract_num_id_raw = None;
assert!(!equivalent_numbering_instance(
&parsed.nums[0],
&changed.nums[0],
7
));
let mut changed = remapped.clone();
changed.abstract_nums[0].levels[0].start_raw = None;
assert!(!equivalent_abstract_numbering(
&parsed.abstract_nums[0],
&changed.abstract_nums[0]
));
assert_eq!(
parsed, original,
"equivalence checks cannot mutate either graph"
);
}
#[test]
fn extreme_numbering_level_reader_fact_does_not_overflow() {
let mut doc = Document::new();
let num_id = doc.add_list_definition(&[ListLevel::decimal()]);
let level = &mut doc.numbering.as_mut().unwrap().abstract_nums[0].levels[0];
level.ilvl = u32::MAX;
level.ppr = Some(CT_PPr::default());
let level = doc
.numbering_level(num_id, u32::MAX)
.expect("extreme level remains readable");
assert!(level.has_paragraph_presentation);
}
#[test]
fn absent_or_unstyled_paragraph_properties_use_default_style_numbering() {
let mut doc = Document::new();
let num_id = doc.add_list_definition(&[ListLevel::decimal()]);
let level = &mut doc.numbering.as_mut().unwrap().abstract_nums[0].levels[0];
level.p_style = Some("Normal".to_owned());
level.ppr = Some(CT_PPr {
keep_next: Some(true),
..Default::default()
});
let default_style = doc
.styles
.styles
.iter_mut()
.find(|style| style.style_id == "Normal")
.expect("default paragraph style");
default_style.ppr = Some(CT_PPr {
num_id: Some(num_id),
..Default::default()
});
doc.add_paragraph("absent properties");
doc.add_paragraph("unstyled properties");
let BodyContent::Paragraph(paragraph) = &mut doc.document.body.content[1] else {
panic!("expected paragraph");
};
paragraph.properties = Some(CT_PPr {
keep_lines: Some(true),
..Default::default()
});
for index in 0..2 {
let paragraph = doc.paragraph(index).expect("paragraph");
let effective = doc.effective_paragraph_properties(¶graph);
assert_eq!(effective.num_id, Some(num_id));
assert_eq!(effective.num_ilvl, Some(0));
assert_eq!(effective.keep_next, Some(true));
}
}
#[test]
fn reader_reports_list_paragraph_presentation_separately_from_unknown_xml() {
let mut doc = Document::new();
let num_id = doc.add_list_definition(&[ListLevel::decimal()]);
let level = &mut doc.numbering.as_mut().unwrap().abstract_nums[0].levels[0];
level.ppr = Some(CT_PPr {
ind_left: Some(Twips(360)),
..CT_PPr::default()
});
let level = doc.numbering_level(num_id, 0).expect("numbering level");
assert!(level.has_paragraph_presentation);
assert!(!level.has_unmodeled_properties);
}
#[test]
fn reader_exposes_background_and_section_completeness_facts() {
let mut doc = Document::new();
assert!(!doc.has_document_background());
assert!(doc.has_section_layout_formatting());
assert!(!doc.has_unmodeled_section_properties());
doc.document.background_xml = Some(b"<w:background/>".to_vec());
doc.document
.body
.sect_pr
.as_mut()
.unwrap()
.extra_xml
.push(b"<w:printerSettings r:id=\"rId1\"/>".to_vec());
assert!(doc.has_document_background());
assert!(doc.has_unmodeled_section_properties());
}
#[test]
fn reader_resolves_concrete_paragraph_and_run_properties() {
let mut doc = Document::new();
let num_id = doc.add_list_definition(&[ListLevel::decimal()]);
let level = &mut doc.numbering.as_mut().unwrap().abstract_nums[0].levels[0];
level.ppr = Some(CT_PPr {
keep_next: Some(true),
..Default::default()
});
level.rpr = Some(CT_RPr {
bold: Some(true),
..Default::default()
});
doc.add_style(
StyleBuilder::paragraph("ListBase", "List Base").run_properties(CT_RPr {
italic: Some(true),
..Default::default()
}),
)
.unwrap();
doc.link_style_to_numbering("ListBase", num_id, 0).unwrap();
doc.add_style(StyleBuilder::paragraph("ListChild", "List Child").based_on("ListBase"))
.unwrap();
doc.add_paragraph("text").style("ListChild");
let BodyContent::Paragraph(paragraph) = &mut doc.document.body.content[0] else {
panic!("expected paragraph");
};
let properties = paragraph.properties.as_mut().expect("paragraph style");
properties.ind_left = Some(Twips(720));
properties.rpr = Some(CT_RPr {
strike: Some(true),
..Default::default()
});
paragraph.runs[0].properties = Some(CT_RPr {
vanish: Some(true),
..Default::default()
});
let paragraph = doc.paragraph(0).expect("paragraph");
let run = paragraph.runs().next().expect("run");
let paragraph_properties = doc.effective_paragraph_properties(¶graph);
let run_properties = doc.effective_run_properties(¶graph, &run);
assert_eq!(paragraph_properties.num_id, Some(num_id));
assert_eq!(paragraph_properties.num_ilvl, Some(0));
assert_eq!(paragraph_properties.keep_next, Some(true));
assert_eq!(paragraph_properties.ind_left, Some(Twips(720)));
assert_eq!(run_properties.italic, Some(true));
assert_eq!(run_properties.bold, None);
assert_eq!(run_properties.strike, Some(true));
assert_eq!(run_properties.vanish, Some(true));
assert!(
doc.numbering_level(num_id, 0)
.expect("numbering level")
.has_marker_presentation
);
}
#[test]
fn effective_paragraph_properties_use_the_final_numbering_identity() {
let mut doc = Document::new();
let inherited_num_id = doc.add_list_definition(&[ListLevel::decimal()]);
let direct_num_id = doc.add_list_definition(&[ListLevel::decimal()]);
doc.numbering.as_mut().unwrap().abstract_nums[0].levels[0].ppr = Some(CT_PPr {
keep_next: Some(true),
..Default::default()
});
doc.numbering.as_mut().unwrap().abstract_nums[1].levels[0].ppr = Some(CT_PPr {
keep_lines: Some(true),
..Default::default()
});
doc.add_style(StyleBuilder::paragraph("InheritedList", "Inherited List"))
.unwrap();
doc.link_style_to_numbering("InheritedList", inherited_num_id, 0)
.unwrap();
doc.add_paragraph("direct").numbering(direct_num_id, 0);
doc.add_paragraph("override")
.style("InheritedList")
.numbering(direct_num_id, 0);
doc.add_paragraph("disabled")
.style("InheritedList")
.numbering(0, 0);
let direct =
doc.effective_paragraph_properties(&doc.paragraph(0).expect("direct paragraph"));
assert_eq!(direct.num_id, Some(direct_num_id));
assert_eq!(direct.keep_lines, Some(true));
let overridden =
doc.effective_paragraph_properties(&doc.paragraph(1).expect("overridden paragraph"));
assert_eq!(overridden.num_id, Some(direct_num_id));
assert_eq!(overridden.keep_next, None);
assert_eq!(overridden.keep_lines, Some(true));
let disabled =
doc.effective_paragraph_properties(&doc.paragraph(2).expect("disabled paragraph"));
assert_eq!(disabled.num_id, Some(0));
assert_eq!(disabled.keep_next, None);
assert_eq!(disabled.keep_lines, None);
}
#[test]
fn picture_round_trips() {
// 1x1 red PNG
let png: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08,
0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x9E, 0xDD, 0x22,
0x71, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
let mut doc = Document::new();
doc.add_paragraph("before");
doc.add_picture(png, "dot.png", Length::inches(1.0), Length::inches(1.0));
let bytes = doc.to_bytes().unwrap();
let doc2 = Document::from_bytes(&bytes).unwrap();
let paras = doc2.paragraphs();
let mut found = None;
for p in ¶s {
for r in p.runs() {
if let Some((rel, _alt)) = r.inline_image() {
found = Some(rel.to_string());
}
}
}
let rel = found.expect("no inline image found on read");
let data = doc2.image_data(&rel).expect("image bytes missing");
assert_eq!(data, png);
}
#[test]
fn layout_resolves_relationship_images_to_shared_media() {
let png: &[u8] = &[
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48,
0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00,
0x00, 0x90, 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08,
0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x9E, 0xDD, 0x22,
0x71, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
];
let mut document = Document::new();
document.add_picture(png, "first.png", Length::inches(1.0), Length::inches(1.0));
document.add_picture(png, "second.png", Length::inches(1.0), Length::inches(1.0));
let page = document
.layout_page(0)
.expect("layout should succeed")
.expect("document should have a first page");
let images = compatibility_page_elements(&page.elements)
.into_iter()
.filter_map(|element| match element {
oxml_layout::PositionedElement::Image {
data,
content_type,
media_id,
..
} => Some((data, content_type, media_id)),
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(images.len(), 2);
assert!(images.iter().all(|(data, _, _)| data.as_slice() == png));
assert!(
images
.iter()
.all(|(_, content_type, _)| *content_type == "image/png")
);
assert_eq!(*images[0].2, oxml_layout::MediaId::from_bytes(png));
assert_eq!(images[0].2, images[1].2);
}
}
#[cfg(test)]
mod hyperlink_span_tests {
use super::*;
use rdocx_oxml::text::HyperlinkSpan;
/// `HyperlinkSpan`'s bounds are public, so a caller building the OXML model
/// by hand can hand us a range past the end of `runs`. `links()` used to
/// slice with it and panic.
#[test]
fn links_clamps_out_of_range_spans() {
let mut doc = Document::new();
{
let mut para = doc.add_paragraph("");
para.add_run("one");
para.add_run("two");
}
let BodyContent::Paragraph(p) = &mut doc.document.body.content[0] else {
unreachable!("just added a paragraph")
};
p.hyperlinks.push(HyperlinkSpan {
rel_id: None,
anchor: Some("bookmark".to_string()),
tooltip: None,
doc_location: None,
run_start: 1,
run_end: 99,
extra_attributes: Vec::new(),
extra_xml: Vec::new(),
preserved_raw_before: None,
});
p.hyperlinks.push(HyperlinkSpan {
rel_id: None,
anchor: Some("inverted".to_string()),
tooltip: None,
doc_location: None,
run_start: 5,
run_end: 1,
extra_attributes: Vec::new(),
extra_xml: Vec::new(),
preserved_raw_before: None,
});
let links = doc.links();
assert_eq!(links.len(), 2);
assert_eq!(links[0].text, "two");
assert_eq!(links[1].text, "");
}
}
#[cfg(test)]
mod watermark_tests {
use super::*;
use std::io::Cursor;
pub(super) const PNG: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8,
0xcf, 0xc0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00,
0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
];
fn text_vml_header(text: &str, color: &str) -> Vec<u8> {
format!(
r#"<w:hdr xmlns:w="{}" xmlns:v="urn:schemas-microsoft-com:vml"><w:p><w:r><w:pict><v:shape style="width:468pt;height:117pt;rotation:315" fillcolor="{color}"><v:fill opacity=".5"/><v:textpath string="{text}" style="font-family:"Calibri""/></v:shape></w:pict></w:r></w:p></w:hdr>"#,
rdocx_oxml::namespace::W_NS
)
.into_bytes()
}
fn page_text(layout: &oxml_layout::LayoutResult, index: usize) -> String {
let mut text = String::new();
oxml_layout::walk(&layout.pages[index].elements, &mut |element, _| {
if let oxml_layout::PositionedElement::Text(run) = element {
text.push_str(&run.text);
}
});
text
}
fn compatibility_page_elements(
elements: &[oxml_layout::PositionedElement],
) -> Vec<&oxml_layout::PositionedElement> {
fn collect<'a>(
elements: &'a [oxml_layout::PositionedElement],
output: &mut Vec<&'a oxml_layout::PositionedElement>,
) {
for element in elements {
match element {
oxml_layout::PositionedElement::MarkedContent { children, .. } => {
collect(children, output);
}
other => output.push(other),
}
}
}
let mut output = Vec::new();
collect(elements, &mut output);
output
}
fn enable_even_headers(document: &mut Document) {
set_even_headers_value(document, None);
}
fn set_even_headers_value(document: &mut Document, value: Option<&str>) {
let part_name = "/word/settings.xml";
let setting = value.map_or_else(
|| "<w:evenAndOddHeaders/>".to_owned(),
|value| format!(r#"<w:evenAndOddHeaders w:val="{value}"/>"#),
);
let xml = format!(
r#"<w:settings xmlns:w="{}">{setting}</w:settings>"#,
rdocx_oxml::namespace::W_NS
)
.into_bytes();
document.settings = Some(CT_Settings::from_xml(&xml).unwrap());
document.settings_part_name = Some(part_name.to_owned());
document.package.set_part(part_name, xml);
document
.ensure_part_relationship_checked(
part_name,
rel_types::SETTINGS,
"application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml",
)
.unwrap();
}
fn assert_watermark_mutation_preserves_reusable_engine(
mut document: Document,
mutate: impl FnOnce(&mut Document),
) {
let layout_state = |document: &Document| {
(
document
.layout_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some(),
document
.normal_layout_engine
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some(),
document
.deterministic_layout_cache
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_some(),
)
};
document.add_paragraph("unchanged cacheable body paragraph");
LAYOUT_INVOCATIONS.set(0);
let accepted_before = document.layout().expect("populate normal layout cache");
document
.to_pdf_deterministic()
.expect("populate deterministic layout cache");
assert_eq!(LAYOUT_INVOCATIONS.get(), 2);
assert_eq!(layout_state(&document), (true, true, true));
mutate(&mut document);
assert_eq!(
layout_state(&document),
(false, true, false),
"completed results are invalidated while reusable work survives"
);
let accepted_after = document
.layout()
.expect("relayout after watermark mutation");
document
.to_pdf_deterministic()
.expect("deterministic relayout after watermark mutation");
assert!(!Arc::ptr_eq(&accepted_before, &accepted_after));
assert_eq!(LAYOUT_INVOCATIONS.get(), 4);
}
#[test]
fn watermark_mutations_preserve_reusable_engine_and_invalidate_completed_layouts() {
assert_watermark_mutation_preserves_reusable_engine(Document::new(), |document| {
document.set_text_watermark("DRAFT").unwrap();
});
assert_watermark_mutation_preserves_reusable_engine(Document::new(), |document| {
document
.set_image_watermark(PNG, "watermark.png", Length::pt(72.0), Length::pt(36.0))
.unwrap();
});
}
#[test]
fn text_and_image_watermarks_round_trip_through_header_relationships() {
let mut text = Document::new();
text.set_header("default header");
text.set_first_page_header("first header");
text.set_raw_header_with_images(
format!(
r#"<w:hdr xmlns:w="{}"><w:p><w:r><w:t>even header</w:t></w:r></w:p></w:hdr>"#,
rdocx_oxml::namespace::W_NS
)
.into_bytes(),
&[],
HdrFtrType::Even,
);
enable_even_headers(&mut text);
text.set_text_watermark("DRAFT").unwrap();
text.set_text_watermark("FINAL").unwrap();
let reopened = Document::from_bytes(&text.to_bytes().unwrap()).unwrap();
let input = reopened.build_layout_input().unwrap();
assert_eq!(input.headers.len(), 3);
assert!(input.headers.values().all(|header| {
matches!(
header.watermarks(),
[rdocx_oxml::header_footer::VmlWatermark::Text { text, .. }]
if text == "FINAL"
)
}));
let mut image = Document::new();
image
.set_image_watermark(PNG, "watermark.png", Length::pt(72.0), Length::pt(36.0))
.unwrap();
let reopened = Document::from_bytes(&image.to_bytes().unwrap()).unwrap();
assert!(!reopened.build_layout_input().unwrap().headers.is_empty());
let mut invalid = Document::new();
invalid.set_header("unchanged");
let before = invalid.to_bytes().unwrap();
assert!(
invalid
.set_image_watermark(PNG, "bad.png", Length::emu(0), Length::pt(36.0))
.is_err()
);
assert_eq!(invalid.to_bytes().unwrap(), before);
}
#[test]
fn watermark_replacement_removes_stale_authored_images() {
let mut document = Document::new();
document.set_header("header");
document
.set_image_watermark(PNG, "first.png", Length::pt(72.0), Length::pt(36.0))
.unwrap();
let first_parts = document
.package
.parts
.iter()
.filter(|(_, bytes)| bytes.as_slice() == PNG)
.map(|(name, _)| name.clone())
.collect::<Vec<_>>();
assert_eq!(first_parts.len(), 1);
let mut replacement = PNG.to_vec();
*replacement.last_mut().unwrap() ^= 1;
document
.set_image_watermark(
&replacement,
"second.png",
Length::pt(72.0),
Length::pt(36.0),
)
.unwrap();
assert!(
first_parts
.iter()
.all(|part| document.package.get_part(part).is_none())
);
assert_eq!(
document
.package
.get_part_rels("/word/header1.xml")
.unwrap()
.items
.iter()
.filter(|relationship| relationship.rel_type == rel_types::IMAGE)
.count(),
1
);
document.set_text_watermark("FINAL").unwrap();
assert!(
document
.package
.parts
.iter()
.all(|(_, bytes)| bytes.as_slice() != replacement)
);
assert!(
document
.package
.get_part_rels("/word/header1.xml")
.is_none_or(|relationships| relationships
.items
.iter()
.all(|relationship| relationship.rel_type != rel_types::IMAGE))
);
}
#[test]
fn imported_header_watermark_order_is_stable_and_preserves_producer_relationships() {
fn reopened_source() -> Document {
let mut source = Document::new();
source.set_header("producer header");
source
.package
.set_part("/word/producer.bin", b"keep".to_vec());
source
.package
.content_types
.add_override("/word/producer.bin", "application/octet-stream");
source
.package
.get_or_create_part_rels("/word/header1.xml")
.add_with_id("producerRel", "urn:producer", "producer.bin");
Document::from_bytes(&source.to_bytes().unwrap()).unwrap()
}
fn build(watermark_first: bool) -> Vec<u8> {
let mut document = reopened_source();
if watermark_first {
document
.set_image_watermark(PNG, "watermark.png", Length::pt(72.0), Length::pt(36.0))
.unwrap();
document.add_picture(PNG, "body.png", Length::pt(36.0), Length::pt(18.0));
} else {
document.add_picture(PNG, "body.png", Length::pt(36.0), Length::pt(18.0));
document
.set_image_watermark(PNG, "watermark.png", Length::pt(72.0), Length::pt(36.0))
.unwrap();
}
document.set_text_watermark("FINAL").unwrap();
let relationships = document.package.get_part_rels("/word/header1.xml").unwrap();
assert_eq!(
relationships.get_by_id("producerRel").unwrap().target,
"producer.bin"
);
document.to_bytes().unwrap()
}
assert_eq!(build(false), build(true));
}
#[test]
fn imported_header_watermark_preserves_producer_drawing_bytes_ids_and_relationship_order() {
let mut source = Document::new();
source.set_header_image(PNG, "producer.png", Length::pt(72.0), Length::pt(36.0));
let source_bytes = source.to_bytes().unwrap();
let mut package = OpcPackage::from_reader(Cursor::new(source_bytes)).unwrap();
let mut header =
String::from_utf8(package.get_part("/word/header1.xml").unwrap().to_vec()).unwrap();
assert!(header.contains(r#"wp:docPr id="1""#));
header = header.replacen(r#"wp:docPr id="1""#, r#"wp:docPr id="77""#, 1);
let drawing_start = header.find("<w:drawing").unwrap();
let drawing_end = drawing_start
+ header[drawing_start..].find("</w:drawing>").unwrap()
+ "</w:drawing>".len();
let producer_drawing = header.as_bytes()[drawing_start..drawing_end].to_vec();
package.set_part("/word/header1.xml", header.into_bytes());
package.set_part("/word/z-producer.bin", b"z".to_vec());
package.set_part("/word/a-producer.bin", b"a".to_vec());
package
.content_types
.add_override("/word/z-producer.bin", "application/octet-stream");
package
.content_types
.add_override("/word/a-producer.bin", "application/octet-stream");
let relationships = package.get_or_create_part_rels("/word/header1.xml");
let image_id = relationships
.get_by_type(rel_types::IMAGE)
.unwrap()
.id
.clone();
relationships.items.insert(
0,
oxml_opc::relationship::Relationship {
id: "zProducer".to_owned(),
rel_type: "urn:producer:z".to_owned(),
target: "z-producer.bin".to_owned(),
target_mode: None,
},
);
relationships.add_with_id("aProducer", "urn:producer:a", "a-producer.bin");
let producer_order = ["zProducer".to_owned(), image_id, "aProducer".to_owned()];
let mut serialized = Cursor::new(Vec::new());
package.write_to(&mut serialized).unwrap();
let mut document = Document::from_bytes(serialized.get_ref()).unwrap();
document
.set_image_watermark(PNG, "watermark.png", Length::pt(36.0), Length::pt(18.0))
.unwrap();
let output = OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
let output_header = output.get_part("/word/header1.xml").unwrap();
assert!(
output_header
.windows(producer_drawing.len())
.any(|window| window == producer_drawing)
);
assert!(String::from_utf8_lossy(output_header).contains(r#"wp:docPr id="77""#));
let output_order = output
.get_part_rels("/word/header1.xml")
.unwrap()
.items
.iter()
.take(3)
.map(|relationship| relationship.id.clone())
.collect::<Vec<_>>();
assert_eq!(output_order, producer_order);
}
#[test]
fn legacy_header_setters_and_watermark_roll_back_relationship_exhaustion() {
let package_bytes = |document: &Document| {
let mut output = Cursor::new(Vec::new());
document.package.write_to(&mut output).unwrap();
output.into_inner()
};
let mut text_seed = Document::new();
text_seed
.package
.get_or_create_part_rels("/word/document.xml")
.add_with_id(
&format!("rId{}", u32::MAX),
"urn:exhaustion",
"unchanged.bin",
);
let mut seed_bytes = Cursor::new(Vec::new());
text_seed.package.write_to(&mut seed_bytes).unwrap();
let mut text = Document::from_bytes(seed_bytes.get_ref()).unwrap();
let before = package_bytes(&text);
assert!(
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
text.set_header("header");
}))
.is_err()
);
assert_eq!(package_bytes(&text), before);
let mut image_seed = Document::new();
image_seed.set_header("producer header");
let mut image_package =
OpcPackage::from_reader(Cursor::new(image_seed.to_bytes().unwrap())).unwrap();
image_package
.get_or_create_part_rels("/word/header1.xml")
.add_with_id(
&format!("rId{}", u32::MAX),
"urn:exhaustion",
"unchanged.bin",
);
let mut seed_bytes = Cursor::new(Vec::new());
image_package.write_to(&mut seed_bytes).unwrap();
let source = seed_bytes.into_inner();
let mut image = Document::from_bytes(&source).unwrap();
let before = package_bytes(&image);
assert!(
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
image.set_header_image(PNG, "image.png", Length::pt(36.0), Length::pt(18.0));
}))
.is_err()
);
assert_eq!(package_bytes(&image), before);
let mut watermark = Document::from_bytes(&source).unwrap();
let before = package_bytes(&watermark);
assert!(
watermark
.set_image_watermark(PNG, "image.png", Length::pt(36.0), Length::pt(18.0))
.is_err()
);
assert_eq!(package_bytes(&watermark), before);
}
#[test]
fn header_image_relationship_ids_are_scoped_per_part() {
let mut document = Document::new();
let image_watermark = |text: &str| {
format!(
r#"<w:hdr xmlns:w="{}" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:p><w:r><w:pict><v:shape style="width:72pt;height:36pt"><v:fill opacity=".5"/><v:imagedata r:id="rId1"/></v:shape></w:pict><w:t>{text}</w:t></w:r></w:p></w:hdr>"#,
rdocx_oxml::namespace::W_NS
)
.into_bytes()
};
let mut alternate = PNG.to_vec();
let last = alternate.len() - 1;
alternate[last] ^= 1;
document.set_raw_header_with_images(
image_watermark("default"),
&[("rId1", PNG, "default.png")],
HdrFtrType::Default,
);
document.set_raw_header_with_images(
image_watermark("first"),
&[("rId1", &alternate, "first.png")],
HdrFtrType::First,
);
let input = document.build_layout_input().unwrap();
let scoped = input
.images
.iter()
.filter(|(id, _)| id.ends_with("\0rId1"))
.map(|(_, image)| image.data.as_slice())
.collect::<Vec<_>>();
assert_eq!(scoped.len(), 2);
assert!(scoped.contains(&PNG));
assert!(scoped.contains(&alternate.as_slice()));
}
#[test]
fn header_image_setter_order_does_not_change_package_bytes() {
fn build(reverse: bool) -> Vec<u8> {
let mut document = Document::new();
let mut alternate = PNG.to_vec();
*alternate.last_mut().unwrap() ^= 1;
if reverse {
document.set_first_page_header_image(
&alternate,
"first.png",
Length::pt(72.0),
Length::pt(36.0),
);
document.set_header_image(PNG, "default.png", Length::pt(72.0), Length::pt(36.0));
} else {
document.set_header_image(PNG, "default.png", Length::pt(72.0), Length::pt(36.0));
document.set_first_page_header_image(
&alternate,
"first.png",
Length::pt(72.0),
Length::pt(36.0),
);
}
document.to_bytes().unwrap()
}
assert_eq!(build(false), build(true));
}
#[test]
fn replacing_authored_image_header_with_text_removes_orphan_media() {
let mut document = Document::new();
document.set_header_image(PNG, "old.png", Length::pt(72.0), Length::pt(36.0));
let relationship = document
.package
.get_part_rels("/word/header1.xml")
.and_then(|relationships| relationships.get_by_type(rel_types::IMAGE))
.cloned()
.unwrap();
let image_part = OpcPackage::resolve_rel_target("/word/header1.xml", &relationship.target);
assert!(document.package.get_part(&image_part).is_some());
document.set_header("replacement");
assert!(
document
.package
.get_part_rels("/word/header1.xml")
.is_none_or(|relationships| relationships.items.is_empty())
);
assert!(document.package.get_part(&image_part).is_none());
let package = OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
assert!(package.get_part(&image_part).is_none());
}
#[test]
fn replacing_authored_images_with_text_restores_direct_package_bytes() {
let mut header_history = Document::new();
header_history.set_header_image(PNG, "old.png", Length::pt(72.0), Length::pt(36.0));
header_history.set_header("replacement");
let mut direct_header = Document::new();
direct_header.set_header("replacement");
assert_eq!(
header_history.to_bytes().unwrap(),
direct_header.to_bytes().unwrap()
);
let mut watermark_history = Document::new();
watermark_history
.set_image_watermark(PNG, "old.png", Length::pt(72.0), Length::pt(36.0))
.unwrap();
watermark_history.set_text_watermark("FINAL").unwrap();
let mut direct_watermark = Document::new();
direct_watermark.set_text_watermark("FINAL").unwrap();
let history_bytes = watermark_history.to_bytes().unwrap();
let direct_bytes = direct_watermark.to_bytes().unwrap();
let history_package = OpcPackage::from_reader(Cursor::new(&history_bytes)).unwrap();
let direct_package = OpcPackage::from_reader(Cursor::new(&direct_bytes)).unwrap();
assert_eq!(history_package.content_types, direct_package.content_types);
assert_eq!(
history_package.package_rels.to_xml().unwrap(),
direct_package.package_rels.to_xml().unwrap()
);
let relationship_xml = |package: &OpcPackage| {
let mut relationships = package
.part_rels
.iter()
.map(|(owner, relationships)| (owner.clone(), relationships.to_xml().unwrap()))
.collect::<Vec<_>>();
relationships.sort_by(|left, right| left.0.cmp(&right.0));
relationships
};
assert_eq!(
relationship_xml(&history_package),
relationship_xml(&direct_package)
);
let mut history_parts = history_package.parts.keys().cloned().collect::<Vec<_>>();
history_parts.sort();
let mut direct_parts = direct_package.parts.keys().cloned().collect::<Vec<_>>();
direct_parts.sort();
assert_eq!(history_parts, direct_parts);
for part_name in history_parts {
let history_part = history_package.get_part(&part_name);
let direct_part = direct_package.get_part(&part_name);
if history_part != direct_part {
panic!(
"part {part_name} differs\nhistory: {}\ndirect: {}",
String::from_utf8_lossy(history_part.unwrap()),
String::from_utf8_lossy(direct_part.unwrap())
);
}
}
assert_eq!(history_bytes, direct_bytes);
}
#[test]
fn orphan_image_default_ignores_override_covered_producer_parts() {
fn source() -> Vec<u8> {
let mut document = Document::new_with_profile(WordCreationProfile::Minimal(
WordPackageClass::Document,
));
let mut package =
OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
package.set_part("/word/media/producer.png", b"producer".to_vec());
package
.content_types
.add_override("/word/media/producer.png", "application/x-producer-image");
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
bytes.into_inner()
}
let source = source();
let mut history = Document::from_bytes(&source).unwrap();
history.set_header_image(PNG, "old.png", Length::pt(72.0), Length::pt(36.0));
history.set_header("replacement");
let mut direct = Document::from_bytes(&source).unwrap();
direct.set_header("replacement");
let history_bytes = history.to_bytes().unwrap();
let direct_bytes = direct.to_bytes().unwrap();
let history_package = OpcPackage::from_reader(Cursor::new(&history_bytes)).unwrap();
let direct_package = OpcPackage::from_reader(Cursor::new(&direct_bytes)).unwrap();
assert!(!history_package.content_types.defaults.contains_key("png"));
assert_eq!(history_package.content_types, direct_package.content_types);
assert_eq!(history_bytes, direct_bytes);
}
#[test]
fn pruned_maximum_header_relationship_id_can_be_reused() {
let mut document = Document::new();
let maximum = format!("rId{}", u32::MAX);
let xml = format!(
r#"<w:hdr xmlns:w="{}" xmlns:r="{}"><w:p><w:r><w:drawing><a:blip xmlns:a="{}" r:embed="{maximum}"/></w:drawing></w:r></w:p></w:hdr>"#,
rdocx_oxml::namespace::W_NS,
drawing_ns::R,
drawing_ns::A,
)
.into_bytes();
document.set_raw_header_with_images(
xml,
&[(maximum.as_str(), PNG, "old.png")],
HdrFtrType::Default,
);
assert!(
document
.package
.get_part_rels("/word/header1.xml")
.unwrap()
.get_by_id(&maximum)
.is_some()
);
document.set_header("replacement");
document.set_header_image(PNG, "new.png", Length::pt(72.0), Length::pt(36.0));
let relationship = document
.package
.get_part_rels("/word/header1.xml")
.and_then(|relationships| relationships.get_by_type(rel_types::IMAGE))
.unwrap();
assert_ne!(relationship.id, maximum);
}
#[test]
fn direct_raw_header_replacement_reuses_its_retired_maximum_relationship_id() {
fn raw_header(relationship_id: &str, text: &str) -> Vec<u8> {
format!(
r#"<w:hdr xmlns:w="{}" xmlns:r="{}"><w:p><w:r><w:drawing><a:blip xmlns:a="{}" r:embed="{relationship_id}"/></w:drawing><w:t>{text}</w:t></w:r></w:p></w:hdr>"#,
rdocx_oxml::namespace::W_NS,
drawing_ns::R,
drawing_ns::A,
)
.into_bytes()
}
let maximum = format!("rId{}", u32::MAX);
let mut final_image = PNG.to_vec();
*final_image.last_mut().unwrap() ^= 1;
let mut history = Document::new();
history.set_raw_header_with_images(
raw_header(&maximum, "old"),
&[(maximum.as_str(), PNG, "old.png")],
HdrFtrType::Default,
);
history.set_raw_header_with_images(
raw_header(&maximum, "final"),
&[(maximum.as_str(), &final_image, "final.png")],
HdrFtrType::Default,
);
let mut direct = Document::new();
direct.set_raw_header_with_images(
raw_header(&maximum, "final"),
&[(maximum.as_str(), &final_image, "final.png")],
HdrFtrType::Default,
);
assert_eq!(history.to_bytes().unwrap(), direct.to_bytes().unwrap());
}
#[test]
fn legacy_header_setters_preserve_imported_relationship_order() {
fn source() -> Vec<u8> {
let mut source = Document::new();
source.set_header("producer header");
let bytes = source.to_bytes().unwrap();
let mut package = OpcPackage::from_reader(Cursor::new(bytes)).unwrap();
let relationships = package.get_or_create_part_rels("/word/header1.xml");
relationships.add_with_id("rId9", "urn:producer:first", "first.bin");
relationships.add_with_id("rId2", "urn:producer:second", "second.bin");
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
bytes.into_inner()
}
for image in [false, true] {
let mut document = Document::from_bytes(&source()).unwrap();
if image {
document.set_header_image(PNG, "new.png", Length::pt(72.0), Length::pt(36.0));
} else {
document.set_header("replacement");
}
let package =
OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
let producer_order = package
.get_part_rels("/word/header1.xml")
.unwrap()
.items
.iter()
.filter(|relationship| relationship.rel_type.starts_with("urn:producer:"))
.map(|relationship| relationship.id.as_str())
.collect::<Vec<_>>();
assert_eq!(producer_order, ["rId9", "rId2"]);
}
}
#[test]
fn set_header_preserves_mixed_case_relationship_owner_spelling() {
const MIXED_HEADER: &str = "/WORD/HEADER1.XML";
let mut source = Document::new();
source.set_header("producer");
let mut package = OpcPackage::from_reader(Cursor::new(source.to_bytes().unwrap())).unwrap();
package.set_part("/word/producer.bin", b"producer".to_vec());
package
.content_types
.add_default("bin", "application/octet-stream");
package
.get_or_create_part_rels("/word/header1.xml")
.add_with_id("rId9", "urn:producer", "producer.bin");
let header = package.remove_part("/word/header1.xml").unwrap();
package.parts.insert(MIXED_HEADER.to_owned(), header);
let relationships = package.remove_part_rels("/word/header1.xml").unwrap();
package
.part_rels
.insert(MIXED_HEADER.to_owned(), relationships);
package
.get_part_rels_mut("/word/document.xml")
.unwrap()
.items
.iter_mut()
.find(|relationship| relationship.rel_type == rel_types::HEADER)
.unwrap()
.target = "HEADER1.XML".to_owned();
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
let mut document = Document::from_bytes(bytes.get_ref()).unwrap();
document.set_header("replacement");
let saved = OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
assert!(saved.part_rels.contains_key(MIXED_HEADER));
assert!(!saved.part_rels.contains_key("/word/header1.xml"));
assert_eq!(
saved
.get_part_rels(MIXED_HEADER)
.unwrap()
.get_by_id("rId9")
.unwrap()
.rel_type,
"urn:producer"
);
}
#[test]
fn raw_header_relationship_collisions_are_remapped_without_replacing_preserved_state() {
let mut source = Document::new();
source.set_header("preserved");
source
.package
.get_or_create_part_rels("/word/header1.xml")
.add_with_id("rId1", "urn:preserved", "preserved.bin");
let mut document = Document::from_bytes(&source.to_bytes().unwrap()).unwrap();
let xml = format!(
r#"<w:hdr xmlns:w="{}" xmlns:r="{}"><w:p><w:r><w:drawing><a:blip xmlns:a="{}" r:embed="rId1"/></w:drawing></w:r></w:p></w:hdr>"#,
rdocx_oxml::namespace::W_NS,
drawing_ns::R,
drawing_ns::A,
)
.into_bytes();
document.set_raw_header_with_images(
xml,
&[("rId1", PNG, "replacement.png")],
HdrFtrType::Default,
);
let relationships = document.package.get_part_rels("/word/header1.xml").unwrap();
assert_eq!(
relationships.get_by_id("rId1").unwrap().rel_type,
"urn:preserved"
);
let image = relationships
.items
.iter()
.find(|relationship| relationship.rel_type == rel_types::IMAGE)
.unwrap();
assert_ne!(image.id, "rId1");
let image_id = image.id.clone();
let header =
std::str::from_utf8(document.package.get_part("/word/header1.xml").unwrap()).unwrap();
assert!(
header.contains(&format!(r#"r:embed="{image_id}""#)),
"{header}"
);
assert!(!header.contains("rId1"), "{header}");
let saved = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&saved).unwrap();
let header_part = reopened.active_header_footer_parts().remove(0);
let header_xml = reopened.package.get_part(&header_part).unwrap();
let referenced = xml_relationship_ids_in_order(header_xml).unwrap();
assert!(referenced.contains(&image_id));
assert!(
reopened
.package
.get_part_rels(&header_part)
.unwrap()
.get_by_id(&image_id)
.is_some()
);
}
#[test]
fn authored_raw_header_identifier_rewrites_preserve_unrelated_bytes() {
let mut document = Document::new();
let xml = format!(
r#"<w:hdr xmlns:w="{}" xmlns:r="{}" xmlns:wp="{}" xmlns:x="urn:producer"><w:p><w:r><w:drawing><wp:inline><wp:docPr id="99"/><a:blip xmlns:a="{}" r:embed="rId9"/></wp:inline></w:drawing><x:unknown x:a = 'v'><!-- keep spacing --></x:unknown></w:r></w:p></w:hdr>"#,
rdocx_oxml::namespace::W_NS,
drawing_ns::R,
drawing_ns::WP,
drawing_ns::A,
);
document.set_raw_header_with_images(
xml.as_bytes().to_vec(),
&[("rId9", PNG, "raw.png")],
HdrFtrType::Default,
);
let package = OpcPackage::from_reader(Cursor::new(document.to_bytes().unwrap())).unwrap();
let actual = package.get_part("/word/header1.xml").unwrap();
let expected = xml
.replace(r#"id="99""#, r#"id="1""#)
.replace(r#"r:embed="rId9""#, r#"r:embed="rId0""#);
assert_eq!(actual, expected.as_bytes());
}
#[test]
fn rejected_raw_header_input_is_atomic() {
let mut document = Document::new();
document.set_header("unchanged");
let before = document.to_bytes().unwrap();
let xml = format!(
r#"<w:hdr xmlns:w="{}" xmlns:r="{}"><w:p><w:r><w:drawing><a:blip xmlns:a="{}" r:embed="rId1"/></w:drawing></w:r></w:p></w:hdr>"#,
rdocx_oxml::namespace::W_NS,
drawing_ns::R,
drawing_ns::A,
)
.into_bytes();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
document.set_raw_header_with_images(
xml,
&[("rId1", PNG, "one.png"), ("rId1", PNG, "two.png")],
HdrFtrType::Default,
);
}));
assert!(result.is_err());
assert_eq!(document.to_bytes().unwrap(), before);
}
#[test]
fn occupied_orphan_header_part_is_not_overwritten() {
let mut source = Document::new();
let mut package = OpcPackage::from_reader(Cursor::new(source.to_bytes().unwrap())).unwrap();
package.set_part("/word/header1.xml", b"preserved orphan".to_vec());
package.content_types.add_override(
"/word/header1.xml",
"application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
);
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
let mut document = Document::from_bytes(&bytes.into_inner()).unwrap();
document.set_header("new header");
assert_eq!(
document.package.get_part("/word/header1.xml"),
Some(b"preserved orphan".as_slice())
);
let active = document
.active_header_footer_parts()
.into_iter()
.next()
.unwrap();
assert_ne!(active, "/word/header1.xml");
}
#[test]
fn watermark_covers_title_page_fallback_and_every_section() {
let mut document = Document::new();
document.add_paragraph("first section");
let BodyContent::Paragraph(first) = document.document.body.content.last_mut().unwrap()
else {
panic!("expected first section paragraph");
};
first.properties = Some(CT_PPr {
sect_pr: Some(CT_SectPr::default_letter()),
..Default::default()
});
document.add_paragraph("final section");
document.set_different_first_page(true);
document.set_text_watermark("DRAFT").unwrap();
let sections = document
.document
.body
.content
.iter()
.filter_map(|content| match content {
BodyContent::Paragraph(paragraph) => paragraph
.properties
.as_ref()
.and_then(|properties| properties.sect_pr.as_ref()),
_ => None,
})
.chain(document.document.body.sect_pr.iter())
.collect::<Vec<_>>();
assert_eq!(sections.len(), 2);
assert!(
sections[0]
.header_refs
.iter()
.any(|reference| reference.hdr_ftr_type == HdrFtrType::Default)
);
assert!(
!sections[1]
.header_refs
.iter()
.any(|reference| reference.hdr_ftr_type == HdrFtrType::Default)
);
assert!(
sections[1]
.header_refs
.iter()
.any(|reference| reference.hdr_ftr_type == HdrFtrType::First)
);
let active_parts = document.active_header_footer_parts();
assert_eq!(active_parts.len(), 2);
assert!(
active_parts
.iter()
.all(|part| document.identifiers.authored_story_parts.contains(part))
);
let reopened = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
assert_eq!(reopened.active_header_footer_parts().len(), 2);
let layout = document
.layout_for_options(RenderOptions::default(), true)
.unwrap()
.layout
.clone();
assert_eq!(layout.pages.len(), 2);
assert!(layout.pages.iter().all(|page| {
matches!(
compatibility_page_elements(&page.elements).first(),
Some(oxml_layout::PositionedElement::Group(_))
)
}));
}
#[test]
fn saved_watermark_materializes_first_and_even_fallback_headers() {
let mut document = Document::new();
document.set_different_first_page(true);
enable_even_headers(&mut document);
document.set_text_watermark("DRAFT").unwrap();
let reopened = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
let section = reopened.section_properties().unwrap();
for hdr_type in [HdrFtrType::Default, HdrFtrType::First, HdrFtrType::Even] {
let reference = section
.header_refs
.iter()
.find(|reference| reference.hdr_ftr_type == hdr_type)
.unwrap_or_else(|| panic!("missing saved {hdr_type:?} header"));
let header = reopened
.load_header_footer(&reference.rel_id, true)
.unwrap();
assert!(matches!(
header.watermarks(),
[rdocx_oxml::header_footer::VmlWatermark::Text { text, .. }]
if text == "DRAFT"
));
}
}
#[test]
fn inherited_ordinary_header_remains_inherited_after_watermarking() {
let mut document = Document::new();
document.set_header("Company header");
document.add_paragraph("first section");
let company = document
.section_properties_mut()
.header_refs
.pop()
.expect("company header reference");
let BodyContent::Paragraph(first) = document.document.body.content.last_mut().unwrap()
else {
panic!("expected first section paragraph");
};
first.properties = Some(CT_PPr {
sect_pr: Some(CT_SectPr {
header_refs: vec![company],
..CT_SectPr::default_letter()
}),
..Default::default()
});
document.add_paragraph("second section");
document.set_text_watermark("DRAFT").unwrap();
assert!(
document
.section_properties()
.unwrap()
.header_refs
.is_empty()
);
let reopened = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
assert!(
reopened
.section_properties()
.unwrap()
.header_refs
.is_empty()
);
let layout = reopened
.layout_for_options(RenderOptions::default(), true)
.unwrap()
.layout
.clone();
assert_eq!(layout.pages.len(), 2);
assert!(page_text(&layout, 1).contains("Company header"));
assert!(matches!(
compatibility_page_elements(&layout.pages[1].elements).first(),
Some(oxml_layout::PositionedElement::Group(_))
));
}
#[test]
fn section_page_number_restart_controls_even_header_parity() {
let mut document = Document::new();
document.add_paragraph("first section");
let BodyContent::Paragraph(first) = document.document.body.content.last_mut().unwrap()
else {
panic!("expected first section paragraph");
};
first.properties = Some(CT_PPr {
sect_pr: Some(CT_SectPr::default_letter()),
..Default::default()
});
document.add_paragraph("second section");
document.set_header("default header");
document.set_raw_header_with_images(
format!(
r#"<w:hdr xmlns:w="{}"><w:p><w:r><w:t>even header</w:t></w:r></w:p></w:hdr>"#,
rdocx_oxml::namespace::W_NS
)
.into_bytes(),
&[],
HdrFtrType::Even,
);
enable_even_headers(&mut document);
document.section_properties_mut().page_number =
Some(rdocx_oxml::document::CT_PageNumberType::new(1));
document.set_text_watermark("DRAFT").unwrap();
let reopened = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
let layout = reopened
.layout_for_options(RenderOptions::default(), true)
.unwrap()
.layout
.clone();
assert_eq!(layout.pages.len(), 2);
assert!(page_text(&layout, 1).contains("default header"));
assert!(!page_text(&layout, 1).contains("even header"));
}
#[test]
fn blank_first_and_even_variants_do_not_borrow_default_content() {
let mut first = Document::new();
first.set_raw_header_with_images(
format!(
r#"<w:hdr xmlns:w="{}" xmlns:v="urn:schemas-microsoft-com:vml"><w:p><w:r><w:pict><v:shape style="width:468pt;height:117pt;rotation:315" fillcolor="D9D9D9"><v:fill opacity=".5"/><v:textpath string="DRAFT"/></v:shape></w:pict><w:t>company header</w:t></w:r></w:p></w:hdr>"#,
rdocx_oxml::namespace::W_NS
)
.into_bytes(),
&[],
HdrFtrType::Default,
);
first.set_footer("company footer");
first.set_first_page_header("");
first.set_first_page_footer("");
first.add_paragraph("body");
let first_layout = first
.layout_for_options(RenderOptions::default(), true)
.unwrap()
.layout
.clone();
assert!(
!compatibility_page_elements(&first_layout.pages[0].elements)
.iter()
.any(|element| matches!(element, oxml_layout::PositionedElement::Group(_)))
);
assert!(!page_text(&first_layout, 0).contains("company header"));
assert!(!page_text(&first_layout, 0).contains("company footer"));
let mut even = Document::new();
even.set_header("company header");
even.set_footer("company footer");
even.set_raw_footer_with_images(
format!(r#"<w:ftr xmlns:w="{}"/>"#, rdocx_oxml::namespace::W_NS).into_bytes(),
&[],
HdrFtrType::Even,
);
enable_even_headers(&mut even);
even.set_text_watermark("DRAFT").unwrap();
even.add_paragraph(&"body ".repeat(4_000));
let even_layout = even
.layout_for_options(RenderOptions::default(), true)
.unwrap()
.layout
.clone();
assert!(matches!(
compatibility_page_elements(&even_layout.pages[1].elements).first(),
Some(oxml_layout::PositionedElement::Group(_))
));
assert!(!page_text(&even_layout, 1).contains("company header"));
assert!(!page_text(&even_layout, 1).contains("company footer"));
}
#[test]
fn image_watermark_target_is_relative_to_a_custom_header_part() {
let mut document = Document::new();
let header_part = "/custom/headers/header.xml";
document.package.set_part(
header_part,
format!(
r#"<w:hdr xmlns:w="{}"><w:p/></w:hdr>"#,
rdocx_oxml::namespace::W_NS
)
.into_bytes(),
);
document.package.content_types.add_override(
header_part,
"application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
);
let target = relative_target(&document.doc_part_name, header_part);
let rel_id = document
.package
.get_or_create_part_rels(&document.doc_part_name)
.add(rel_types::HEADER, &target);
document.section_properties_mut().header_refs = vec![HdrFtrRef {
hdr_ftr_type: HdrFtrType::Default,
rel_id,
}];
document
.set_image_watermark(PNG, "custom.png", Length::pt(72.0), Length::pt(36.0))
.unwrap();
let image_relationship = document
.package
.get_part_rels(header_part)
.unwrap()
.items
.iter()
.find(|relationship| relationship.rel_type == rel_types::IMAGE)
.unwrap();
let image_part = OpcPackage::resolve_rel_target(header_part, &image_relationship.target);
assert_eq!(document.package.get_part(&image_part), Some(PNG));
}
#[test]
fn named_vml_colour_renders_its_defined_rgb_value() {
let mut document = Document::new();
document.set_raw_header_with_images(
text_vml_header("DRAFT", "silver"),
&[],
HdrFtrType::Default,
);
document.add_paragraph("body");
let layout = document
.layout_for_options(RenderOptions::default(), true)
.unwrap()
.layout
.clone();
let elements = compatibility_page_elements(&layout.pages[0].elements);
let oxml_layout::PositionedElement::Group(group) = elements[0] else {
panic!("expected watermark group");
};
let oxml_layout::PositionedElement::Text(run) = &group.children[0] else {
panic!("expected watermark text");
};
assert_eq!(
run.color,
oxml_layout::Color {
r: 192.0 / 255.0,
g: 192.0 / 255.0,
b: 192.0 / 255.0,
a: 1.0,
}
);
}
#[test]
fn invalid_multibyte_vml_colour_is_suppressed_without_panicking() {
let mut document = Document::new();
document.set_raw_header_with_images(
text_vml_header("DRAFT", "€€"),
&[],
HdrFtrType::Default,
);
document.add_paragraph("body");
let layout = document
.layout_for_options(RenderOptions::default(), true)
.unwrap()
.layout
.clone();
assert!(
!compatibility_page_elements(&layout.pages[0].elements)
.iter()
.any(|element| matches!(element, oxml_layout::PositionedElement::Group(_)))
);
assert!(
layout
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("colour")
&& diagnostic.message.contains("unsupported"))
);
}
#[test]
fn even_header_selection_follows_the_document_setting() {
let render = |setting: Option<Option<&str>>| {
let mut document = Document::new();
document.set_header("default header");
document.set_raw_header_with_images(
format!(
r#"<w:hdr xmlns:w="{}"><w:p><w:r><w:t>even header</w:t></w:r></w:p></w:hdr>"#,
rdocx_oxml::namespace::W_NS
)
.into_bytes(),
&[],
HdrFtrType::Even,
);
if let Some(value) = setting {
set_even_headers_value(&mut document, value);
}
document.add_paragraph(&"body ".repeat(4_000));
document
.layout_for_options(RenderOptions::default(), true)
.unwrap()
.layout
.clone()
};
let disabled = render(None);
let entity_false = render(Some(Some("0")));
let enabled = render(Some(None));
assert!(page_text(&disabled, 1).contains("default header"));
assert!(!page_text(&disabled, 1).contains("even header"));
assert!(page_text(&entity_false, 1).contains("default header"));
assert!(!page_text(&entity_false, 1).contains("even header"));
assert!(page_text(&enabled, 1).contains("even header"));
}
#[test]
fn unresolved_image_watermark_is_suppressed_with_a_diagnostic() {
let mut document = Document::new();
let xml = format!(
r#"<w:hdr xmlns:w="{}" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:p><w:r><w:pict><v:shape style="width:72pt;height:36pt"><v:imagedata r:id="rIdMissing"/></v:shape></w:pict></w:r></w:p></w:hdr>"#,
rdocx_oxml::namespace::W_NS
);
document.set_raw_header_with_images(xml.into_bytes(), &[], HdrFtrType::Default);
document.add_paragraph("body");
let layout = document
.layout_for_options(RenderOptions::default(), true)
.unwrap()
.layout
.clone();
assert!(
!compatibility_page_elements(&layout.pages[0].elements)
.iter()
.any(|element| matches!(element, oxml_layout::PositionedElement::Group(_)))
);
assert!(layout.diagnostics.iter().any(|diagnostic| {
diagnostic.message.contains("rIdMissing") && diagnostic.message.contains("not resolved")
}));
}
#[test]
fn watermark_is_centered_in_the_margin_rectangle() {
let mut document = Document::new();
document.set_margins(
Length::pt(36.0),
Length::pt(144.0),
Length::pt(108.0),
Length::pt(72.0),
);
document.set_text_watermark("DRAFT").unwrap();
document.add_paragraph("body");
let layout = document
.layout_for_options(RenderOptions::default(), true)
.unwrap()
.layout
.clone();
let elements = compatibility_page_elements(&layout.pages[0].elements);
let oxml_layout::PositionedElement::Group(group) = elements[0] else {
panic!("expected watermark group");
};
let center = group.transform.apply(oxml_layout::Point {
x: 468.0 / 2.0,
y: 117.0 / 2.0,
});
assert!((center.x - (72.0 + (612.0 - 72.0 - 144.0) / 2.0)).abs() < 1.0e-9);
assert!((center.y - (36.0 + (792.0 - 36.0 - 108.0) / 2.0)).abs() < 1.0e-9);
}
#[test]
fn watermark_group_precedes_body_elements_on_every_page() {
let mut document = Document::new();
document.set_header("default header");
document.set_first_page_header("first header");
document.set_raw_header_with_images(
format!(
r#"<w:hdr xmlns:w="{}"><w:p><w:r><w:t>even header</w:t></w:r></w:p></w:hdr>"#,
rdocx_oxml::namespace::W_NS
)
.into_bytes(),
&[],
HdrFtrType::Even,
);
enable_even_headers(&mut document);
document.set_text_watermark("DRAFT").unwrap();
document.add_paragraph(&"body ".repeat(4_000));
let layout = document
.layout_for_options(RenderOptions::default(), true)
.unwrap()
.layout
.clone();
assert!(layout.pages.len() > 2);
assert!(layout.pages.iter().all(|page| {
matches!(
compatibility_page_elements(&page.elements).first(),
Some(oxml_layout::PositionedElement::Group(_))
)
}));
assert!(
page_text(&layout, 0).contains("first header"),
"{:?}",
page_text(&layout, 0)
);
assert!(
page_text(&layout, 1).contains("even header"),
"{:?}",
page_text(&layout, 1)
);
assert!(
page_text(&layout, 2).contains("default header"),
"{:?}",
page_text(&layout, 2)
);
}
#[test]
fn watermark_renders_behind_body_text_on_every_page() {
let mut document = Document::new();
document.set_text_watermark("DRAFT").unwrap();
document.add_paragraph(&"body ".repeat(4_000));
let layout = document
.layout_for_options(RenderOptions::default(), true)
.unwrap()
.layout
.clone();
let pngs = oxml_pdf::render_all_pages(&layout, 72.0);
assert!(pngs.len() > 1);
assert!(pngs.iter().all(|png| png.starts_with(b"\x89PNG\r\n\x1a\n")));
let digests = pngs
.iter()
.map(|png| {
png.iter().fold(0xcbf2_9ce4_8422_2325_u64, |hash, byte| {
(hash ^ u64::from(*byte)).wrapping_mul(0x0000_0100_0000_01b3)
})
})
.collect::<Vec<_>>();
assert_eq!(
digests,
[
8_654_160_490_163_008_642,
8_654_160_490_163_008_642,
8_654_160_490_163_008_642,
8_654_160_490_163_008_642,
8_654_160_490_163_008_642,
9_924_658_905_511_625_255,
]
);
}
}
#[cfg(test)]
mod reader_fact_tests {
use super::*;
#[test]
fn revision_reader_exposes_tracked_insertion_paragraph_items() {
let xml = br#"<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><w:body><w:p><w:ins w:id="7" w:author="Ada"><w:r><w:t>before</w:t></w:r><w:hyperlink r:id="rId9"><w:r><w:t>linked</w:t></w:r></w:hyperlink></w:ins></w:p></w:body></w:document>"#;
let mut document = Document::new();
document.document = CT_Document::from_xml(xml).expect("document parses");
let crate::BodyItemRef::Paragraph(paragraph) =
document.body_items().next().expect("paragraph body item")
else {
panic!("expected paragraph");
};
let crate::ParagraphItemRef::Revision(revision) =
paragraph.items().next().expect("revision paragraph item")
else {
panic!("expected revision");
};
let insertion = revision
.insertion_paragraph()
.expect("insertion paragraph projection");
let items = insertion.items().collect::<Vec<_>>();
assert!(matches!(items[0], crate::ParagraphItemRef::Run(_)));
let crate::ParagraphItemRef::Hyperlink(link) = &items[1] else {
panic!("expected hyperlink");
};
assert_eq!(link.relationship_id(), Some("rId9"));
}
#[test]
fn reader_resolves_complex_field_display_segment_properties() {
let xml = br#"<?xml version="1.0"?><w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body><w:p><w:pPr><w:rPr><w:i/></w:rPr></w:pPr><w:r><w:fldChar w:fldCharType="begin"/></w:r><w:r><w:instrText>PAGE</w:instrText></w:r><w:r><w:fldChar w:fldCharType="separate"/></w:r><w:r><w:rPr><w:b/></w:rPr><w:t>7</w:t></w:r><w:r><w:fldChar w:fldCharType="end"/></w:r></w:p></w:body></w:document>"#;
let mut document = Document::new();
document.document = CT_Document::from_xml(xml).expect("document parses");
let paragraph = document.paragraph(0).expect("paragraph");
let run = paragraph.runs().next().expect("field run");
let crate::RunItemRef::Field(field) = run.items().next().expect("field item") else {
panic!("expected field");
};
let segment = field
.cached_display_segments()
.into_iter()
.next()
.expect("cached segment");
let properties = document.effective_field_segment_properties(¶graph, &segment);
assert_eq!(properties.bold, Some(true));
assert_eq!(properties.italic, Some(true));
}
#[test]
fn unsupported_xml_reader_inspects_preserved_item_bytes() {
let raw = br#"<w:proofErr xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" w:type="spellStart"/>"#;
let fact = UnsupportedXmlRef::from_bytes(raw);
assert_eq!(fact.raw_xml(), Some(raw.as_slice()));
assert_eq!(fact.qualified_name(), Some("w:proofErr"));
assert_eq!(fact.local_name(), "proofErr");
assert_eq!(
fact.namespace_uri(),
Some("http://schemas.openxmlformats.org/wordprocessingml/2006/main")
);
assert!(!fact.has_child_content());
}
}
#[cfg(test)]
mod odttf_tests {
use super::*;
#[test]
fn fx251_authored_font_requires_name_table_before_key_or_part() {
let licensed = include_bytes!("../../oxml-layout/fonts/Caladea-Regular.ttf");
let mut malformed = licensed.to_vec();
let count = u16::from_be_bytes([malformed[4], malformed[5]]) as usize;
let record = (0..count)
.map(|index| 12 + index * 16)
.find(|offset| &malformed[*offset..*offset + 4] == b"name")
.unwrap();
malformed[record..record + 4].copy_from_slice(b"nam_");
malformed.extend_from_slice(b"name");
let head = (0..count)
.map(|index| 12 + index * 16)
.find(|offset| &malformed[*offset..*offset + 4] == b"head")
.unwrap();
let adjustment =
u32::from_be_bytes(malformed[head + 8..head + 12].try_into().unwrap()) as usize + 8;
malformed[adjustment..adjustment + 4].fill(0);
let checksum = malformed.chunks(4).fold(0u32, |sum, chunk| {
let mut word = [0u8; 4];
word[..chunk.len()].copy_from_slice(chunk);
sum.wrapping_add(u32::from_be_bytes(word))
});
malformed[adjustment..adjustment + 4]
.copy_from_slice(&0xB1B0_AFBAu32.wrapping_sub(checksum).to_be_bytes());
assert!(malformed.windows(4).any(|bytes| bytes == b"name"));
assert!(derive_font_key(&malformed).is_err());
let mut document = Document::new();
document.add_paragraph("held source");
let before = document.to_bytes().unwrap();
let make = |data| {
EmbeddedFont::new(
EmbeddedFontKind::Regular,
data,
"{00112233-4455-6677-8899-AABBCCDDEEFF}",
FontEmbeddingLicense::new(true, "Apache-2.0: Caladea"),
)
};
assert!(
document
.embed_font("Refused font", make(malformed.clone()))
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), before);
assert!(
document
.fonts()
.iter()
.all(|font| font.embedded_fonts.is_empty())
);
assert!(derive_font_key(licensed).is_ok());
document
.embed_font("Licensed font", make(licensed.to_vec()))
.unwrap();
let reopened = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
assert!(reopened.fonts().iter().any(|font| font.name == "Licensed font" && font.embedded_fonts[0].data == licensed));
// Imported unsupported font bodies remain opaque through unrelated work.
let reference = document
.font_table
.as_ref()
.unwrap()
.fonts()
.iter()
.find(|font| font.name == "Licensed font")
.unwrap()
.embedded_fonts[0]
.clone();
let (part, _) = document.checked_embedded_font_source(&reference).unwrap();
let stored =
obfuscate_odttf_with_key(&malformed, &font_key_bytes(&reference.font_key).unwrap());
document.package.set_part(&part, stored.clone());
let mut imported = Document::from_bytes(&document.to_bytes().unwrap()).unwrap();
imported.add_paragraph("unrelated edit");
let package =
OpcPackage::from_reader(std::io::Cursor::new(imported.to_bytes().unwrap())).unwrap();
assert_eq!(package.get_part(&part).unwrap(), stored);
}
#[test]
fn fx220_missing_record_embedding_is_one_native_transaction() {
let body = include_bytes!("../../oxml-layout/fonts/Caladea-Regular.ttf");
let mut document = Document::new();
document.add_paragraph("unchanged body");
let font = EmbeddedFont::new(
EmbeddedFontKind::Regular,
body.to_vec(),
"{00112233-4455-6677-8899-AABBCCDDEEFF}",
FontEmbeddingLicense::new(true, "OFL 1.1"),
);
document
.embed_font("FX220 native uninstalled", font.clone())
.unwrap();
let snapshot = document
.fonts()
.into_iter()
.find(|f| f.name == "FX220 native uninstalled")
.unwrap();
assert_eq!(
snapshot.embedded_fonts.as_slice(),
std::slice::from_ref(&font)
);
let before = document.to_bytes().unwrap();
document
.embed_font("FX220 native uninstalled", font)
.unwrap();
assert_eq!(document.to_bytes().unwrap(), before);
let reopened = Document::from_bytes(&before).unwrap();
assert_eq!(reopened.fonts(), document.fonts());
}
#[test]
fn fx220_word_faces_flags_and_atomic_invalid_inputs() {
let body = include_bytes!("../../oxml-layout/fonts/Caladea-Regular.ttf");
let mut document = Document::new();
assert_eq!(document.embed_true_type_fonts().unwrap(), None);
assert_eq!(document.embed_system_fonts().unwrap(), None);
assert_eq!(document.save_subset_fonts().unwrap(), None);
for kind in [
EmbeddedFontKind::Regular,
EmbeddedFontKind::Bold,
EmbeddedFontKind::Italic,
EmbeddedFontKind::BoldItalic,
] {
let font = EmbeddedFont::new(
kind,
body.to_vec(),
"{00112233-4455-6677-8899-AABBCCDDEEFF}",
FontEmbeddingLicense::new(true, "OFL 1.1"),
)
.with_subsetted(kind == EmbeddedFontKind::Italic);
document.embed_font("FX220 face controls", font).unwrap();
}
assert_eq!(document.embed_true_type_fonts().unwrap(), Some(true));
document.set_embed_system_fonts(Some(false)).unwrap();
document.set_save_subset_fonts(Some(true)).unwrap();
let before = document.to_bytes().unwrap();
let fonts = document.try_fonts().unwrap();
let faces = &fonts
.iter()
.find(|font| font.name == "FX220 face controls")
.unwrap()
.embedded_fonts;
assert_eq!(faces.len(), 4);
assert!(
faces
.iter()
.all(|face| face.data.as_slice() == body.as_slice()
&& face.license.identity == "OFL 1.1")
);
let mut reopened = Document::from_bytes(&before).unwrap();
assert_eq!(reopened.try_fonts().unwrap(), fonts);
assert_eq!(reopened.embed_system_fonts().unwrap(), Some(false));
assert_eq!(reopened.save_subset_fonts().unwrap(), Some(true));
for case in 0..8 {
let mut font = faces[0].clone();
match case {
0 => font.license.authorized = false,
1 => font.font_key = "invalid".to_owned(),
2 => font.license.identity = "bad\nidentity".to_owned(),
3 => font.data = b"ttcf\0\x01\0\0".to_vec(),
4 => font.data[20..24].copy_from_slice(&u32::MAX.to_be_bytes()),
5 => font.license.identity = "bad\0identity".to_owned(),
6 => font.license.identity = "bad\u{fffe}identity".to_owned(),
7 => font.license.identity = "bad\u{ffff}identity".to_owned(),
_ => unreachable!(),
}
assert!(reopened.embed_font("FX220 face controls", font).is_err());
assert_eq!(reopened.to_bytes().unwrap(), before);
}
for kind in [
EmbeddedFontKind::Regular,
EmbeddedFontKind::Bold,
EmbeddedFontKind::Italic,
EmbeddedFontKind::BoldItalic,
] {
assert!(
reopened
.remove_embedded_font("FX220 face controls", kind)
.unwrap()
.is_some()
);
}
assert_eq!(reopened.embed_true_type_fonts().unwrap(), Some(true));
assert!(
reopened
.try_fonts()
.unwrap()
.iter()
.find(|font| font.name == "FX220 face controls")
.unwrap()
.embedded_fonts
.is_empty()
);
reopened.set_embed_true_type_fonts(None).unwrap();
reopened.set_embed_system_fonts(None).unwrap();
reopened.set_save_subset_fonts(None).unwrap();
assert_eq!(reopened.embed_true_type_fonts().unwrap(), None);
assert_eq!(reopened.embed_system_fonts().unwrap(), None);
assert_eq!(reopened.save_subset_fonts().unwrap(), None);
}
#[test]
fn fx220_word_snapshot_counts_repeated_projected_body_copies() {
let body = include_bytes!("../../oxml-layout/fonts/Carlito-Regular.ttf");
let mut document = Document::new();
document
.embed_font(
"FX220 shared source",
EmbeddedFont::new(
EmbeddedFontKind::Regular,
body.to_vec(),
"{00112233-4455-6677-8899-AABBCCDDEEFF}",
FontEmbeddingLicense::new(true, "OFL 1.1"),
),
)
.unwrap();
let table = document.font_table.as_mut().unwrap();
let reference = table
.fonts()
.iter()
.find(|font| font.name == "FX220 shared source")
.unwrap()
.embedded_fonts[0]
.clone();
for index in 0..2000 {
let name = format!("FX220 shared alias {index}");
table.set_font(name.clone(), None, None, None);
table.set_embedded_font(&name, reference.clone());
}
let before = table.to_xml().unwrap();
assert!(
matches!(document.try_fonts(), Err(error) if error.to_string().contains("byte or face bound"))
);
assert_eq!(
document.font_table.as_ref().unwrap().to_xml().unwrap(),
before
);
assert!(document.fonts().is_empty());
assert!(
document
.remove_embedded_font("FX220 shared alias 1000", EmbeddedFontKind::Regular)
.unwrap()
.is_some()
);
assert!(
document
.package
.parts
.values()
.any(|bytes| bytes.len() == body.len())
);
}
fn fx220_word_source_snapshot(document: &Document) -> (Vec<u8>, Vec<u8>) {
let mut writer = std::io::Cursor::new(Vec::new());
document.package.write_to(&mut writer).unwrap();
(
writer.into_inner(),
document.font_table.as_ref().unwrap().to_xml().unwrap(),
)
}
#[test]
fn fx220_word_invalid_removed_face_graph_refuses_before_publication() {
let body = include_bytes!("../../oxml-layout/fonts/Caladea-Regular.ttf");
for case in 0..5 {
for preserve in [false, true] {
for whole in [false, true] {
let mut document = Document::new();
document
.embed_font(
"FX220 graph owner",
EmbeddedFont::new(
EmbeddedFontKind::Regular,
body.to_vec(),
"{00112233-4455-6677-8899-AABBCCDDEEFF}",
FontEmbeddingLicense::new(true, "OFL 1.1"),
),
)
.unwrap();
let reference = document
.font_table
.as_ref()
.unwrap()
.fonts()
.iter()
.find(|font| font.name == "FX220 graph owner")
.unwrap()
.embedded_fonts[0]
.clone();
let (part, _) = document.checked_embedded_font_source(&reference).unwrap();
if preserve {
let xml = String::from_utf8(
document.font_table.as_ref().unwrap().to_xml().unwrap(),
)
.unwrap()
.replace(
"</w:fonts>",
&format!(
"<x:payload xmlns:x='urn:fx220-retention' r:id='{}'/></w:fonts>",
reference.relationship_id
),
);
document.font_table = Some(FontTable::from_xml(xml.as_bytes()).unwrap());
}
let owner = document.font_table_part_name.clone().unwrap();
let relations = document.package.get_part_rels_mut(&owner).unwrap();
let relation = relations
.items
.iter_mut()
.find(|rel| rel.id == reference.relationship_id)
.unwrap();
match case {
0 => relation.rel_type = rel_types::IMAGE.to_owned(),
1 => relations
.items
.retain(|rel| rel.id != reference.relationship_id),
2 => relation.target_mode = Some("External".to_owned()),
3 => {
document.package.remove_part(&part);
}
4 => document
.package
.content_types
.add_override(&part, "image/png"),
_ => unreachable!(),
}
let before = fx220_word_source_snapshot(&document);
if whole {
assert!(document.remove_font("FX220 graph owner").is_err());
} else {
assert!(
document
.remove_embedded_font(
"FX220 graph owner",
EmbeddedFontKind::Regular
)
.is_err()
);
}
assert_eq!(fx220_word_source_snapshot(&document), before);
}
}
}
}
#[test]
fn fx220_word_shared_target_replacement_and_removal_use_copy_on_write() {
let body = include_bytes!("../../oxml-layout/fonts/Caladea-Regular.ttf");
let replacement = include_bytes!("../../oxml-layout/fonts/Carlito-Regular.ttf");
let mut document = Document::new();
let font = EmbeddedFont::new(
EmbeddedFontKind::Regular,
body.to_vec(),
"{00112233-4455-6677-8899-AABBCCDDEEFF}",
FontEmbeddingLicense::new(true, "OFL 1.1"),
);
document
.embed_font("FX220 original owner", font.clone())
.unwrap();
let table = document.font_table.as_mut().unwrap();
let reference = table
.fonts()
.iter()
.find(|font| font.name == "FX220 original owner")
.unwrap()
.embedded_fonts[0]
.clone();
table.set_font("FX220 shared owner".to_owned(), None, None, None);
table.set_embedded_font("FX220 shared owner", reference.clone());
let (original_part, original_bytes) =
document.checked_embedded_font_source(&reference).unwrap();
let original_bytes = original_bytes.to_vec();
let mut changed = font;
changed.data = replacement.to_vec();
document
.embed_font("FX220 original owner", changed)
.unwrap();
assert_eq!(
document.package.get_part(&original_part),
Some(original_bytes.as_slice())
);
assert_eq!(
document
.try_fonts()
.unwrap()
.iter()
.find(|font| font.name == "FX220 shared owner")
.unwrap()
.embedded_fonts[0]
.data
.as_slice(),
body.as_slice()
);
document.remove_font("FX220 original owner").unwrap();
assert_eq!(
document.package.get_part(&original_part),
Some(original_bytes.as_slice())
);
document
.remove_embedded_font("FX220 shared owner", EmbeddedFontKind::Regular)
.unwrap();
assert!(document.package.get_part(&original_part).is_none());
assert_eq!(document.embed_true_type_fonts().unwrap(), Some(true));
}
#[test]
fn fx220_word_render_caller_tier_wins_embedded_css_face_rank() {
let body = include_bytes!("../../oxml-layout/fonts/Caladea-Bold.ttf");
let caller = include_bytes!("../../oxml-layout/fonts/Carlito-Regular.ttf");
let name = "FX220 Word uninstalled tier";
let mut document = Document::new();
document
.add_paragraph("")
.add_run("iiiiWWWW")
.font(name)
.bold(true);
document
.embed_font(
name,
EmbeddedFont::new(
EmbeddedFontKind::Bold,
body.to_vec(),
"{00112233-4455-6677-8899-AABBCCDDEEFF}",
FontEmbeddingLicense::new(true, "OFL 1.1"),
),
)
.unwrap();
let embedded = document.layout_deterministic().unwrap();
assert!(
embedded
.layout
.fonts
.iter()
.any(|font| font.data.as_ref() == body.as_slice())
);
let layout = document
.layout_with_fonts_and_bundled_fallback(&[(name, caller.as_slice())])
.unwrap();
let mut painted = 0;
oxml_layout::walk(&layout.layout.pages[0].elements, &mut |element, _| {
let (text, id) = match element {
oxml_layout::PositionedElement::Text(run) => (run.text.as_str(), run.font_id),
oxml_layout::PositionedElement::MultilingualText(run) => {
(run.logical_text.as_str(), run.font_id)
}
_ => return,
};
if text.contains("iiiiWWWW") {
painted += 1;
let selected = layout
.layout
.fonts
.iter()
.find(|font| font.id == id)
.unwrap();
assert!(
selected.data.as_ref() == caller.as_slice(),
"actual selected family {}",
selected.family
);
}
});
assert!(painted > 0);
}
#[test]
fn fx220_word_derived_key_has_checked_reversible_byte_order() {
let body = include_bytes!("../../oxml-layout/fonts/Caladea-Regular.ttf");
let key = derive_font_key(body).unwrap();
assert_eq!(key, derive_font_key(body).unwrap());
assert_eq!(key.len(), 38);
assert_eq!(key, key.to_uppercase());
let guid = font_key_bytes(&key).unwrap();
let stored = obfuscate_odttf_with_key(body, &guid);
assert_ne!(&stored[..32], &body[..32]);
assert_eq!(&stored[32..], &body[32..]);
assert_eq!(
deobfuscate_odttf_with_key(&stored, &key).as_deref(),
Some(body.as_slice())
);
assert!(derive_font_key(b"ttcf").is_err());
assert!(derive_font_key(&[0; 32]).is_err());
}
#[test]
fn fx220_word_distinct_source_renders_all_shared_aliases_and_preserves_noop_cache() {
let body = include_bytes!("../../oxml-layout/fonts/LiberationMono-Regular.ttf");
let caller = include_bytes!("../../oxml-layout/fonts/Carlito-Regular.ttf");
let mut document = Document::new();
let font = EmbeddedFont::new(
EmbeddedFontKind::Regular,
body.to_vec(),
derive_font_key(body).unwrap(),
FontEmbeddingLicense::new(true, "OFL 1.1"),
);
document
.embed_font("FX220 alias source", font.clone())
.unwrap();
let table = document.font_table.as_mut().unwrap();
let reference = table
.fonts()
.iter()
.find(|font| font.name == "FX220 alias source")
.unwrap()
.embedded_fonts[0]
.clone();
for index in 0..2000 {
let name = format!("FX220 render alias {index}");
table.set_font(name.clone(), None, None, None);
table.set_embedded_font(&name, reference.clone());
}
document.font_table_dirty = true;
for index in [0, 1000, 1999] {
document
.add_paragraph("")
.add_run("iiiiWWWW")
.font(&format!("FX220 render alias {index}"));
}
let input = document.build_layout_input().unwrap();
assert!(input.fonts.is_empty());
assert_eq!(input.embedded_fonts.len(), 1);
assert_eq!(input.embedded_fonts[0].0.len(), 2001);
assert_eq!(input.embedded_fonts[0].1.as_ref(), body.as_slice());
let before = document.to_bytes().unwrap();
let first = document.layout_deterministic().unwrap();
let mut painted = 0;
for page in &first.layout.pages {
oxml_layout::walk(&page.elements, &mut |element, _| {
let (text, id) = match element {
oxml_layout::PositionedElement::Text(run) => (run.text.as_str(), run.font_id),
oxml_layout::PositionedElement::MultilingualText(run) => {
(run.logical_text.as_str(), run.font_id)
}
_ => return,
};
if text.contains("iiiiWWWW") {
painted += 1;
assert!(
first
.layout
.fonts
.iter()
.find(|font| font.id == id)
.unwrap()
.data
.as_ref()
== body.as_slice()
);
}
});
}
assert_eq!(painted, 3);
document.embed_font("FX220 alias source", font).unwrap();
assert_eq!(document.to_bytes().unwrap(), before);
assert!(Arc::ptr_eq(
&first,
&document.layout_deterministic().unwrap()
));
document.set_embed_true_type_fonts(None).unwrap();
assert!(
document
.layout_deterministic()
.unwrap()
.layout
.fonts
.iter()
.any(|font| font.data.as_ref() == body.as_slice())
);
document.set_embed_true_type_fonts(Some(false)).unwrap();
assert!(
document
.build_layout_input()
.unwrap()
.embedded_fonts
.is_empty()
);
assert!(
document
.layout_deterministic()
.unwrap()
.layout
.fonts
.iter()
.all(|font| font.data.as_ref() != body.as_slice())
);
let layout = document
.layout_with_fonts_and_bundled_fallback(&[(
"FX220 render alias 1999",
caller.as_slice(),
)])
.unwrap();
assert!(
layout
.layout
.fonts
.iter()
.any(|font| font.data.as_ref() == caller.as_slice())
);
}
#[test]
fn fx220_word_descriptive_font_noop_retains_cached_layout() {
let mut document = Document::new();
let font = FontDefinition::new("FX220 metadata font")
.with_alternate_name("FX220 alternate")
.with_family("roman")
.with_pitch("variable");
document.set_font(font.clone()).unwrap();
document.add_paragraph("metadata no-op");
let first = document.layout_deterministic().unwrap();
let before = document.to_bytes().unwrap();
document.set_font(font).unwrap();
assert_eq!(document.to_bytes().unwrap(), before);
assert!(Arc::ptr_eq(
&first,
&document.layout_deterministic().unwrap()
));
}
#[test]
fn fx220_word_descriptive_font_opaque_clear_refuses_atomically() {
let xml = format!(
r#"<w:fonts xmlns:w="{}" xmlns:x="urn:producer"><w:font w:name="FX220 opaque"><w:altName w:val="alternate" x:fact="kept"/></w:font></w:fonts>"#,
rdocx_oxml::namespace::W_NS
);
let mut document = Document::new();
document
.set_font(FontDefinition::new("FX220 opaque"))
.unwrap();
document.font_table = Some(FontTable::from_xml(xml.as_bytes()).unwrap());
document.font_table_dirty = true;
let before = document.to_bytes().unwrap();
assert!(
document
.set_font(FontDefinition::new("FX220 opaque"))
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), before);
}
#[test]
fn fx220_word_multiple_font_flags_serialize_and_reopen() {
let mut document = Document::new();
for (kind, body) in [
(
EmbeddedFontKind::Regular,
include_bytes!("../../oxml-layout/fonts/Selawik-Regular.ttf").as_slice(),
),
(
EmbeddedFontKind::Bold,
include_bytes!("../../oxml-layout/fonts/Selawik-Bold.ttf").as_slice(),
),
] {
document
.embed_font(
"Selawik",
EmbeddedFont::new(
kind,
body.to_vec(),
derive_font_key(body).unwrap(),
FontEmbeddingLicense::new(true, "SIL Open Font License 1.1"),
),
)
.unwrap();
}
document.set_save_subset_fonts(Some(false)).unwrap();
document.set_embed_true_type_fonts(Some(false)).unwrap();
let raw = document.settings.as_ref().unwrap().to_xml().unwrap();
assert!(
oxml_core::xml::validate_strict_xml_1_0(&raw).is_ok(),
"settings: {raw:?}"
);
let bytes = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&bytes).unwrap();
assert_eq!(reopened.embed_true_type_fonts().unwrap(), Some(false));
assert_eq!(reopened.save_subset_fonts().unwrap(), Some(false));
assert_eq!(
reopened
.try_fonts()
.unwrap()
.iter()
.find(|font| font.name == "Selawik")
.unwrap()
.embedded_fonts
.len(),
2
);
}
/// Build a TrueType header with a two-entry table directory.
fn fake_font() -> Vec<u8> {
let mut data = Vec::new();
data.extend(b"\x00\x01\x00\x00"); // sfntVersion
data.extend(2u16.to_be_bytes()); // numTables
data.extend(32u16.to_be_bytes()); // searchRange
data.extend(1u16.to_be_bytes()); // entrySelector
data.extend(0u16.to_be_bytes()); // rangeShift
for (tag, offset, length) in [(b"cmap", 96u32, 40u32), (b"head", 136, 54)] {
data.extend(tag); // tag
data.extend(0u32.to_be_bytes()); // checksum
data.extend(offset.to_be_bytes());
data.extend(length.to_be_bytes());
}
data.extend((0u8..64).map(|i| i.wrapping_mul(7)));
data
}
fn obfuscate(font: &[u8], key: &[u8; 16]) -> Vec<u8> {
let mut out = font.to_vec();
for (i, byte) in out.iter_mut().take(32).enumerate() {
*byte ^= key[i % 16];
}
out
}
const GUID_HEX: &str = "00112233445566778899AABBCCDDEEFF";
fn guid_bytes() -> [u8; 16] {
let mut g = [0u8; 16];
for (i, b) in g.iter_mut().enumerate() {
*b = u8::from_str_radix(&GUID_HEX[i * 2..i * 2 + 2], 16).unwrap();
}
g
}
#[test]
fn recovers_font_under_either_key_convention() {
let font = fake_font();
let name = format!("{GUID_HEX}.odttf");
for key in odttf_key_candidates(&guid_bytes()) {
let obfuscated = obfuscate(&font, &key);
assert_eq!(
deobfuscate_odttf(&obfuscated, &name).as_deref(),
Some(font.as_slice()),
"failed to recover font for key {key:02x?}",
);
}
}
#[test]
fn rejects_data_that_does_not_decode_to_a_font() {
// A GUID that matches nothing in the payload must not yield garbage.
let junk = vec![0xAB; 64];
let name = format!("{GUID_HEX}.odttf");
assert_eq!(deobfuscate_odttf(&junk, &name), None);
}
#[test]
fn rejects_short_or_malformed_input() {
assert_eq!(deobfuscate_odttf(&[0u8; 8], "abc.odttf"), None);
assert_eq!(deobfuscate_odttf(&[0u8; 64], "not-a-guid.odttf"), None);
}
#[test]
fn accepts_braced_and_hyphenated_names() {
let font = fake_font();
let key = odttf_key_candidates(&guid_bytes())[0];
let obfuscated = obfuscate(&font, &key);
for name in [
"{00112233-4455-6677-8899-AABBCCDDEEFF}.odttf",
"00112233-4455-6677-8899-AABBCCDDEEFF.odttf",
] {
assert_eq!(
deobfuscate_odttf(&obfuscated, name).as_deref(),
Some(font.as_slice()),
"failed for {name}"
);
}
}
#[test]
fn malformed_fragment_relationship_xml_aborts_without_mutation() {
use std::io::{Cursor, Read, Write};
use zip::write::SimpleFileOptions;
use zip::{ZipArchive, ZipWriter};
let mut source = Document::new();
source.add_picture(
b"malformed-fragment-relationship",
"malformed.png",
Length::pt(12.0),
Length::pt(9.0),
);
let body = source
.stories()
.unwrap()
.into_iter()
.find(|story| story.kind() == StoryKind::Body)
.unwrap();
let item = source.story_items(&body).unwrap()[0].location().clone();
let mut fragment =
DocumentFragment::from_range(&source, &item, &ContentLocation::end(body), false)
.unwrap();
let mut archive = ZipArchive::new(Cursor::new(fragment.package.as_slice())).unwrap();
let mut entries = Vec::new();
for index in 0..archive.len() {
let mut file = archive.by_index(index).unwrap();
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).unwrap();
let name = file.name().unwrap().into_owned();
if name == "word/_rels/document.xml.rels" {
bytes = b"<Relationships".to_vec();
}
entries.push((name, file.compression(), bytes));
}
let mut writer = ZipWriter::new(Cursor::new(Vec::new()));
for (name, compression, bytes) in entries {
writer
.start_file(
name,
SimpleFileOptions::default().compression_method(compression),
)
.unwrap();
writer.write_all(&bytes).unwrap();
}
fragment.package = writer.finish().unwrap().into_inner();
let mut destination = Document::new();
destination.add_paragraph("unchanged");
let before = destination.to_bytes().unwrap();
let body = destination
.stories()
.unwrap()
.into_iter()
.find(|story| story.kind() == StoryKind::Body)
.unwrap();
let error = destination
.import_fragment(
&ContentLocation::end(body),
&fragment,
FragmentConflictPolicy::reuse_equivalent(),
)
.unwrap_err();
assert!(
error.to_string().contains("invalid document fragment")
&& error.to_string().contains("XML error"),
"{error}"
);
assert_eq!(destination.to_bytes().unwrap(), before);
}
#[test]
fn scoped_source_probe_distinguishes_identical_sibling_positions_and_publishes_nothing() {
let mut document = Document::new();
document.add_paragraph("identical");
document.add_paragraph("identical");
document.prepare_staged_package().unwrap();
let canonical = document.document.to_xml().unwrap();
let original = document
.package
.get_part(&document.doc_part_name)
.unwrap()
.to_vec();
let owner = scan_story_owners(&canonical, StoryKind::Body)
.unwrap()
.into_iter()
.find(|o| o.kind == StoryKind::Body)
.unwrap();
let items = direct_story_content_items(&canonical, &owner).unwrap();
let retained_owner = scan_story_owners(&original, StoryKind::Body)
.unwrap()
.into_iter()
.find(|o| o.kind == StoryKind::Body)
.unwrap();
let retained = direct_story_content_items(&original, &retained_owner).unwrap();
assert!(
document
.prove_scoped_main_source(
&canonical,
items[0].full.clone(),
&original,
retained[0].full.clone()
)
.is_ok()
);
assert!(
document
.prove_scoped_main_source(
&canonical,
items[0].full.clone(),
&original,
retained[1].full.clone()
)
.is_err()
);
assert_eq!(
document.package.get_part(&document.doc_part_name),
Some(original.as_slice())
);
}
#[test]
fn scoped_replacement_preparation_failures_leave_models_and_package_unchanged() {
use std::io::Cursor;
let mut source =
Document::new_with_profile(WordCreationProfile::Minimal(WordPackageClass::Document));
source.add_paragraph("Version");
let mut package = OpcPackage::from_reader(Cursor::new(source.to_bytes().unwrap())).unwrap();
let relationships = package.get_or_create_part_rels("/word/document.xml");
relationships
.items
.retain(|relationship| relationship.rel_type != rel_types::STYLES);
relationships.add_with_id(&format!("rId{}", u32::MAX), "urn:producer", "producer.bin");
package.parts.remove(DEFAULT_STYLES_PART);
package.content_types.overrides.remove(DEFAULT_STYLES_PART);
let mut bytes = Cursor::new(Vec::new());
package.write_to(&mut bytes).unwrap();
let mut document = Document::from_bytes(bytes.get_ref()).unwrap();
let location = document.paragraph_story_location(0).unwrap().unwrap();
let model = document.document.clone();
let mut before = Cursor::new(Vec::new());
document.package.write_to(&mut before).unwrap();
let result = document.try_replace_text_at(&location, "Version", "Release", Some(1));
assert!(
result
.unwrap_err()
.to_string()
.contains("styles relationship allocation failed")
);
assert_eq!(document.document, model);
let mut after = Cursor::new(Vec::new());
document.package.write_to(&mut after).unwrap();
assert_eq!(after.into_inner(), before.into_inner());
}
#[test]
fn scoped_replacement_final_reopen_failure_discards_the_complete_candidate() {
let mut document = Document::new();
document.add_paragraph("Version");
document.add_paragraph("Version");
let location = document.paragraph_story_location(0).unwrap().unwrap();
let before = document.to_bytes().unwrap();
let model = document.document.clone();
FAIL_NEXT_SCOPED_REOPEN.set(true);
let result = document.try_replace_text_at(&location, "Version", "Release", Some(1));
let missed_boundary = FAIL_NEXT_SCOPED_REOPEN.replace(false);
assert!(
!missed_boundary,
"final reopen boundary must have been reached"
);
assert!(result.unwrap_err().to_string().contains("entry count"));
assert_eq!(document.document, model);
assert_eq!(document.to_bytes().unwrap(), before);
}
#[test]
fn whole_story_final_reopen_and_preparation_failures_discard_candidates() {
let mut document = Document::new();
document.add_paragraph("live");
document.set_header("original");
let before = document.to_bytes().unwrap();
let model = document.document.clone();
FAIL_NEXT_STAGED_REOPEN.set(true);
let result = document.try_set_first_page_header("new");
let missed = FAIL_NEXT_STAGED_REOPEN.replace(false);
assert!(!missed, "actual final reopen boundary reached");
assert!(result.unwrap_err().to_string().contains("entry count"));
assert_eq!(document.document, model);
assert_eq!(document.to_bytes().unwrap(), before);
assert!(document.try_set_header("invalid \u{1}").is_err());
assert_eq!(document.to_bytes().unwrap(), before);
}
fn fx210_layout_runs(layout: &oxml_layout::LayoutResult) -> Vec<oxml_layout::GlyphRun> {
let mut runs = Vec::new();
for page in &layout.pages {
oxml_layout::walk(&page.elements, &mut |element, _| match element {
oxml_layout::PositionedElement::Text(run) => runs.push(run.clone()),
oxml_layout::PositionedElement::MultilingualText(run) => {
runs.push(run.legacy_projection())
}
_ => {}
});
}
runs
}
#[test]
fn fx210_word_public_aptos_diagnostics_follow_actual_owned_contributions() {
let mut document = Document::new();
for family in ["Aptos", "Aptos", "Aptos Display", "Calibri"] {
document.add_paragraph("").add_run("iiiiWWWW").font(family);
}
document.add_paragraph("").add_run("").font("Aptos");
let before = document.to_bytes().unwrap();
let cold = document
.layout_with_fonts_and_bundled_fallback(&[])
.unwrap();
let runs = fx210_layout_runs(&cold.layout);
assert_eq!(
runs.iter().filter(|run| run.text == "iiiiWWWW").count(),
4,
"actual final contribution precondition"
);
for run in &runs {
assert!(run.glyph_ids.iter().all(|glyph| *glyph != 0));
let selected = cold
.layout
.fonts
.iter()
.find(|font| font.id == run.font_id)
.unwrap();
assert_eq!(
selected.family, "Carlito",
"actual approximate-face precondition"
);
assert_eq!(
selected.data.as_ref(),
include_bytes!("../../oxml-layout/fonts/Carlito-Regular.ttf").as_slice()
);
}
let messages: Vec<_> = cold
.layout
.diagnostics
.iter()
.filter(|d| d.message.contains("Aptos"))
.map(|d| d.message.clone())
.collect();
assert_eq!(
messages.len(),
2,
"F-X210 Word owned layout must report both Aptos requests once"
);
assert!(messages[0].contains("Aptos") && !messages[0].contains("Aptos Display"));
assert!(messages[1].contains("Aptos Display"));
assert!(
messages
.iter()
.all(|message| message.contains("Carlito") && message.contains("approximate"))
);
let warm = document
.layout_with_fonts_and_bundled_fallback(&[])
.unwrap();
assert_eq!(warm.layout.diagnostics, cold.layout.diagnostics);
assert_eq!(fx210_layout_runs(&warm.layout), runs);
assert_eq!(document.to_bytes().unwrap(), before);
for index in 0..4 {
document
.paragraph_mut(index)
.unwrap()
.run_mut(0)
.unwrap()
.set_font("Calibri");
}
let non_aptos = document
.layout_with_fonts_and_bundled_fallback(&[])
.unwrap();
assert!(
!non_aptos
.layout
.diagnostics
.iter()
.any(|d| d.message.contains("Aptos")),
"prior FontIds must not contaminate a later non-Aptos output"
);
}
#[test]
fn fx210_word_public_symbols_and_list_markers_use_only_qualified_semantics() {
let cases = [
("Wingdings", 0xa7, '\u{25aa}'),
("Wingdings", 0xd8, '\u{27a2}'),
("Wingdings", 0xfc, '\u{2713}'),
("Wingdings", 0x76, '\u{2756}'),
("Wingdings", 0x71, '\u{2751}'),
("Wingdings", 0x6e, '\u{25a0}'),
("Wingdings", 0xfe, '\u{2611}'),
("Symbol", 0xb7, '\u{2022}'),
];
for (family, code, target) in cases {
for original in [code, 0xf000 + code] {
let mut document = Document::new();
document
.add_paragraph("")
.add_run("")
.add_symbol_char(family, original);
let before = document.to_bytes().unwrap();
let source = document.document.to_xml().unwrap();
let result = document.layout_deterministic().unwrap();
let runs = fx210_layout_runs(&result.layout);
assert_eq!(
runs.len(),
1,
"explicit symbol fixture must reach one painted run"
);
assert_eq!(
runs[0].text,
target.to_string(),
"F-X210 Word explicit symbol must use its qualified Unicode target"
);
assert!(runs[0].glyph_ids.iter().all(|glyph| *glyph != 0));
assert_eq!(document.document.to_xml().unwrap(), source);
assert_eq!(document.to_bytes().unwrap(), before);
let reopened = Document::from_bytes(&before).unwrap();
assert_eq!(
fx210_layout_runs(&reopened.layout_deterministic().unwrap().layout),
runs
);
}
let mut document = Document::new();
let marker = char::from_u32(u32::from(0xf000 + code))
.unwrap()
.to_string();
let level = ListLevel::bullet()
.level_text(&marker)
.marker_properties(CT_RPr {
font_ascii: Some(family.to_owned()),
font_hansi: Some(family.to_owned()),
..Default::default()
});
let id = document.add_list_definition(&[level]);
assert!(document.add_paragraph("item").set_numbering(id, 0));
let before = document.to_bytes().unwrap();
let result = document.layout_deterministic().unwrap();
let runs = fx210_layout_runs(&result.layout);
let visible = runs
.iter()
.find(|run| run.text != "item")
.expect("a real list marker is emitted");
assert_eq!(
visible.text.trim(),
target.to_string(),
"F-X210 Word list marker must use its qualified Unicode target"
);
assert!(visible.glyph_ids.iter().all(|glyph| *glyph != 0));
assert_eq!(document.to_bytes().unwrap(), before);
}
// Ordinary text, unknown codes and other font families retain source semantics.
for (family, character, explicit) in [
("Wingdings", '\u{f0a7}', false),
("Wingdings", '\u{f001}', true),
("Wingdings 2", '\u{f0a7}', true),
("Unrelated face", '\u{f0a7}', true),
] {
let mut document = Document::new();
let mut paragraph = document.add_paragraph("");
let mut run = paragraph.add_run("");
run.set_font(family);
if explicit {
run.add_symbol_char(family, character as u16);
} else {
run.add_text(&character.to_string());
}
let before = document.to_bytes().unwrap();
let result = document.layout_deterministic().unwrap();
assert_eq!(
fx210_layout_runs(&result.layout)[0].text,
character.to_string()
);
assert_eq!(document.to_bytes().unwrap(), before);
}
}
#[test]
fn fx210_word_diagnostic_companions_keep_payload_and_one_layout() {
let mut document = Document::new();
document
.add_paragraph("")
.add_run("Actual contributing text")
.font("Aptos");
let before = document.to_bytes().unwrap();
let fonts = [(
"Aptos",
include_bytes!("../../oxml-layout/fonts/Carlito-Regular.ttf").as_slice(),
)];
let options = RenderOptions::default();
super::LAYOUT_INVOCATIONS.set(0);
let (pdf, facts) = document.to_pdf_with_diagnostics(&fonts, options).unwrap();
assert_eq!(
super::LAYOUT_INVOCATIONS.get(),
1,
"diagnostic companions must not infer earlier bytes from another render"
);
assert_eq!(
facts
.iter()
.filter(|d| d.message.contains("Font 'Aptos' is approximate"))
.count(),
1
);
assert_eq!(
pdf,
document
.to_pdf_with_fonts_and_options(&fonts, options)
.unwrap()
);
super::LAYOUT_INVOCATIONS.set(0);
let (png, png_facts) = document
.render_page_to_png_with_diagnostics(0, 72.0, &fonts, options)
.unwrap();
assert_eq!(super::LAYOUT_INVOCATIONS.get(), 1);
assert_eq!(png_facts, facts);
assert_eq!(
png,
document
.render_page_to_png_with_fonts_and_options(0, 72.0, &fonts, options)
.unwrap()
);
assert!(png.unwrap().starts_with(b"\x89PNG"));
let raster_options = oxml_pdf::RasterOptions {
dpi: 72.0,
format: oxml_pdf::RasterFormat::Png {
transparent_background: false,
},
};
super::LAYOUT_INVOCATIONS.set(0);
let (all, all_facts) = document
.render_pages_with_diagnostics(None, raster_options, &fonts, options)
.unwrap();
assert_eq!(super::LAYOUT_INVOCATIONS.get(), 1);
assert_eq!(all_facts, facts);
let oxml_pdf::RasterOutput::SeparatePages(all) = all else {
panic!("PNG must retain the separate-page payload");
};
assert_eq!(all.len(), 1);
let empty_error = document
.render_pages_with_diagnostics(Some(&[]), raster_options, &fonts, options)
.unwrap_err();
assert!(matches!(
empty_error,
crate::Error::Raster(oxml_pdf::RasterError::EmptyPageSelection)
));
assert_eq!(document.to_bytes().unwrap(), before);
let (tiff, _) = document
.render_pages_with_diagnostics(
None,
oxml_pdf::RasterOptions {
dpi: 72.0,
format: oxml_pdf::RasterFormat::Tiff,
},
&fonts,
options,
)
.unwrap();
assert!(matches!(tiff, oxml_pdf::RasterOutput::MultiPageTiff(_)));
super::LAYOUT_INVOCATIONS.set(0);
let (archival, archival_facts) = document
.to_pdfa_with_diagnostics(oxml_pdf::PdfConformance::PdfA2b, &fonts, options)
.unwrap();
assert_eq!(super::LAYOUT_INVOCATIONS.get(), 1);
assert_eq!(archival_facts, facts);
assert_eq!(
archival,
document
.to_pdfa_with_fonts_deterministic(&fonts, oxml_pdf::PdfConformance::PdfA2b)
.unwrap()
);
let svg = document
.render_page_to_svg_with_fonts_and_options(0, &fonts, options)
.unwrap()
.unwrap();
assert!(
svg.diagnostics
.iter()
.any(|d| d.message.contains("Font 'Aptos' is approximate"))
);
assert_eq!(document.to_bytes().unwrap(), before);
}
#[test]
fn fx200_complete_style_snapshot_clears_borders_and_tabs_without_losing_other_properties() {
let mut document = Document::new();
let properties = CT_PPr {
keep_next: Some(true),
tabs: Some(rdocx_oxml::borders::CT_Tabs {
tabs: vec![rdocx_oxml::borders::CT_TabStop {
val: rdocx_oxml::shared::ST_TabJc::Right,
pos: Twips(720),
leader: None,
source_occurrence: None,
}],
}),
borders: Some(Box::new(rdocx_oxml::borders::CT_PBdr {
top: Some(rdocx_oxml::borders::CT_BorderEdge::new(
rdocx_oxml::shared::ST_Border::Single,
)),
..Default::default()
})),
..Default::default()
};
document
.add_style(StyleBuilder::paragraph("Fx200", "Fx200").paragraph_properties(properties))
.unwrap();
let mut cleared = document
.style("Fx200")
.unwrap()
.paragraph_properties()
.unwrap()
.clone();
cleared.tabs = None;
cleared.refresh_tabs_source(None).unwrap();
cleared.borders = None;
document
.set_style(
StyleBuilder::paragraph("Fx200", "Fx200")
.replace_paragraph_properties(cleared.clone()),
)
.unwrap();
assert_eq!(
document.style("Fx200").unwrap().paragraph_properties(),
Some(&cleared)
);
assert_eq!(cleared.keep_next, Some(true));
assert!(cleared.tabs.is_none() && cleared.borders.is_none());
let bytes = document.to_bytes().unwrap();
let reopened = Document::from_bytes(&bytes).unwrap();
let reopened_style = reopened.style("Fx200").unwrap();
let properties = reopened_style.paragraph_properties().unwrap();
assert!(properties.tabs.is_none() && properties.borders.is_none());
assert_eq!(properties.keep_next, Some(true));
}
#[test]
fn fx200_document_default_updates_are_checked_and_do_not_restyle_normal() {
let mut document = Document::new();
document.add_paragraph("retained");
let styles = document.styles.styles.clone();
// Compare imported metadata with an unchanged import of the same authored seed.
let unchanged_bytes = document.to_bytes().unwrap();
let mut unchanged = Document::from_bytes(&unchanged_bytes).unwrap();
let imported_styles = unchanged.styles.styles.clone();
let unchanged_reopened = Document::from_bytes(&unchanged.to_bytes().unwrap()).unwrap();
assert_eq!(unchanged_reopened.styles.styles, imported_styles);
document
.set_default_font_name_value(Some("Gelasio"))
.unwrap();
document
.set_default_font_size_value(Some(Length::pt(13.0)))
.unwrap();
assert_eq!(document.default_font_name(), Some("Gelasio"));
assert_eq!(document.default_font_size(), Some(Length::pt(13.0)));
let rpr = document
.styles
.doc_defaults
.as_ref()
.unwrap()
.rpr
.as_ref()
.unwrap();
assert_eq!(rpr.font_ascii, rpr.font_hansi);
assert_eq!(rpr.font_ascii, rpr.font_east_asia);
assert_eq!(rpr.font_ascii, rpr.font_cs);
assert!(rpr.font_ascii_theme.is_none() && rpr.font_cs_theme.is_none());
assert_eq!(rpr.sz, rpr.sz_cs);
assert_eq!(document.styles.styles, styles);
assert_eq!(document.paragraph(0).unwrap().text(), "retained");
let before = document.to_bytes().unwrap();
assert!(
document
.set_default_font_size_value(Some(Length::emu(-1)))
.is_err()
);
assert_eq!(document.to_bytes().unwrap(), before);
let mut reopened = Document::from_bytes(&before).unwrap();
assert_eq!(reopened.default_font_name(), Some("Gelasio"));
assert_eq!(reopened.default_font_size(), Some(Length::pt(13.0)));
assert_eq!(reopened.styles.styles, imported_styles);
reopened.set_default_font_name_value(None).unwrap();
reopened.set_default_font_size_value(None).unwrap();
assert!(reopened.default_font_name().is_none() && reopened.default_font_size().is_none());
assert_eq!(reopened.styles.styles, imported_styles);
let cleared_reopened = Document::from_bytes(&reopened.to_bytes().unwrap()).unwrap();
assert_eq!(cleared_reopened.styles.styles, imported_styles);
assert!(
cleared_reopened.default_font_name().is_none()
&& cleared_reopened.default_font_size().is_none()
);
}
#[test]
fn fx200_absent_default_getters_and_clears_do_not_materialize_defaults() {
let mut document = Document::new();
document.styles.doc_defaults = None;
let before = document.to_bytes().unwrap();
assert!(document.default_font_name().is_none() && document.default_font_size().is_none());
document.set_default_font_name_value(None).unwrap();
document.set_default_font_size_value(None).unwrap();
assert!(document.styles.doc_defaults.is_none());
assert_eq!(document.to_bytes().unwrap(), before);
}
}
/// The accepted-view text of one story that the body view leaves out.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoryText {
/// The story the items belong to.
pub story: StoryId,
/// Each direct paragraph and block content control of the story.
pub items: Vec<StoryTextItem>,
}
/// One direct paragraph or block content control of a [`StoryText`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StoryTextItem {
/// The index path of the item inside its story.
pub index_path: Vec<usize>,
/// [`StoryItemKind::Paragraph`] or [`StoryItemKind::ContentControl`].
pub kind: StoryItemKind,
/// The accepted-view text of the item.
pub text: String,
}
/// A diagnostic accompanying an owned accepted text projection.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextDiagnostic {
pub message: String,
}
/// The CLI-compatible text presentation and its explicit source scope.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextReport {
pub text: String,
pub scope: &'static str,
pub revision_view: &'static str,
pub diagnostics: Vec<TextDiagnostic>,
}
const STORY_TEXT_RELATIONSHIPS: [&str; 5] = [
rel_types::HEADER,
rel_types::FOOTER,
rel_types::FOOTNOTES,
rel_types::ENDNOTES,
rel_types::COMMENTS,
];
impl Document {
/// Read every story but the main body and its table cells, which the body
/// view already covers, in [`Document::stories`] order.
///
/// Only the direct items of a story are read, so the text of an inline
/// control or a field is not repeated after its paragraph. A table is left
/// out because each of its cells is a table-cell story of its own.
///
/// A story part that cannot be read, such as a truncated header or a
/// header relationship to a missing part, returns the reason, naming the
/// first part that is not well-formed XML when there is one. `validate`
/// reports the same part as an error.
pub fn other_story_texts(&self) -> std::result::Result<Vec<StoryText>, String> {
self.read_other_story_texts().map_err(|error| {
self.malformed_story_part()
.unwrap_or_else(|| error.to_string())
})
}
fn read_other_story_texts(&self) -> Result<Vec<StoryText>> {
let mut main_part = None;
let mut stories: Vec<StoryText> = Vec::new();
for item in self.story_item_snapshots()? {
let story = item.location().story();
if story.kind() == StoryKind::Body {
main_part = Some(story.part_name().to_owned());
continue;
}
if story.kind() == StoryKind::TableCell
&& main_part.as_deref() == Some(story.part_name())
{
continue;
}
if stories.last().is_none_or(|last| &last.story != story) {
stories.push(StoryText {
story: story.clone(),
items: Vec::new(),
});
}
let kind = item.location().item_kind();
if !matches!(
kind,
StoryItemKind::Paragraph | StoryItemKind::ContentControl
) {
continue;
}
if item.is_direct_child()
&& let Some(last) = stories.last_mut()
{
last.items.push(StoryTextItem {
index_path: item.location().index_path().to_vec(),
kind,
text: item.text().unwrap_or_default().to_owned(),
});
}
}
Ok(stories)
}
/// Name the first story part of the main document that is not
/// well-formed XML, with the reason.
fn malformed_story_part(&self) -> Option<String> {
let package = &self.package;
let doc_part = package.main_document_part()?;
let rels = package.get_part_rels(&doc_part)?;
rels.items
.iter()
.filter(|rel| {
rel.target_mode.as_deref() != Some("External")
&& STORY_TEXT_RELATIONSHIPS.contains(&rel.rel_type.as_str())
})
.find_map(|rel| {
let part_name = OpcPackage::resolve_rel_target(&doc_part, &rel.target);
let detail = document_style_references(package.get_part(&part_name)?).err()?;
Some(format!("part {part_name} is not well-formed XML: {detail}"))
})
}
/// Present accepted body text followed by readable other stories.
/// If any other story is malformed, return main-only text and the CLI warning.
pub fn text_report(&self) -> TextReport {
let mut text = self.text();
let mut diagnostics = Vec::new();
let (stories, scope) = match self.other_story_texts() {
Ok(stories) => (stories, "all-supported-stories"),
Err(reason) => {
diagnostics.push(TextDiagnostic {
message: format!("other stories left out: {reason}"),
});
(Vec::new(), "main")
}
};
for (kind, part_name, texts) in document_story_parts(&stories) {
text.push_str(&format!(
"--- {} ({part_name}) ---\n",
document_story_kind_name(kind)
));
for line in texts {
text.push_str(line);
text.push('\n');
}
}
TextReport {
text,
scope,
revision_view: "accepted",
diagnostics,
}
}
/// Count Unicode scalars in the same accepted body paragraphs as word_count.
/// No presentation separators or story labels contribute characters.
pub fn character_count(&self, include_spaces: bool) -> usize {
let mut count = 0;
visit_body_paragraphs(&self.document.body.content, true, &mut |paragraph| {
count += paragraph
.text()
.chars()
.filter(|character| include_spaces || !character.is_whitespace())
.count();
});
count
}
}
/// Group the texts of the stories by package part, each part labelled with
/// the kind of its first story, such as a header part holding table cells.
/// A part without any text, such as an empty header variant, is left out.
fn document_story_parts(stories: &[StoryText]) -> Vec<(StoryKind, &str, Vec<&str>)> {
let mut parts: Vec<(StoryKind, &str, Vec<&str>)> = Vec::new();
for story in stories {
let part_name = story.story.part_name();
if parts.last().is_none_or(|(_, last, _)| *last != part_name) {
parts.push((story.story.kind(), part_name, Vec::new()));
}
if let Some((_, _, texts)) = parts.last_mut() {
texts.extend(story.items.iter().map(|item| item.text.as_str()));
}
}
parts.retain(|(_, _, texts)| texts.iter().any(|text| !text.trim().is_empty()));
parts
}
fn document_story_kind_name(kind: StoryKind) -> &'static str {
match kind {
StoryKind::Body => "body",
StoryKind::TableCell => "table_cell",
StoryKind::Header => "header",
StoryKind::Footer => "footer",
StoryKind::Footnote => "footnote",
StoryKind::Endnote => "endnote",
StoryKind::Comment => "comment",
StoryKind::TextBox => "text_box",
}
}
// Selected MC branches and preserved property-change subtrees determine the
// exact style-reference stream. Capabilities share the embedded-content authority.
#[derive(Default)]
struct StyleReferenceScope {
excluded: bool,
alternate: Option<(bool, bool, bool)>, // saw choice, selected branch, saw fallback
}
fn document_choice_supported(
reader: &NsReader<&[u8]>,
element: &BytesStart<'_>,
) -> std::result::Result<bool, String> {
let mut requires = None;
for attribute in element.attributes() {
let attribute = attribute.map_err(|e| e.to_string())?;
let (namespace, local) = reader.resolver().resolve_attribute(attribute.key);
if matches!(namespace, ResolveResult::Unbound) && local.as_ref() == b"Requires" {
if requires.is_some() {
return Err("MC Choice has duplicate Requires".into());
}
requires = Some(
attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|e| e.to_string())?
.into_owned(),
);
}
}
Ok(requires.is_some_and(|requires| {
let mut prefixes = requires.split_ascii_whitespace();
prefixes.clone().next().is_some()
&& prefixes
.all(|prefix| crate::embedded::must_understand_prefix_is_valid(reader, prefix))
}))
}
fn document_style_references(
xml: &[u8],
) -> std::result::Result<Vec<(&'static str, String)>, String> {
let mut reader = NsReader::from_reader(xml);
let mut open: Vec<StyleReferenceScope> = Vec::new();
let mut roots = 0usize;
let mut references = Vec::new();
let mut buffer = Vec::new();
loop {
let (namespace, event) = match reader.read_resolved_event_into(&mut buffer) {
Ok(read) => read,
Err(error) => return Err(format!("{error} at byte {}", reader.error_position())),
};
let is_word =
matches!(namespace, ResolveResult::Bound(Namespace(uri)) if uri == W_NS.as_bytes());
let is_compatibility =
matches!(namespace, ResolveResult::Bound(Namespace(uri)) if uri == MC_NS.as_bytes());
match event {
Event::Start(ref element) | Event::Empty(ref element) => {
if open.is_empty() {
roots += 1;
if roots > 1 {
return Err("the part has more than one root element".into());
}
}
let local = element.local_name();
let mut excluded = open.last().is_some_and(|scope| scope.excluded)
|| (is_word && local.as_ref().ends_with(b"PrChange"));
if let Some(state) = open.last_mut().and_then(|scope| scope.alternate.as_mut()) {
if is_compatibility && local.as_ref() == b"Choice" {
if state.2 {
return Err("MC Choice follows Fallback".into());
}
state.0 = true;
let selected = !state.1 && document_choice_supported(&reader, element)?;
state.1 |= selected;
excluded |= !selected;
} else if is_compatibility && local.as_ref() == b"Fallback" {
if !state.0 || state.2 {
return Err(
"MC Fallback has no preceding Choice or is duplicated".into()
);
}
state.2 = true;
excluded |= state.1;
state.1 = true;
} else {
// A foreign lookalike is opaque, never an admitted branch.
excluded = true;
}
} else if is_compatibility && matches!(local.as_ref(), b"Choice" | b"Fallback") {
excluded = true;
}
let kind = match local.as_ref() {
b"pStyle" => Some("paragraph"),
b"rStyle" => Some("character"),
b"tblStyle" => Some("table"),
_ => None,
};
if let Some(kind) = kind.filter(|_| is_word && !excluded)
&& let Some(style_id) = document_word_value(&reader, element)?
&& !style_id.is_empty()
{
references.push((kind, style_id));
}
if matches!(event, Event::Start(_)) {
open.push(StyleReferenceScope {
excluded,
alternate: (is_compatibility && local.as_ref() == b"AlternateContent")
.then_some((false, false, false)),
});
}
}
Event::End(_) => {
open.pop();
}
Event::Text(text) if open.is_empty() && !text.iter().all(u8::is_ascii_whitespace) => {
return Err("the part has text outside its root element".into());
}
Event::CData(_) if open.is_empty() => {
return Err("the part has CDATA outside its root element".into());
}
Event::Eof if roots == 0 => return Err("the part has no root element".into()),
Event::Eof if !open.is_empty() => {
return Err(format!(
"the part ends inside {} unclosed element(s)",
open.len()
));
}
Event::Eof => return Ok(references),
_ => {}
}
buffer.clear();
}
}
/// Return the `w:val` attribute of a WordprocessingML element.
fn document_word_value(
reader: &NsReader<&[u8]>,
element: &BytesStart<'_>,
) -> std::result::Result<Option<String>, String> {
for attribute in element.attributes() {
let attribute = attribute.map_err(|error| error.to_string())?;
let (namespace, local_name) = reader.resolver().resolve_attribute(attribute.key);
if matches!(namespace, ResolveResult::Bound(Namespace(uri)) if uri == W_NS.as_bytes())
&& local_name.as_ref() == b"val"
{
let value = attribute
.decoded_and_normalized_value(XmlVersion::Implicit1_0, reader.decoder())
.map_err(|error| error.to_string())?;
return Ok(Some(value.into_owned()));
}
}
Ok(None)
}
/// Validate serialized document bytes without changing a live document.
/// Non-package input and unexplained document-open failures remain errors.
pub fn validate_document_bytes(bytes: &[u8]) -> Result<ValidationReport> {
validate_document_package(OpcPackage::from_reader(std::io::Cursor::new(bytes))?)
}
/// Validate a document package at a path with the same findings as byte validation.
pub fn validate_document_file(path: impl AsRef<Path>) -> Result<ValidationReport> {
validate_document_package(OpcPackage::open(path)?)
}
fn document_validation_report(errors: Vec<String>, warnings: Vec<String>) -> ValidationReport {
ValidationReport {
findings: errors
.into_iter()
.map(|message| ValidationFinding {
severity: ValidationSeverity::Error,
message,
})
.chain(warnings.into_iter().map(|message| ValidationFinding {
severity: ValidationSeverity::Warning,
message,
}))
.collect(),
}
}
impl Document {
/// Validate an owned serialized snapshot, retaining this document's source and handles.
pub fn validate(&self) -> Result<ValidationReport> {
let mut snapshot = self.clone_for_staging();
validate_document_bytes(&snapshot.to_bytes()?)
}
}
fn validate_document_package(package: OpcPackage) -> Result<ValidationReport> {
let mut errors: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
// --- Structural errors ---
// Every relationship must point at a part that is actually in the package.
if let Some(doc_part) = package.main_document_part() {
if package.get_part(&doc_part).is_none() {
errors.push(format!("main document part {doc_part} is missing"));
}
if let Some(rels) = package.get_part_rels(&doc_part) {
for rel in &rels.items {
// External targets live outside the package by definition.
if rel.target_mode.as_deref() == Some("External") {
continue;
}
let target = oxml_opc::OpcPackage::resolve_rel_target(&doc_part, &rel.target);
if package.get_part(&target).is_none() {
errors.push(format!(
"relationship {} points at missing part {target}",
rel.id
));
}
}
}
} else {
errors.push("package declares no main document relationship".to_string());
}
// Every part needs a declared content type.
for part_name in package.parts.keys() {
if package.content_types.content_type_for(part_name).is_none() {
errors.push(format!("part {part_name} has no declared content type"));
}
}
// Markup Compatibility requires every prefix that `mc:Ignorable` or
// `mc:MustUnderstand` lists to be declared, and a consumer may reject a
// part that breaks the rule. A part that does not parse as XML is
// outside this check.
let mut xml_parts = package
.parts
.iter()
.filter(|(part_name, _)| {
package
.content_types
.content_type_for(part_name)
.is_some_and(|content_type| content_type.ends_with("xml"))
})
.collect::<Vec<_>>();
xml_parts.sort();
for (part_name, xml) in xml_parts {
let Ok(findings) = rdocx_oxml::namespace::undeclared_compatibility_prefixes(xml) else {
continue;
};
for (attribute, prefix) in findings {
errors.push(format!(
"part {part_name} lists undeclared prefix `{prefix}` in mc:{attribute}"
));
}
}
// Every XML part the main document relates to must be well formed. The
// style ids that its story parts name are checked once the document opens.
let mut style_references = Vec::new();
if let Some(doc_part) = package.main_document_part() {
let mut parts = vec![(doc_part.clone(), true)];
for rel in package
.get_part_rels(&doc_part)
.iter()
.flat_map(|rels| &rels.items)
{
if rel.target_mode.as_deref() == Some("External") {
continue;
}
let target = oxml_opc::OpcPackage::resolve_rel_target(&doc_part, &rel.target);
let story = STORY_TEXT_RELATIONSHIPS.contains(&rel.rel_type.as_str());
if !parts.iter().any(|(known, _)| *known == target) {
parts.push((target, story));
}
}
for (part_name, story) in parts {
let is_xml = package
.content_types
.content_type_for(&part_name)
.is_some_and(|content_type| {
content_type.ends_with("+xml") || content_type.ends_with("/xml")
});
let Some(xml) = package.get_part(&part_name).filter(|_| is_xml) else {
continue;
};
match document_style_references(xml) {
Err(detail) => {
errors.push(format!("part {part_name} is not well-formed XML: {detail}"));
}
Ok(references) if story => style_references.extend(
references
.into_iter()
.map(|(kind, style_id)| (kind, style_id, part_name.clone())),
),
Ok(_) => {}
}
}
}
// A malformed part can keep the document from opening. The errors found
// so far explain why, so they are reported together with the failure.
let doc = match Document::from_package(package) {
Ok(doc) => doc,
Err(error) if !errors.is_empty() => {
errors.push(format!("the document does not open: {error}"));
return Ok(document_validation_report(errors, warnings));
}
Err(error) => return Err(error),
};
// Word falls back to the default style for a style id that no style
// defines, so the style that the document names is silently lost.
for (kind, style_id, part_name) in style_references {
let issue = format!("{kind} style {style_id:?} used in {part_name} is not defined");
if doc.style(&style_id).is_none() && !errors.contains(&issue) {
errors.push(issue);
}
}
if let Err(error) = doc.validate_comment_ownership() {
errors.push(format!("invalid comment ownership: {error}"));
}
// --- Advisory findings ---
if doc.content_count() == 0 {
warnings.push("Document has no content (no paragraphs or tables)".to_string());
}
match doc.paragraph_visibility() {
Ok(visibility) => {
let empty_count = visibility.iter().filter(|visible| !**visible).count();
if empty_count > 0 {
warnings.push(format!("{empty_count} empty paragraph(s) found"));
}
}
Err(error) if !errors.is_empty() => warnings.push(format!(
"Paragraph visibility could not be determined: {error}"
)),
Err(error) => return Err(error),
}
let mut prev_level: Option<u32> = None;
for (level, _) in doc.headings() {
if let Some(prev) = prev_level
&& level > prev + 1
{
warnings.push(format!(
"Heading level gap: Heading{prev} -> Heading{level} (skipped level(s))"
));
}
prev_level = Some(level);
}
if doc.title().is_none() {
warnings.push("Missing document title".to_string());
}
if doc.author().is_none() {
warnings.push("Missing document author".to_string());
}
for finding in doc.validate_sections().findings {
match finding.severity {
ValidationSeverity::Error => errors.push(finding.message),
ValidationSeverity::Warning => warnings.push(finding.message),
}
}
Ok(document_validation_report(errors, warnings))
}
#[cfg(test)]
#[test]
fn fx204_checked_regex_late_note_failure_preserves_native_source() {
let mut document = Document::new();
document.add_paragraph("before");
document
.package
.set_part("/word/endnotes.xml", b"<w:endnotes".to_vec());
document.package.content_types.add_override(
"/word/endnotes.xml",
"application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
);
document
.package
.get_or_create_part_rels(&document.doc_part_name.clone())
.add(rel_types::ENDNOTES, "endnotes.xml");
let before = document.document.to_xml().unwrap();
let package = document.package.parts.clone();
assert!(
document
.try_replace_all_regex_expected(&[("before", "after", Some(1))])
.is_err()
);
assert_eq!(document.document.to_xml().unwrap(), before);
assert_eq!(document.package.parts, package);
}
#[cfg(test)]
#[test]
fn fx204_checked_regex_counts_and_mismatch_are_atomic() {
let mut document = Document::new();
document.add_paragraph("a12 a34");
let original = document.document.to_xml().unwrap();
assert_eq!(
document
.try_replace_all_regex_expected(&[("a([0-9]+)", "b$1", Some(2)), ("b", "c", Some(1))])
.unwrap()
.unwrap_err()
.index,
1
);
assert_eq!(document.document.to_xml().unwrap(), original);
assert_eq!(
document
.try_replace_all_regex_expected(&[("a([0-9]+)", "b$1", Some(2)), ("b", "c", Some(2))])
.unwrap()
.unwrap(),
vec![2, 2]
);
assert_eq!(document.paragraph(0).unwrap().text(), "c12 c34");
let original = document.document.to_xml().unwrap();
assert!(
document
.try_replace_all_regex_expected(&[("c", "a", None), ("[", "x", None)])
.is_err()
);
assert_eq!(document.document.to_xml().unwrap(), original);
assert_eq!(
document
.try_replace_all_regex_expected(&[("", "x", Some(0))])
.unwrap()
.unwrap(),
vec![0]
);
}