Skip to main content

rust_docx/doc/
build.rs

1//! Document builder: walks the CP space of each subdocument (main text,
2//! headers/footers, footnotes, endnotes), reconstructs paragraphs, runs,
3//! fields and tables from the piece table + FKP formatting layers, and
4//! synthesizes a complete in-memory OOXML package.
5
6use std::collections::{HashMap, HashSet};
7
8use crate::package::{OoxmlPackage, OoxmlPart};
9
10use super::chp::Chp;
11use super::fib::fcidx;
12use super::fkp;
13use super::fmt::{escape_xml, escape_xml_into};
14use super::fonts::FontTable;
15use super::images::{extract_image, InlineImage};
16use super::lists::ListTables;
17use super::pap::Pap;
18use super::sep::Sep;
19use super::stsh::StyleSheet;
20use super::tap::Tap;
21use super::textboxes::{parse_text_boxes, TextBoxSpec, TextBoxStory};
22use super::DocFile;
23
24const W_NS: &str = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
25const R_NS: &str = "http://schemas.openxmlformats.org/officeDocument/2006/relationships";
26const WP_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawing";
27const A_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/main";
28const PIC_NS: &str = "http://schemas.openxmlformats.org/drawingml/2006/picture";
29
30const REL_TYPE_HEADER: &str =
31    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/header";
32const REL_TYPE_FOOTER: &str =
33    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer";
34const REL_TYPE_IMAGE: &str =
35    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/image";
36const REL_TYPE_STYLES: &str =
37    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles";
38const REL_TYPE_NUMBERING: &str =
39    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/numbering";
40const REL_TYPE_SETTINGS: &str =
41    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings";
42const REL_TYPE_FOOTNOTES: &str =
43    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes";
44const REL_TYPE_ENDNOTES: &str =
45    "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes";
46
47#[derive(Default)]
48struct RelSet {
49    rels: Vec<(String, String, String)>, // (id, type, target)
50}
51
52impl RelSet {
53    fn add(&mut self, rel_type: &str, target: &str) -> String {
54        let id = format!("rId{}", self.rels.len() + 1);
55        self.rels
56            .push((id.clone(), rel_type.to_string(), target.to_string()));
57        id
58    }
59
60    fn to_xml(&self) -> String {
61        let mut xml = String::from(
62            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">"#,
63        );
64        for (id, rel_type, target) in &self.rels {
65            xml.push_str(&format!(
66                r#"<Relationship Id="{id}" Type="{rel_type}" Target="{}"/>"#,
67                escape_xml(target)
68            ));
69        }
70        xml.push_str("</Relationships>");
71        xml
72    }
73}
74
75#[derive(Debug, Clone)]
76struct CharRun {
77    cp_start: u32,
78    cp_end: u32,
79    grpprl: Vec<u8>,
80}
81
82/// One reconstructed paragraph, ready for table grouping and emission.
83struct ParaOut {
84    pap: Pap,
85    runs_xml: String,
86    /// rPr of the paragraph mark itself — governs the line height of empty
87    /// paragraphs.
88    mark_rpr: String,
89    mark_char: u16,
90    effective_itap: u32,
91    is_row_end_level: u32, // 0 = not a row end; otherwise the table depth it terminates
92    cp_end: u32,
93    sect_index: Option<usize>, // set when this paragraph ends section N (not the last)
94}
95
96/// Walk-state shared across runs of one story (fields span runs and
97/// paragraphs).
98#[derive(Default, Clone, Copy, PartialEq)]
99enum FieldState {
100    #[default]
101    None,
102    Instruction,
103    ReplacedInstruction,
104    Result,
105}
106
107enum NoteContext {
108    /// Main document: 0x02 resolves through the reference PLCs.
109    Body,
110    /// Inside a footnote/endnote: 0x02 is the note's own number mark.
111    Footnote,
112    Endnote,
113}
114
115pub struct DocBuilder<'a> {
116    doc: &'a DocFile,
117    styles: StyleSheet,
118    fonts: FontTable,
119    lists: ListTables,
120    char_runs: Vec<CharRun>,
121    char_run_max_ends: Vec<u32>,
122    papx_bins: Vec<(u32, u32, u32)>,
123    papx_pages: HashMap<u32, Vec<fkp::PapxRun>>,
124    sections: Vec<(u32, Sep)>, // (exclusive cp end, properties)
125    section_ends: HashSet<u32>,
126    footnote_refs: Vec<u32>,
127    endnote_refs: Vec<u32>,
128    footnote_ranges: Vec<(u32, u32)>,
129    endnote_ranges: Vec<(u32, u32)>,
130    comments: Vec<super::comments::Comment>,
131    revision_authors: Vec<String>,
132    field_instructions: HashMap<u32, String>,
133    phonetic_results: HashMap<u32, String>,
134    spanning_links: Vec<super::fields::LinkRange>,
135    bookmarks: std::collections::BTreeMap<u32, String>,
136    text_boxes_by_anchor: HashMap<u32, Vec<TextBoxSpec>>,
137    images_by_anchor: HashMap<u32, Vec<super::images::FloatingImage>>,
138    warnings: Vec<String>,
139    f_facing_pages: bool,
140    default_tab: u16,
141    // Outputs accumulated during emission.
142    parts: HashMap<String, String>,
143    media: HashMap<String, Vec<u8>>,
144    media_extensions: HashSet<&'static str>,
145    image_count: usize,
146    header_count: usize,
147    footer_count: usize,
148}
149
150pub fn parse_doc(bytes: &[u8]) -> Result<OoxmlPackage, String> {
151    let doc = DocFile::open(bytes)?;
152    let mut builder = DocBuilder::new(&doc);
153    builder.build()
154}
155
156impl<'a> DocBuilder<'a> {
157    fn new(doc: &'a DocFile) -> DocBuilder<'a> {
158        let styles = doc
159            .fib
160            .table_slice(&doc.table, fcidx::STSHF)
161            .map(StyleSheet::parse)
162            .unwrap_or_else(StyleSheet::empty);
163        let fonts = doc
164            .fib
165            .table_slice(&doc.table, fcidx::STTBF_FFN)
166            .map(FontTable::parse)
167            .unwrap_or_else(FontTable::empty);
168        let lists = ListTables::parse(
169            doc.fib
170                .fc_lcb(fcidx::PLF_LST)
171                .filter(|(_, size)| *size != 0)
172                .and_then(|(offset, _)| doc.table.get(offset as usize..)),
173            doc.fib.table_slice(&doc.table, fcidx::PLF_LFO),
174        );
175
176        let field_text = super::fields::field_text(doc);
177        let mut builder = DocBuilder {
178            doc,
179            styles,
180            fonts,
181            lists,
182            char_runs: Vec::new(),
183            char_run_max_ends: Vec::new(),
184            papx_bins: Vec::new(),
185            papx_pages: HashMap::new(),
186            sections: Vec::new(),
187            section_ends: HashSet::new(),
188            footnote_refs: Vec::new(),
189            endnote_refs: Vec::new(),
190            footnote_ranges: Vec::new(),
191            endnote_ranges: Vec::new(),
192            comments: super::comments::parse(doc),
193            revision_authors: super::revisions::authors(
194                doc.fib.table_slice(&doc.table, fcidx::STTBF_RMARK).unwrap_or_default(),
195            ),
196            field_instructions: field_text.instructions,
197            phonetic_results: field_text.phonetic_results,
198            spanning_links: super::fields::spanning_links(doc),
199            bookmarks: super::fields::bookmarks(doc),
200            text_boxes_by_anchor: HashMap::new(),
201            images_by_anchor: HashMap::new(),
202            warnings: Vec::new(),
203            f_facing_pages: false,
204            default_tab: 720,
205            parts: HashMap::new(),
206            media: HashMap::new(),
207            media_extensions: HashSet::new(),
208            image_count: 0,
209            header_count: 0,
210            footer_count: 0,
211        };
212        builder.load_char_runs();
213        builder.load_papx_bins();
214        builder.load_sections();
215        builder.load_notes();
216        builder.load_dop();
217        builder.load_text_boxes();
218        for image in super::images::floating_images(doc) {
219            builder
220                .images_by_anchor
221                .entry(image.anchor.anchor_cp)
222                .or_default()
223                .push(image);
224        }
225        builder
226    }
227
228    // ----- table/property loading -----
229
230    fn load_char_runs(&mut self) {
231        let Some(plc) = self
232            .doc
233            .fib
234            .table_slice(&self.doc.table, fcidx::PLCF_BTE_CHPX)
235        else {
236            return;
237        };
238        let bins = fkp::parse_bin_table(plc);
239        let mut fkp_cache: HashMap<u32, Vec<fkp::ChpxRun>> = HashMap::new();
240
241        for piece in &self.doc.pieces.pieces {
242            let char_width = if piece.compressed { 1u32 } else { 2u32 };
243            let piece_fc_start = piece.fc;
244            let piece_fc_end = piece.fc + (piece.cp_end - piece.cp_start) * char_width;
245            let prm_grpprl = self.prm_grpprl(piece.prm);
246
247            for (bin_fc_start, bin_fc_end, page) in &bins {
248                if *bin_fc_end <= piece_fc_start || *bin_fc_start >= piece_fc_end {
249                    continue;
250                }
251                let runs = fkp_cache
252                    .entry(*page)
253                    .or_insert_with(|| fkp::parse_chpx_fkp(&self.doc.word, *page));
254                for run in runs.iter() {
255                    let fc_start = run.fc_start.max(piece_fc_start);
256                    let fc_end = run.fc_end.min(piece_fc_end);
257                    if fc_start >= fc_end {
258                        continue;
259                    }
260                    let cp_start = piece.cp_start + (fc_start - piece_fc_start) / char_width;
261                    let cp_end = piece.cp_start + (fc_end - piece_fc_start).div_ceil(char_width);
262                    let mut grpprl = run.grpprl.clone();
263                    if let Some(extra) = &prm_grpprl {
264                        grpprl.extend_from_slice(extra);
265                    }
266                    self.char_runs.push(CharRun {
267                        cp_start,
268                        cp_end,
269                        grpprl,
270                    });
271                }
272            }
273        }
274        self.char_runs.sort_by_key(|run| run.cp_start);
275        self.char_run_max_ends = self
276            .char_runs
277            .iter()
278            .scan(0, |end, run| {
279                *end = (*end).max(run.cp_end);
280                Some(*end)
281            })
282            .collect();
283    }
284
285    fn char_run_range(&self, start: u32, end: u32) -> std::ops::Range<usize> {
286        let first = self
287            .char_run_max_ends
288            .partition_point(|max_end| *max_end <= start);
289        let last = self.char_runs.partition_point(|run| run.cp_start < end);
290        first.min(last)..last
291    }
292
293    fn prm_grpprl(&self, prm: u16) -> Option<Vec<u8>> {
294        if prm == 0 {
295            return None;
296        }
297        if prm & 1 == 1 {
298            let index = (prm >> 1) as usize;
299            return self.doc.pieces.grpprls.get(index).cloned();
300        }
301        let opcode: u16 = match (prm >> 1) & 0x7F {
302            0x05 => 0x2461,
303            0x07 => 0x2405,
304            0x08 => 0x2406,
305            0x09 => 0x2407,
306            0x0C => 0x260A,
307            0x18 => 0x2416,
308            0x19 => 0x2417,
309            0x33 => 0x2431,
310            0x41 => 0x0800,
311            0x42 => 0x0801,
312            0x43 => 0x0802,
313            0x47 => 0x0806,
314            0x4B => 0x080A,
315            0x4D => 0x2A0C,
316            0x4E => 0x0858,
317            0x55..=0x5C => 0x0835 + ((prm >> 1) & 0x7F) - 0x55,
318            0x5E => 0x2A3E,
319            0x62 => 0x2A42,
320            0x68 => 0x2A48,
321            0x73 => 0x2A53,
322            0x74 => 0x0854,
323            0x75 => 0x0855,
324            0x76 => 0x0856,
325            0x78 => 0x2640,
326            _ => return None,
327        };
328        let [low, high] = opcode.to_le_bytes();
329        Some(vec![low, high, (prm >> 8) as u8])
330    }
331
332    fn load_papx_bins(&mut self) {
333        if let Some(plc) = self
334            .doc
335            .fib
336            .table_slice(&self.doc.table, fcidx::PLCF_BTE_PAPX)
337        {
338            self.papx_bins = fkp::parse_bin_table(plc);
339        }
340    }
341
342    fn papx_for_fc(&mut self, fc: u32) -> Option<fkp::PapxRun> {
343        let page = self
344            .papx_bins
345            .iter()
346            .find(|(start, end, _)| fc >= *start && fc < *end)
347            .map(|(_, _, page)| *page)?;
348        let runs = self
349            .papx_pages
350            .entry(page)
351            .or_insert_with(|| fkp::parse_papx_fkp(&self.doc.word, page));
352        runs.iter()
353            .find(|run| fc >= run.fc_start && fc < run.fc_end)
354            .cloned()
355    }
356
357    /// Expands sprmPHugePapx (0x6646): paragraph grpprls too large for an FKP
358    /// (typically wide table rows) live as a length-prefixed grpprl in the
359    /// Data stream.
360    fn resolve_huge_papx(&self, grpprl: &[u8]) -> Vec<u8> {
361        let Some(huge) = super::sprm::iterate(grpprl).find(|sprm| sprm.opcode == 0x6646) else {
362            return grpprl.to_vec();
363        };
364        let offset = huge.u32_operand() as usize;
365        let Some(cb) = self
366            .doc
367            .data
368            .get(offset..offset + 2)
369            .map(|b| u16::from_le_bytes([b[0], b[1]]) as usize)
370        else {
371            return grpprl.to_vec();
372        };
373        let Some(huge_grpprl) = self.doc.data.get(offset + 2..offset + 2 + cb) else {
374            return grpprl.to_vec();
375        };
376        let mut combined = grpprl.to_vec();
377        combined.extend_from_slice(huge_grpprl);
378        combined
379    }
380
381    fn load_sections(&mut self) {
382        let Some(plc) = self.doc.fib.table_slice(&self.doc.table, fcidx::PLCF_SED) else {
383            self.sections.push((self.doc.fib.ccp.text, Sep::default()));
384            return;
385        };
386        const SED_SIZE: usize = 12;
387        if plc.len() < 4 + SED_SIZE {
388            self.sections.push((self.doc.fib.ccp.text, Sep::default()));
389            return;
390        }
391        let count = (plc.len() - 4) / (4 + SED_SIZE);
392        let read_u32 = |offset: usize| {
393            plc.get(offset..offset + 4)
394                .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
395                .unwrap_or(0)
396        };
397        for index in 0..count {
398            let cp_end = read_u32((index + 1) * 4);
399            let sed_offset = (count + 1) * 4 + index * SED_SIZE;
400            let fc_sepx = read_u32(sed_offset + 2);
401            let mut sep = Sep::default();
402            if fc_sepx != 0xFFFF_FFFF {
403                let base = fc_sepx as usize;
404                if let Some(cb) = self
405                    .doc
406                    .word
407                    .get(base..base + 2)
408                    .map(|b| u16::from_le_bytes([b[0], b[1]]) as usize)
409                {
410                    if let Some(grpprl) = self.doc.word.get(base + 2..base + 2 + cb) {
411                        sep.apply_sepx(grpprl);
412                    }
413                }
414            }
415            self.sections.push((cp_end, sep));
416        }
417        for (cp_end, _) in &self.sections {
418            self.section_ends.insert(*cp_end);
419        }
420    }
421
422    fn load_notes(&mut self) {
423        // Reference PLCs: CPs in the main document plus 2 bytes of data each.
424        let parse_ref_plc = |plc: Option<&[u8]>, data_size: usize| -> Vec<u32> {
425            let Some(plc) = plc else { return Vec::new() };
426            if plc.len() < 4 {
427                return Vec::new();
428            }
429            let count = (plc.len() - 4) / (4 + data_size);
430            (0..count)
431                .filter_map(|index| {
432                    plc.get(index * 4..index * 4 + 4)
433                        .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
434                })
435                .collect()
436        };
437        // Text PLCs: CP-only, relative to the note subdocument.
438        let parse_txt_plc = |plc: Option<&[u8]>| -> Vec<u32> {
439            let Some(plc) = plc else { return Vec::new() };
440            plc.chunks_exact(4)
441                .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
442                .collect()
443        };
444
445        self.footnote_refs = parse_ref_plc(
446            self.doc
447                .fib
448                .table_slice(&self.doc.table, fcidx::PLCFFND_REF),
449            2,
450        );
451        self.endnote_refs = parse_ref_plc(
452            self.doc
453                .fib
454                .table_slice(&self.doc.table, fcidx::PLCFEND_REF),
455            2,
456        );
457
458        let ftn_base = self.doc.fib.ccp.ftn_start();
459        let ftn_cps = parse_txt_plc(
460            self.doc
461                .fib
462                .table_slice(&self.doc.table, fcidx::PLCFFND_TXT),
463        );
464        for window in ftn_cps.windows(2).take(self.footnote_refs.len()) {
465            self.footnote_ranges
466                .push((ftn_base + window[0], ftn_base + window[1]));
467        }
468        let edn_base = self.doc.fib.ccp.edn_start();
469        let edn_cps = parse_txt_plc(
470            self.doc
471                .fib
472                .table_slice(&self.doc.table, fcidx::PLCFEND_TXT),
473        );
474        for window in edn_cps.windows(2).take(self.endnote_refs.len()) {
475            self.endnote_ranges
476                .push((edn_base + window[0], edn_base + window[1]));
477        }
478    }
479
480    fn load_dop(&mut self) {
481        if let Some(dop) = self.doc.fib.table_slice(&self.doc.table, fcidx::DOP) {
482            if let Some(&flags) = dop.first() {
483                self.f_facing_pages = flags & 0x01 != 0;
484            }
485            if let Some(tab) = dop.get(8..10).map(|b| u16::from_le_bytes([b[0], b[1]])) {
486                if (36..=2880).contains(&tab) {
487                    self.default_tab = tab;
488                }
489            }
490        }
491    }
492
493    fn load_text_boxes(&mut self) {
494        for story in [TextBoxStory::Main, TextBoxStory::Header] {
495            let parsed = parse_text_boxes(self.doc, story);
496            self.warnings.extend(parsed.warnings);
497            for spec in parsed.specs {
498                self.text_boxes_by_anchor
499                    .entry(spec.anchor_cp)
500                    .or_default()
501                    .push(spec);
502            }
503        }
504        for specs in self.text_boxes_by_anchor.values_mut() {
505            specs.sort_by_key(|spec| spec.shape_id);
506        }
507    }
508
509    // ----- paragraph construction -----
510
511    /// Splits `[cp_start, cp_end)` into paragraphs at paragraph marks (0x0D),
512    /// cell/row marks (0x07) and section marks (0x0C at a section boundary).
513    fn build_paragraphs(
514        &mut self,
515        cp_start: u32,
516        cp_end: u32,
517        context: &NoteContext,
518    ) -> Vec<ParaOut> {
519        let text = self.doc.text(cp_start, cp_end);
520        let mut paragraphs = Vec::new();
521        let mut fields = Vec::new();
522        let mut para_start = cp_start;
523        for (index, &unit) in text.iter().enumerate() {
524            let cp = cp_start + index as u32;
525            let is_end = unit == 0x0D
526                || unit == 0x07
527                || (unit == 0x0C && self.section_ends.contains(&(cp + 1)));
528            if is_end {
529                paragraphs.push(self.build_paragraph(
530                    para_start,
531                    cp + 1,
532                    &text[(para_start - cp_start) as usize..=index],
533                    unit,
534                    context,
535                    &mut fields,
536                ));
537                para_start = cp + 1;
538            }
539        }
540        if para_start < cp_end {
541            paragraphs.push(self.build_paragraph(
542                para_start,
543                cp_end,
544                &text[(para_start - cp_start) as usize..],
545                0x0D,
546                context,
547                &mut fields,
548            ));
549        }
550        paragraphs
551    }
552
553    fn build_paragraph(
554        &mut self,
555        cp_start: u32,
556        cp_end: u32,
557        text: &[u16],
558        mark_char: u16,
559        context: &NoteContext,
560        fields: &mut Vec<FieldState>,
561    ) -> ParaOut {
562        // Paragraph properties live at the paragraph mark's FC.
563        let mark_cp = cp_end.saturating_sub(1);
564        let mut pap = Pap::default();
565        if let Some(fc) = self.doc.pieces.cp_to_fc(mark_cp) {
566            if let Some(papx) = self.papx_for_fc(fc) {
567                pap.istd = papx.istd;
568                pap.apply_papx(&self.resolve_huge_papx(&papx.grpprl));
569            }
570        }
571        if let Some(piece) = self.doc.pieces.piece_for(mark_cp) {
572            if let Some(extra) = self.prm_grpprl(piece.prm) {
573                pap.apply_papx(&extra);
574            }
575        }
576
577        let effective_itap = if pap.itap == 0 && pap.f_in_table {
578            1
579        } else {
580            pap.itap
581        };
582        let is_row_end_level = if pap.f_ttp {
583            1
584        } else if pap.f_inner_ttp {
585            effective_itap.max(2)
586        } else {
587            0
588        };
589
590        let mut runs_xml = self.build_runs(
591            cp_start,
592            mark_cp,
593            &text[..text.len().saturating_sub(1)],
594            pap.istd,
595            context,
596            fields,
597        );
598        if let Some(markers) = self.bookmarks.get(&mark_cp) {
599            runs_xml.push_str(markers);
600        }
601
602        // Paragraph-mark formatting (empty-paragraph strut height).
603        let mark_rpr = {
604            let grpprl = self.char_runs[self.char_run_range(mark_cp, cp_end)]
605                .iter()
606                .find(|run| mark_cp >= run.cp_start && mark_cp < run.cp_end)
607                .map(|run| run.grpprl.as_slice())
608                .unwrap_or_default();
609            let char_istd = super::sprm::iterate(&grpprl)
610                .find(|sprm| sprm.opcode == 0x4A30)
611                .map(|sprm| sprm.u16_operand());
612            let base_toggles = self.styles.run_toggle_base(pap.istd, char_istd);
613            let mut chp = Chp::default();
614            chp.apply_chpx(&grpprl, &base_toggles);
615            chp.to_rpr_xml(&|istd| self.styles.style_id(istd), &|ftc| {
616                self.fonts.name(ftc)
617            })
618        };
619
620        ParaOut {
621            pap,
622            runs_xml,
623            mark_rpr,
624            mark_char,
625            effective_itap,
626            is_row_end_level,
627            cp_end,
628            sect_index: None,
629        }
630    }
631
632    /// Emits the runs of `[cp_start, cp_end)` (paragraph content without its
633    /// mark), splitting at character-run boundaries and special characters.
634    fn build_runs(
635        &mut self,
636        cp_start: u32,
637        cp_end: u32,
638        text: &[u16],
639        para_istd: u16,
640        context: &NoteContext,
641        fields: &mut Vec<FieldState>,
642    ) -> String {
643        let mut xml = String::new();
644        if cp_start >= cp_end {
645            return xml;
646        }
647
648        // Collect runs intersecting the range; fall back to one unformatted run.
649        let range = self.char_run_range(cp_start, cp_end);
650        let mut intervals: Vec<(u32, u32, Option<usize>)> = self.char_runs[range.clone()]
651            .iter()
652            .enumerate()
653            .filter(|(_, run)| run.cp_end > cp_start && run.cp_start < cp_end)
654            .map(|(index, run)| {
655                (
656                    run.cp_start.max(cp_start),
657                    run.cp_end.min(cp_end),
658                    Some(range.start + index),
659                )
660            })
661            .collect();
662        if intervals.is_empty() {
663            intervals.push((cp_start, cp_end, None));
664        }
665        intervals.sort_by_key(|(start, _, _)| *start);
666        // Fill any gaps so every CP is covered.
667        let mut covered = cp_start;
668        let mut complete: Vec<(u32, u32, Option<usize>)> = Vec::new();
669        for (start, end, grpprl) in intervals {
670            if start > covered {
671                complete.push((covered, start, None));
672            }
673            let clamped_start = start.max(covered);
674            if clamped_start < end {
675                complete.push((clamped_start, end, grpprl));
676                covered = end;
677            }
678        }
679        if covered < cp_end {
680            complete.push((covered, cp_end, None));
681        }
682
683        let mut linked = Vec::new();
684        for (start, end, grpprl) in complete {
685            if self.spanning_links.is_empty() {
686                linked.push((start, end, grpprl, None));
687                continue;
688            }
689            let mut boundaries = vec![start, end];
690            for link in &self.spanning_links {
691                boundaries.extend(
692                    [link.start, link.end]
693                        .into_iter()
694                        .filter(|cp| *cp > start && *cp < end),
695                );
696            }
697            boundaries.sort_unstable();
698            boundaries.dedup();
699            for pair in boundaries.windows(2) {
700                let instruction = self
701                    .spanning_links
702                    .iter()
703                    .find(|link| pair[0] >= link.start && pair[1] <= link.end)
704                    .map(|link| link.instruction.clone());
705                linked.push((pair[0], pair[1], grpprl, instruction));
706            }
707        }
708        for (start, end, run_index, instruction) in linked {
709            let grpprl = run_index
710                .map(|index| self.char_runs[index].grpprl.as_slice())
711                .unwrap_or_default();
712            if let Some(instruction) = &instruction {
713                xml.push_str(&format!(
714                    r#"<w:fldSimple w:instr="{}">"#,
715                    escape_xml(instruction)
716                ));
717            }
718            let char_istd = super::sprm::iterate(&grpprl)
719                .find(|sprm| sprm.opcode == 0x4A30)
720                .map(|sprm| sprm.u16_operand());
721            let base_toggles = self.styles.run_toggle_base(para_istd, char_istd);
722            let mut chp = Chp::default();
723            chp.apply_chpx(&grpprl, &base_toggles);
724            let rpr = chp.to_rpr_xml(&|istd| self.styles.style_id(istd), &|ftc| {
725                self.fonts.name(ftc)
726            });
727            let (insertion, deletion) = super::revisions::marks(grpprl, chp.deleted);
728            if let Some(mark) = &insertion {
729                xml.push_str(&mark.open("ins", start, &self.revision_authors));
730            }
731            if let Some(mark) = deletion {
732                xml.push_str(&mark.open("del", start, &self.revision_authors));
733                let mut deleted = String::new();
734                let mut deleted_fields = fields.clone();
735                self.emit_run_content(
736                    &mut deleted,
737                    start,
738                    &text[(start - cp_start) as usize..(end - cp_start) as usize],
739                    &chp,
740                    &rpr,
741                    &mut deleted_fields,
742                    context,
743                );
744                let deleted = deleted
745                    .replace("<w:t ", "<w:delText ")
746                    .replace("</w:t>", "</w:delText>");
747                xml.push_str(&deleted);
748                xml.push_str("</w:del>");
749            } else {
750                self.emit_run_content(
751                    &mut xml,
752                    start,
753                    &text[(start - cp_start) as usize..(end - cp_start) as usize],
754                    &chp,
755                    &rpr,
756                    fields,
757                    context,
758                );
759            }
760            if insertion.is_some() {
761                xml.push_str("</w:ins>");
762            }
763            if instruction.is_some() {
764                xml.push_str("</w:fldSimple>");
765            }
766        }
767        xml
768    }
769
770    #[allow(clippy::too_many_arguments)]
771    fn emit_run_content(
772        &mut self,
773        xml: &mut String,
774        cp_start: u32,
775        text: &[u16],
776        chp: &Chp,
777        rpr: &str,
778        fields: &mut Vec<FieldState>,
779        context: &NoteContext,
780    ) {
781        let mut buffer = String::new();
782
783        let flush = |buffer: &mut String, xml: &mut String, state: FieldState, rpr: &str| {
784            if buffer.is_empty() {
785                return;
786            }
787            xml.push_str("<w:r>");
788            xml.push_str(rpr);
789            let instruction = state == FieldState::Instruction;
790            xml.push_str(if instruction {
791                r#"<w:instrText xml:space="preserve">"#
792            } else {
793                r#"<w:t xml:space="preserve">"#
794            });
795            escape_xml_into(buffer, xml);
796            xml.push_str(if instruction {
797                "</w:instrText></w:r>"
798            } else {
799                "</w:t></w:r>"
800            });
801            buffer.clear();
802        };
803        let single = |xml: &mut String, rpr: &str, inner: &str| {
804            xml.push_str("<w:r>");
805            xml.push_str(rpr);
806            xml.push_str(inner);
807            xml.push_str("</w:r>");
808        };
809
810        for (index, &unit) in text.iter().enumerate() {
811            let cp = cp_start + index as u32;
812            if let Some(markers) = self.bookmarks.get(&cp) {
813                flush(
814                    &mut buffer,
815                    xml,
816                    fields.last().copied().unwrap_or_default(),
817                    rpr,
818                );
819                xml.push_str(markers);
820            }
821            if fields.last() == Some(&FieldState::ReplacedInstruction)
822                && !matches!(unit, 0x13 | 0x14 | 0x15)
823            {
824                continue;
825            }
826            match unit {
827                0x05 => {
828                    flush(
829                        &mut buffer,
830                        xml,
831                        fields.last().copied().unwrap_or_default(),
832                        rpr,
833                    );
834                    if let Some(index) = self
835                        .comments
836                        .iter()
837                        .position(|comment| comment.reference_cp == cp)
838                    {
839                        single(
840                            xml,
841                            rpr,
842                            &format!(r#"<w:commentReference w:id="{index}"/>"#),
843                        );
844                    }
845                }
846                0x09 => {
847                    flush(
848                        &mut buffer,
849                        xml,
850                        fields.last().copied().unwrap_or_default(),
851                        rpr,
852                    );
853                    single(xml, rpr, "<w:tab/>");
854                }
855                0x0B => {
856                    flush(
857                        &mut buffer,
858                        xml,
859                        fields.last().copied().unwrap_or_default(),
860                        rpr,
861                    );
862                    single(xml, rpr, "<w:br/>");
863                }
864                0x0C => {
865                    flush(
866                        &mut buffer,
867                        xml,
868                        fields.last().copied().unwrap_or_default(),
869                        rpr,
870                    );
871                    single(xml, rpr, r#"<w:br w:type="page"/>"#);
872                }
873                0x0E => {
874                    flush(
875                        &mut buffer,
876                        xml,
877                        fields.last().copied().unwrap_or_default(),
878                        rpr,
879                    );
880                    single(xml, rpr, r#"<w:br w:type="column"/>"#);
881                }
882                0x08 => {
883                    flush(
884                        &mut buffer,
885                        xml,
886                        fields.last().copied().unwrap_or_default(),
887                        rpr,
888                    );
889                    if let Some(images) = self.images_by_anchor.remove(&cp) {
890                        for image in images {
891                            let drawing = self.register_floating_image(image);
892                            single(xml, rpr, &drawing);
893                        }
894                    }
895                    if let Some(specs) = self.text_boxes_by_anchor.remove(&cp) {
896                        for spec in specs {
897                            let shape = self.build_text_box_shape(&spec);
898                            single(xml, rpr, &shape);
899                        }
900                    }
901                }
902                0x13 => {
903                    flush(
904                        &mut buffer,
905                        xml,
906                        fields.last().copied().unwrap_or_default(),
907                        rpr,
908                    );
909                    single(xml, rpr, r#"<w:fldChar w:fldCharType="begin"/>"#);
910                    if let Some(instruction) = self.field_instructions.get(&cp) {
911                        single(
912                            xml,
913                            rpr,
914                            &format!(
915                                r#"<w:instrText xml:space="preserve">{}</w:instrText>"#,
916                                escape_xml(instruction)
917                            ),
918                        );
919                        fields.push(FieldState::ReplacedInstruction);
920                    } else {
921                        fields.push(FieldState::Instruction);
922                    }
923                }
924                0x14 => {
925                    flush(
926                        &mut buffer,
927                        xml,
928                        fields.last().copied().unwrap_or_default(),
929                        rpr,
930                    );
931                    single(xml, rpr, r#"<w:fldChar w:fldCharType="separate"/>"#);
932                    if let Some(state) = fields.last_mut() {
933                        *state = FieldState::Result;
934                    }
935                }
936                0x15 => {
937                    flush(
938                        &mut buffer,
939                        xml,
940                        fields.last().copied().unwrap_or_default(),
941                        rpr,
942                    );
943                    if fields.last() == Some(&FieldState::Instruction) {
944                        if let Some(text) = self.phonetic_results.get(&cp) {
945                            single(xml, rpr, r#"<w:fldChar w:fldCharType="separate"/>"#);
946                            single(
947                                xml,
948                                rpr,
949                                &format!(r#"<w:t xml:space="preserve">{}</w:t>"#, escape_xml(text)),
950                            );
951                        }
952                    }
953                    single(xml, rpr, r#"<w:fldChar w:fldCharType="end"/>"#);
954                    fields.pop();
955                }
956                0x1E => buffer.push('\u{2011}'),
957                0x1F => buffer.push('\u{00AD}'),
958                0x01 if chp.f_spec => {
959                    flush(
960                        &mut buffer,
961                        xml,
962                        fields.last().copied().unwrap_or_default(),
963                        rpr,
964                    );
965                    if !chp.f_obj && !chp.f_ole2 && !chp.f_data {
966                        if let Some(location) = chp.pic_location {
967                            if let Some(image) = extract_image(&self.doc.data, location) {
968                                let drawing = self.register_image(image);
969                                single(xml, rpr, &drawing);
970                            }
971                        }
972                    }
973                }
974                0x02 if chp.f_spec => {
975                    flush(
976                        &mut buffer,
977                        xml,
978                        fields.last().copied().unwrap_or_default(),
979                        rpr,
980                    );
981                    match context {
982                        NoteContext::Body => {
983                            if let Some(position) =
984                                self.footnote_refs.iter().position(|&ref_cp| ref_cp == cp)
985                            {
986                                single(
987                                    xml,
988                                    rpr,
989                                    &format!(r#"<w:footnoteReference w:id="{}"/>"#, position + 1),
990                                );
991                            } else if let Some(position) =
992                                self.endnote_refs.iter().position(|&ref_cp| ref_cp == cp)
993                            {
994                                single(
995                                    xml,
996                                    rpr,
997                                    &format!(r#"<w:endnoteReference w:id="{}"/>"#, position + 1),
998                                );
999                            }
1000                        }
1001                        NoteContext::Footnote => single(xml, rpr, "<w:footnoteRef/>"),
1002                        NoteContext::Endnote => single(xml, rpr, "<w:endnoteRef/>"),
1003                    }
1004                }
1005                0x28 if chp.f_spec => {
1006                    flush(
1007                        &mut buffer,
1008                        xml,
1009                        fields.last().copied().unwrap_or_default(),
1010                        rpr,
1011                    );
1012                    if let Some((ftc, xchar)) = chp.symbol {
1013                        let font = self.fonts.name(ftc).unwrap_or_else(|| "Symbol".to_string());
1014                        single(
1015                            xml,
1016                            rpr,
1017                            &format!(
1018                                r#"<w:sym w:font="{}" w:char="{xchar:04X}"/>"#,
1019                                escape_xml(&font)
1020                            ),
1021                        );
1022                    }
1023                }
1024                0x00..=0x1F => {} // other control chars: dropped
1025                0xD800..=0xDBFF
1026                    if text
1027                        .get(index + 1)
1028                        .is_some_and(|u| (0xDC00..=0xDFFF).contains(u)) =>
1029                {
1030                    buffer.push_str(&String::from_utf16_lossy(&text[index..index + 2]));
1031                }
1032                0xDC00..=0xDFFF if index > 0 && (0xD800..=0xDBFF).contains(&text[index - 1]) => {}
1033                _ => buffer.push_str(&String::from_utf16_lossy(&[unit])),
1034            }
1035        }
1036        flush(
1037            &mut buffer,
1038            xml,
1039            fields.last().copied().unwrap_or_default(),
1040            rpr,
1041        );
1042    }
1043
1044    fn build_text_box_shape(&mut self, spec: &TextBoxSpec) -> String {
1045        let paragraphs =
1046            self.build_paragraphs(spec.text_cp_start, spec.text_cp_end, &NoteContext::Body);
1047        let body = self.assemble_blocks(&paragraphs);
1048        let horizontal_relative = match spec.horizontal_relative {
1049            0 => "margin",
1050            1 => "page",
1051            2 => "column",
1052            _ => "page",
1053        };
1054        let vertical_relative = match spec.vertical_relative {
1055            0 => "margin",
1056            1 => "page",
1057            2 => "paragraph",
1058            _ => "page",
1059        };
1060        let wrap_type = match spec.wrap {
1061            0 | 2 => "square",
1062            1 => "topAndBottom",
1063            3 => "none",
1064            4 => "tight",
1065            5 => "through",
1066            _ => "square",
1067        };
1068        let wrap_side = match spec.wrap_side {
1069            1 => "left",
1070            2 => "right",
1071            3 => "largest",
1072            _ => "both",
1073        };
1074        let z_index = if spec.wrap == 3 && spec.behind_text {
1075            -1
1076        } else {
1077            0
1078        };
1079        let left = format_twips_as_points(i64::from(spec.left_twips));
1080        let top = format_twips_as_points(i64::from(spec.top_twips));
1081        let width = format_twips_as_points(i64::from(spec.width_twips));
1082        let height = format_twips_as_points(i64::from(spec.height_twips));
1083        format!(
1084            r##"<w:pict xmlns:v="urn:schemas-microsoft-com:vml" xmlns:w10="urn:schemas-microsoft-com:office:word"><v:shape id="_x0000_s{shape_id}" type="#_x0000_t202" filled="f" stroked="f" style="position:absolute;left:{left}pt;top:{top}pt;width:{width}pt;height:{height}pt;mso-position-horizontal:absolute;mso-position-horizontal-relative:{horizontal_relative};mso-position-vertical:absolute;mso-position-vertical-relative:{vertical_relative};mso-wrap-style:{wrap_type};z-index:{z_index}"><w10:wrap type="{wrap_type}" side="{wrap_side}"/><v:textbox inset="0pt,0pt,0pt,0pt"><w:txbxContent>{body}</w:txbxContent></v:textbox></v:shape></w:pict>"##,
1085            shape_id = spec.shape_id,
1086        )
1087    }
1088
1089    fn register_image(&mut self, image: InlineImage) -> String {
1090        self.image_count += 1;
1091        let index = self.image_count;
1092        let file_name = format!("media/image{index}.{}", image.extension);
1093        let part_name = format!("word/{file_name}");
1094        self.media.insert(part_name, image.bytes);
1095        self.media_extensions.insert(image.extension);
1096
1097        // The rel id is fixed up by the caller part's RelSet via placeholder
1098        // replacement when parts are finalized.
1099        let placeholder = format!("__IMG_REL_{index}__");
1100        format!(
1101            r#"<w:drawing><wp:inline distT="0" distB="0" distL="0" distR="0"><wp:extent cx="{cx}" cy="{cy}"/><wp:docPr id="{index}" name="Picture {index}"/><a:graphic xmlns:a="{A_NS}"><a:graphicData uri="{PIC_NS}"><pic:pic xmlns:pic="{PIC_NS}"><pic:nvPicPr><pic:cNvPr id="{index}" name="Picture {index}"/><pic:cNvPicPr/></pic:nvPicPr><pic:blipFill><a:blip r:embed="{placeholder}"/><a:stretch><a:fillRect/></a:stretch></pic:blipFill><pic:spPr><a:xfrm><a:off x="0" y="0"/><a:ext cx="{cx}" cy="{cy}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></pic:spPr></pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing>"#,
1102            cx = image.width_emu.max(1),
1103            cy = image.height_emu.max(1),
1104        )
1105    }
1106
1107    fn register_floating_image(&mut self, image: super::images::FloatingImage) -> String {
1108        let anchor = image.anchor;
1109        let mut drawing = self.register_image(image.image);
1110        if image.tiled {
1111            drawing = drawing.replace("<a:stretch><a:fillRect/></a:stretch>", "<a:tile/>");
1112        }
1113        let horizontal = match anchor.horizontal_relative {
1114            0 => "margin",
1115            1 => "page",
1116            2 => "column",
1117            _ => "character",
1118        };
1119        let vertical = match anchor.vertical_relative {
1120            0 => "margin",
1121            1 => "page",
1122            2 => "paragraph",
1123            _ => "line",
1124        };
1125        let position = format!(
1126            r#"<wp:anchor distT="0" distB="0" distL="0" distR="0" simplePos="0" relativeHeight="{}" behindDoc="{}" locked="0" layoutInCell="1" allowOverlap="1"><wp:simplePos x="0" y="0"/><wp:positionH relativeFrom="{horizontal}"><wp:posOffset>{}</wp:posOffset></wp:positionH><wp:positionV relativeFrom="{vertical}"><wp:posOffset>{}</wp:posOffset></wp:positionV>"#,
1127            anchor.shape_id,
1128            u8::from(anchor.behind_text),
1129            i64::from(anchor.left) * 635,
1130            i64::from(anchor.top) * 635
1131        );
1132        let side = match anchor.wrap_side {
1133            1 => "left",
1134            2 => "right",
1135            3 => "largest",
1136            _ => "bothSides",
1137        };
1138        let wrap = match anchor.wrap {
1139            1 => "<wp:wrapTopAndBottom/>".to_string(),
1140            3 => "<wp:wrapNone/>".to_string(),
1141            _ => format!(r#"<wp:wrapSquare wrapText="{side}"/>"#),
1142        };
1143        drawing
1144            .replace(
1145                r#"<wp:inline distT="0" distB="0" distL="0" distR="0">"#,
1146                &position,
1147            )
1148            .replace("<wp:docPr", &format!("{wrap}<wp:docPr"))
1149            .replace("</wp:inline>", "</wp:anchor>")
1150    }
1151
1152    fn finalize_story_images(&mut self, xml: Option<String>, part: &str) -> Option<String> {
1153        let xml = xml?;
1154        let mut rels = RelSet::default();
1155        let xml = self.finalize_image_rels(xml, &mut rels);
1156        if !rels.rels.is_empty() {
1157            self.parts
1158                .insert(format!("word/_rels/{part}.rels"), rels.to_xml());
1159        }
1160        Some(xml)
1161    }
1162
1163    /// Replaces image rel placeholders in `xml` with real rel ids registered
1164    /// in `rels` (targets are relative to word/).
1165    fn finalize_image_rels(&self, xml: String, rels: &mut RelSet) -> String {
1166        let mut result = xml;
1167        for index in 1..=self.image_count {
1168            let placeholder = format!("__IMG_REL_{index}__");
1169            if result.contains(&placeholder) {
1170                // Recover the media file extension for this index.
1171                let target = self
1172                    .media
1173                    .keys()
1174                    .find(|name| name.starts_with(&format!("word/media/image{index}.")))
1175                    .map(|name| name.trim_start_matches("word/").to_string())
1176                    .unwrap_or_else(|| format!("media/image{index}.png"));
1177                let rel_id = rels.add(REL_TYPE_IMAGE, &target);
1178                result = result.replace(&placeholder, &rel_id);
1179            }
1180        }
1181        result
1182    }
1183
1184    // ----- block assembly (paragraphs + tables) -----
1185
1186    fn assemble_blocks(&self, paragraphs: &[ParaOut]) -> String {
1187        let mut xml = String::new();
1188        let mut index = 0;
1189        while index < paragraphs.len() {
1190            let para = &paragraphs[index];
1191            if para.effective_itap >= 1 {
1192                let (table_xml, consumed, pending_sections) =
1193                    self.assemble_table(&paragraphs[index..], 1);
1194                xml.push_str(&table_xml);
1195                for sect_index in pending_sections {
1196                    xml.push_str(&format!(
1197                        "<w:p><w:pPr>{}</w:pPr></w:p>",
1198                        self.sect_pr_xml(sect_index)
1199                    ));
1200                }
1201                index += consumed;
1202            } else {
1203                self.append_paragraph_xml(para, &mut xml);
1204                index += 1;
1205            }
1206        }
1207        xml
1208    }
1209
1210    fn append_paragraph_xml(&self, para: &ParaOut, xml: &mut String) {
1211        let mut ppr_inner = para
1212            .pap
1213            .to_ppr_inner_xml(&|istd| self.styles.style_id(istd));
1214        ppr_inner.push_str(&para.mark_rpr);
1215        if let Some(sect_index) = para.sect_index {
1216            ppr_inner.push_str(&self.sect_pr_xml(sect_index));
1217        }
1218        xml.push_str("<w:p>");
1219        if !ppr_inner.is_empty() {
1220            xml.push_str("<w:pPr>");
1221            xml.push_str(&ppr_inner);
1222            xml.push_str("</w:pPr>");
1223        }
1224        xml.push_str(&para.runs_xml);
1225        xml.push_str("</w:p>");
1226    }
1227
1228    /// Builds one table at `level` from the head of `paragraphs`. Returns the
1229    /// XML, paragraph count consumed and any section indices that ended inside.
1230    fn assemble_table(&self, paragraphs: &[ParaOut], level: u32) -> (String, usize, Vec<usize>) {
1231        struct Row<'p> {
1232            cells: Vec<Vec<&'p ParaOut>>,
1233            tap: Tap,
1234        }
1235        let mut rows: Vec<Row<'_>> = Vec::new();
1236        let mut pending_sections = Vec::new();
1237        let mut index = 0;
1238
1239        'rows: while index < paragraphs.len() && paragraphs[index].effective_itap >= level {
1240            let mut cells: Vec<Vec<&ParaOut>> = Vec::new();
1241            let mut current: Vec<&ParaOut> = Vec::new();
1242            loop {
1243                if index >= paragraphs.len() {
1244                    if !current.is_empty() {
1245                        cells.push(std::mem::take(&mut current));
1246                    }
1247                    if !cells.is_empty() {
1248                        rows.push(Row {
1249                            cells,
1250                            tap: Tap::default(),
1251                        });
1252                    }
1253                    break 'rows;
1254                }
1255                let para = &paragraphs[index];
1256                if para.effective_itap < level {
1257                    if !current.is_empty() {
1258                        cells.push(std::mem::take(&mut current));
1259                    }
1260                    if !cells.is_empty() {
1261                        rows.push(Row {
1262                            cells,
1263                            tap: Tap::default(),
1264                        });
1265                    }
1266                    break 'rows;
1267                }
1268                if let Some(sect_index) = para.sect_index {
1269                    pending_sections.push(sect_index);
1270                }
1271                if para.is_row_end_level != 0 && para.is_row_end_level == level {
1272                    let tap = Tap::from_grpprl(&para.pap.table_grpprl);
1273                    if !current.is_empty() {
1274                        cells.push(std::mem::take(&mut current));
1275                    }
1276                    index += 1;
1277                    rows.push(Row { cells, tap });
1278                    break;
1279                }
1280                current.push(para);
1281                index += 1;
1282                if para.effective_itap == level
1283                    && (para.mark_char == 0x07 || para.pap.f_inner_table_cell)
1284                    && para.is_row_end_level == 0
1285                {
1286                    cells.push(std::mem::take(&mut current));
1287                }
1288            }
1289        }
1290
1291        if rows.is_empty() {
1292            // Defensive: avoid infinite loops on malformed structure.
1293            return (String::new(), 1.max(index), pending_sections);
1294        }
1295
1296        // Unified grid across rows.
1297        let mut boundaries: Vec<i32> = Vec::new();
1298        for row in &rows {
1299            for cell in &row.tap.cells {
1300                boundaries.push(cell.boundary_left);
1301                boundaries.push(cell.boundary_right);
1302            }
1303        }
1304        boundaries.sort_unstable();
1305        boundaries.dedup();
1306
1307        let first_tap = &rows[0].tap;
1308        let mut xml = String::from("<w:tbl>");
1309        xml.push_str("<w:tblPr>");
1310        let jc = match first_tap.jc {
1311            1 => Some("center"),
1312            2 => Some("right"),
1313            _ => None,
1314        };
1315        xml.push_str(r#"<w:tblW w:w="0" w:type="auto"/>"#);
1316        if let Some(jc) = jc {
1317            xml.push_str(&format!(r#"<w:jc w:val="{jc}"/>"#));
1318        }
1319        let ind = first_tap.row_left_edge() + first_tap.dxa_gap_half as i32;
1320        if ind != 0 {
1321            xml.push_str(&format!(r#"<w:tblInd w:w="{ind}" w:type="dxa"/>"#));
1322        }
1323        let border_tags = [
1324            "w:top",
1325            "w:left",
1326            "w:bottom",
1327            "w:right",
1328            "w:insideH",
1329            "w:insideV",
1330        ];
1331        if first_tap.table_borders.iter().any(Option::is_some) {
1332            xml.push_str("<w:tblBorders>");
1333            for (tag, border) in border_tags.iter().zip(first_tap.table_borders.iter()) {
1334                if let Some(border) = border {
1335                    xml.push_str(&border.to_xml(tag));
1336                }
1337            }
1338            xml.push_str("</w:tblBorders>");
1339        }
1340        let table_cell_padding = first_tap.default_cell_padding_twips();
1341        let [top, left, bottom, right] = table_cell_padding;
1342        xml.push_str(&format!(
1343            r#"<w:tblCellMar><w:top w:w="{top}" w:type="dxa"/><w:left w:w="{left}" w:type="dxa"/><w:bottom w:w="{bottom}" w:type="dxa"/><w:right w:w="{right}" w:type="dxa"/></w:tblCellMar>"#
1344        ));
1345        xml.push_str(r#"<w:tblLayout w:type="fixed"/>"#);
1346        xml.push_str("</w:tblPr>");
1347
1348        if boundaries.len() >= 2 {
1349            xml.push_str("<w:tblGrid>");
1350            for window in boundaries.windows(2) {
1351                xml.push_str(&format!(r#"<w:gridCol w:w="{}"/>"#, window[1] - window[0]));
1352            }
1353            xml.push_str("</w:tblGrid>");
1354        }
1355
1356        for row in &rows {
1357            xml.push_str("<w:tr>");
1358            let mut tr_pr = String::new();
1359            if row.tap.dya_row_height != 0 {
1360                let (value, rule) = if row.tap.dya_row_height < 0 {
1361                    (-(row.tap.dya_row_height as i32), "exact")
1362                } else {
1363                    (row.tap.dya_row_height as i32, "atLeast")
1364                };
1365                tr_pr.push_str(&format!(
1366                    r#"<w:trHeight w:val="{value}" w:hRule="{rule}"/>"#
1367                ));
1368            }
1369            if row.tap.f_cant_split {
1370                tr_pr.push_str("<w:cantSplit/>");
1371            }
1372            if row.tap.f_table_header {
1373                tr_pr.push_str("<w:tblHeader/>");
1374            }
1375            if !tr_pr.is_empty() {
1376                xml.push_str(&format!("<w:trPr>{tr_pr}</w:trPr>"));
1377            }
1378
1379            let cell_count = row.tap.cells.len().max(row.cells.len());
1380            let mut cell_index = 0usize;
1381            while cell_index < cell_count {
1382                let tc = row.tap.cells.get(cell_index);
1383                // Horizontally merged continuation cells fold into the first.
1384                if tc
1385                    .map(|tc| tc.f_merged && !tc.f_first_merged)
1386                    .unwrap_or(false)
1387                {
1388                    cell_index += 1;
1389                    continue;
1390                }
1391                let mut span_right = tc.map(|tc| tc.boundary_right).unwrap_or(0);
1392                let mut merged_end = cell_index + 1;
1393                if tc.map(|tc| tc.f_first_merged).unwrap_or(false) {
1394                    while merged_end < row.tap.cells.len() && row.tap.cells[merged_end].f_merged {
1395                        span_right = row.tap.cells[merged_end].boundary_right;
1396                        merged_end += 1;
1397                    }
1398                }
1399
1400                xml.push_str("<w:tc>");
1401                let mut tc_pr = String::new();
1402                if let Some(tc) = tc {
1403                    let width = span_right - tc.boundary_left;
1404                    tc_pr.push_str(&format!(r#"<w:tcW w:w="{}" w:type="dxa"/>"#, width.max(0)));
1405                    let grid_span = boundaries
1406                        .iter()
1407                        .filter(|b| **b > tc.boundary_left && **b < span_right)
1408                        .count()
1409                        + 1;
1410                    if grid_span > 1 {
1411                        xml_push_grid_span(&mut tc_pr, grid_span);
1412                    }
1413                    if tc.f_vert_restart {
1414                        tc_pr.push_str(r#"<w:vMerge w:val="restart"/>"#);
1415                    } else if tc.f_vert_merge {
1416                        tc_pr.push_str("<w:vMerge/>");
1417                    }
1418                    let cell_borders: Vec<(&str, &Option<super::fmt::Border>)> = vec![
1419                        ("w:top", &tc.brc_top),
1420                        ("w:left", &tc.brc_left),
1421                        ("w:bottom", &tc.brc_bottom),
1422                        ("w:right", &tc.brc_right),
1423                    ];
1424                    if cell_borders.iter().any(|(_, border)| border.is_some()) {
1425                        tc_pr.push_str("<w:tcBorders>");
1426                        for (tag, border) in cell_borders {
1427                            if let Some(border) = border {
1428                                tc_pr.push_str(&border.to_xml(tag));
1429                            }
1430                        }
1431                        tc_pr.push_str("</w:tcBorders>");
1432                    }
1433                    if let Some(shd) = &tc.shd {
1434                        if shd.is_visible() {
1435                            tc_pr.push_str(&shd.to_xml());
1436                        }
1437                    }
1438                }
1439                let cell_padding = row.tap.cell_padding_twips(cell_index);
1440                if cell_padding != table_cell_padding {
1441                    let [top, left, bottom, right] = cell_padding;
1442                    tc_pr.push_str(&format!(
1443                        r#"<w:tcMar><w:top w:w="{top}" w:type="dxa"/><w:left w:w="{left}" w:type="dxa"/><w:bottom w:w="{bottom}" w:type="dxa"/><w:right w:w="{right}" w:type="dxa"/></w:tcMar>"#
1444                    ));
1445                }
1446                if let Some(tc) = tc {
1447                    if tc.f_vertical {
1448                        tc_pr.push_str(&format!(
1449                            r#"<w:textDirection w:val="{}"/>"#,
1450                            if tc.f_backward { "btLr" } else { "tbRl" }
1451                        ));
1452                    }
1453                    match tc.vert_align {
1454                        1 => tc_pr.push_str(r#"<w:vAlign w:val="center"/>"#),
1455                        2 => tc_pr.push_str(r#"<w:vAlign w:val="bottom"/>"#),
1456                        _ => {}
1457                    }
1458                }
1459                if !tc_pr.is_empty() {
1460                    xml.push_str(&format!("<w:tcPr>{tc_pr}</w:tcPr>"));
1461                }
1462
1463                let content: String = match row.cells.get(cell_index) {
1464                    Some(cell_paras) if !cell_paras.is_empty() => {
1465                        let owned: Vec<&ParaOut> = cell_paras.to_vec();
1466                        self.assemble_cell(&owned)
1467                    }
1468                    _ => "<w:p/>".to_string(),
1469                };
1470                xml.push_str(&content);
1471                xml.push_str("</w:tc>");
1472                cell_index = merged_end.max(cell_index + 1);
1473            }
1474            xml.push_str("</w:tr>");
1475        }
1476        xml.push_str("</w:tbl>");
1477        (xml, index, pending_sections)
1478    }
1479
1480    fn assemble_cell(&self, cell_paras: &[&ParaOut]) -> String {
1481        let mut xml = String::new();
1482        let mut index = 0;
1483        while index < cell_paras.len() {
1484            let para = cell_paras[index];
1485            let level = para.effective_itap;
1486            if level >= 2 {
1487                // Nested table content: hand the contiguous deeper block to
1488                // assemble_table at the deeper level.
1489                let start = index;
1490                while index < cell_paras.len() && cell_paras[index].effective_itap >= 2 {
1491                    index += 1;
1492                }
1493                let owned: Vec<ParaOut> = cell_paras[start..index]
1494                    .iter()
1495                    .map(|para| ParaOut {
1496                        pap: para.pap.clone(),
1497                        runs_xml: para.runs_xml.clone(),
1498                        mark_rpr: para.mark_rpr.clone(),
1499                        mark_char: para.mark_char,
1500                        effective_itap: para.effective_itap - 1,
1501                        is_row_end_level: para.is_row_end_level.saturating_sub(1),
1502                        cp_end: para.cp_end,
1503                        sect_index: para.sect_index,
1504                    })
1505                    .collect();
1506                let (table_xml, _, _) = self.assemble_table(&owned, 1);
1507                xml.push_str(&table_xml);
1508            } else {
1509                self.append_paragraph_xml(para, &mut xml);
1510                index += 1;
1511            }
1512        }
1513        if xml.is_empty() {
1514            xml.push_str("<w:p/>");
1515        }
1516        xml
1517    }
1518
1519    // ----- sections / headers / footers -----
1520
1521    fn sect_pr_xml(&self, sect_index: usize) -> String {
1522        let sep = self
1523            .sections
1524            .get(sect_index)
1525            .map(|(_, sep)| sep.clone())
1526            .unwrap_or_default();
1527        let mut inner = String::new();
1528        // Header/footer references come first (Word's element order).
1529        inner.push_str(
1530            self.parts
1531                .get(&format!("__sect_refs_{sect_index}"))
1532                .map(String::as_str)
1533                .unwrap_or(""),
1534        );
1535        inner.push_str(&sep.to_sect_pr_inner_xml());
1536        format!("<w:sectPr>{inner}</w:sectPr>")
1537    }
1538
1539    /// Builds header/footer parts for every section and stashes the reference
1540    /// XML (per section) for sect_pr_xml. Returns rels to add to document.rels.
1541    fn build_headers_footers(&mut self) -> Vec<(String, String)> {
1542        let mut rels_to_add: Vec<(String, String)> = Vec::new();
1543        let Some(plc) = self.doc.fib.table_slice(&self.doc.table, fcidx::PLCF_HDD) else {
1544            return rels_to_add;
1545        };
1546        if self.doc.fib.ccp.hdd == 0 {
1547            return rels_to_add;
1548        }
1549        let cps: Vec<u32> = plc
1550            .chunks_exact(4)
1551            .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
1552            .collect();
1553        if cps.len() < 2 {
1554            return rels_to_add;
1555        }
1556        let hdd_base = self.doc.fib.ccp.hdd_start();
1557        let story_count = cps.len() - 1;
1558
1559        // Stories 0-5 are footnote/endnote separators; per-section groups of 6
1560        // follow: even hdr, odd hdr, even ftr, odd ftr, first hdr, first ftr.
1561        const STORY_KINDS: [(usize, &str, bool); 6] = [
1562            (0, "even", true),
1563            (1, "default", true),
1564            (2, "even", false),
1565            (3, "default", false),
1566            (4, "first", true),
1567            (5, "first", false),
1568        ];
1569
1570        // Carry-forward for "same as previous" (empty story) sections.
1571        let mut previous_refs: [Option<(String, String, bool)>; 6] = Default::default();
1572
1573        for sect_index in 0..self.sections.len() {
1574            let mut refs_xml = String::new();
1575            for (kind_index, ref_type, is_header) in STORY_KINDS {
1576                let story_index = 6 + sect_index * 6 + kind_index;
1577                let story_present = story_index + 1 < story_count + 1
1578                    && story_index + 1 < cps.len()
1579                    && cps[story_index + 1] > cps[story_index];
1580
1581                let entry: Option<(String, String, bool)> = if story_present {
1582                    let cp_start = hdd_base + cps[story_index];
1583                    let cp_end = hdd_base + cps[story_index + 1];
1584                    let part_name = if is_header {
1585                        self.header_count += 1;
1586                        format!("word/header{}.xml", self.header_count)
1587                    } else {
1588                        self.footer_count += 1;
1589                        format!("word/footer{}.xml", self.footer_count)
1590                    };
1591                    let paragraphs = self.build_paragraphs(cp_start, cp_end, &NoteContext::Body);
1592                    let body = self.assemble_blocks(&paragraphs);
1593                    let root_tag = if is_header { "w:hdr" } else { "w:ftr" };
1594                    let mut part_rels = RelSet::default();
1595                    let body = self.finalize_image_rels(body, &mut part_rels);
1596                    let part_xml = format!(
1597                        r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><{root_tag} xmlns:w="{W_NS}" xmlns:r="{R_NS}" xmlns:wp="{WP_NS}">{body}</{root_tag}>"#
1598                    );
1599                    self.parts.insert(part_name.clone(), part_xml);
1600                    if !part_rels.rels.is_empty() {
1601                        let rel_part =
1602                            format!("word/_rels/{}.rels", part_name.trim_start_matches("word/"));
1603                        self.parts.insert(rel_part, part_rels.to_xml());
1604                    }
1605                    Some((part_name, ref_type.to_string(), is_header))
1606                } else {
1607                    previous_refs[kind_index].clone()
1608                };
1609
1610                if let Some((part_name, ref_type, is_header)) = &entry {
1611                    let target = part_name.trim_start_matches("word/");
1612                    let rel_id = format!("rIdHdrFtr{}_{}", sect_index, kind_index);
1613                    rels_to_add.push((
1614                        rel_id.clone(),
1615                        format!(
1616                            r#"<Relationship Id="{rel_id}" Type="{}" Target="{target}"/>"#,
1617                            if *is_header {
1618                                REL_TYPE_HEADER
1619                            } else {
1620                                REL_TYPE_FOOTER
1621                            }
1622                        ),
1623                    ));
1624                    let tag = if *is_header {
1625                        "w:headerReference"
1626                    } else {
1627                        "w:footerReference"
1628                    };
1629                    refs_xml.push_str(&format!(r#"<{tag} w:type="{ref_type}" r:id="{rel_id}"/>"#));
1630                }
1631                previous_refs[kind_index] = entry;
1632            }
1633            self.parts
1634                .insert(format!("__sect_refs_{sect_index}"), refs_xml);
1635        }
1636        rels_to_add
1637    }
1638
1639    // ----- notes -----
1640
1641    fn build_comments_part(&mut self) -> Option<String> {
1642        if self.comments.is_empty() {
1643            return None;
1644        }
1645        let mut xml =
1646            format!(r#"<w:comments xmlns:w="{W_NS}" xmlns:r="{R_NS}" xmlns:wp="{WP_NS}">"#);
1647        for index in 0..self.comments.len() {
1648            let comment = &self.comments[index];
1649            let mut attrs = format!(
1650                r#"w:id="{index}" w:author="{}" w:initials="{}""#,
1651                escape_xml(&comment.author),
1652                escape_xml(&comment.initials)
1653            );
1654            if let Some(date) = &comment.date {
1655                attrs.push_str(&format!(r#" w:date="{date}""#));
1656            }
1657            let (start, end) = (comment.text_start, comment.text_end);
1658            let paragraphs = self.build_paragraphs(start, end, &NoteContext::Body);
1659            let body = self.assemble_blocks(&paragraphs);
1660            xml.push_str(&format!("<w:comment {attrs}>{body}</w:comment>"));
1661        }
1662        xml.push_str("</w:comments>");
1663        Some(xml)
1664    }
1665
1666    fn build_notes_part(
1667        &mut self,
1668        ranges: &[(u32, u32)],
1669        root: &str,
1670        note_tag: &str,
1671        ref_tag: &str,
1672        context: NoteContext,
1673    ) -> Option<String> {
1674        if ranges.is_empty() {
1675            return None;
1676        }
1677        let mut xml = format!(
1678            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><{root} xmlns:w="{W_NS}" xmlns:r="{R_NS}" xmlns:wp="{WP_NS}">"#
1679        );
1680        xml.push_str(&format!(
1681            r#"<{note_tag} w:type="separator" w:id="-1"><w:p><w:r><w:separator/></w:r></w:p></{note_tag}><{note_tag} w:type="continuationSeparator" w:id="0"><w:p><w:r><w:continuationSeparator/></w:r></w:p></{note_tag}>"#
1682        ));
1683        let owned_ranges = ranges.to_vec();
1684        for (index, (cp_start, cp_end)) in owned_ranges.into_iter().enumerate() {
1685            let paragraphs = self.build_paragraphs(cp_start, cp_end, &context);
1686            let body = self.assemble_blocks(&paragraphs);
1687            xml.push_str(&format!(
1688                r#"<{note_tag} w:id="{}">{body}</{note_tag}>"#,
1689                index + 1
1690            ));
1691        }
1692        let _ = ref_tag;
1693        xml.push_str(&format!("</{root}>"));
1694        Some(xml)
1695    }
1696
1697    // ----- top-level assembly -----
1698
1699    fn build(&mut self) -> Result<OoxmlPackage, String> {
1700        let ccp_text = self.doc.fib.ccp.text;
1701
1702        // Header/footer parts must exist before sectPr emission.
1703        let header_rels = self.build_headers_footers();
1704
1705        // Main document paragraphs with section attribution.
1706        let mut paragraphs = self.build_paragraphs(0, ccp_text, &NoteContext::Body);
1707        let section_count = self.sections.len();
1708        for para in paragraphs.iter_mut() {
1709            if let Some(position) = self
1710                .sections
1711                .iter()
1712                .position(|(cp_end, _)| *cp_end == para.cp_end)
1713            {
1714                if position + 1 < section_count {
1715                    para.sect_index = Some(position);
1716                }
1717            }
1718        }
1719        let body_blocks = self.assemble_blocks(&paragraphs);
1720        let final_sect = self.sect_pr_xml(section_count.saturating_sub(1));
1721
1722        let mut document_rels = RelSet::default();
1723        let body_blocks = self.finalize_image_rels(body_blocks, &mut document_rels);
1724
1725        let document_xml = format!(
1726            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:document xmlns:w="{W_NS}" xmlns:r="{R_NS}" xmlns:wp="{WP_NS}" xmlns:a="{A_NS}" xmlns:pic="{PIC_NS}"><w:body>{body_blocks}{final_sect}</w:body></w:document>"#
1727        );
1728
1729        // Notes parts.
1730        let footnote_ranges = self.footnote_ranges.clone();
1731        let footnotes_xml = self.build_notes_part(
1732            &footnote_ranges,
1733            "w:footnotes",
1734            "w:footnote",
1735            "w:footnoteRef",
1736            NoteContext::Footnote,
1737        );
1738        let endnote_ranges = self.endnote_ranges.clone();
1739        let endnotes_xml = self.build_notes_part(
1740            &endnote_ranges,
1741            "w:endnotes",
1742            "w:endnote",
1743            "w:endnoteRef",
1744            NoteContext::Endnote,
1745        );
1746        let comments_xml = self.build_comments_part();
1747        let footnotes_xml = self.finalize_story_images(footnotes_xml, "footnotes.xml");
1748        let endnotes_xml = self.finalize_story_images(endnotes_xml, "endnotes.xml");
1749        let comments_xml = self.finalize_story_images(comments_xml, "comments.xml");
1750
1751        // Fixed rels.
1752        document_rels.add(REL_TYPE_STYLES, "styles.xml");
1753        document_rels.add(REL_TYPE_SETTINGS, "settings.xml");
1754        if comments_xml.is_some() {
1755            document_rels.add(
1756                "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments",
1757                "comments.xml",
1758            );
1759        }
1760        let numbering_xml = self.lists.to_numbering_xml(&self.fonts);
1761        if numbering_xml.is_some() {
1762            document_rels.add(REL_TYPE_NUMBERING, "numbering.xml");
1763        }
1764        if footnotes_xml.is_some() {
1765            document_rels.add(REL_TYPE_FOOTNOTES, "footnotes.xml");
1766        }
1767        if endnotes_xml.is_some() {
1768            document_rels.add(REL_TYPE_ENDNOTES, "endnotes.xml");
1769        }
1770        let mut document_rels_xml = document_rels.to_xml();
1771        // Header/footer rels carry pre-assigned ids; splice them in.
1772        if !header_rels.is_empty() {
1773            let mut spliced = String::new();
1774            for (_, rel_xml) in &header_rels {
1775                spliced.push_str(rel_xml);
1776            }
1777            document_rels_xml = document_rels_xml
1778                .replace("</Relationships>", &format!("{spliced}</Relationships>"));
1779        }
1780
1781        let settings_xml = format!(
1782            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:settings xmlns:w="{W_NS}">{}<w:defaultTabStop w:val="{}"/></w:settings>"#,
1783            if self.f_facing_pages {
1784                "<w:evenAndOddHeaders/>"
1785            } else {
1786                ""
1787            },
1788            self.default_tab
1789        );
1790
1791        let styles_xml = self.styles.to_styles_xml(&self.fonts, 20);
1792
1793        // [Content_Types].xml
1794        let mut content_types = String::from(
1795            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/>"#,
1796        );
1797        for extension in &self.media_extensions {
1798            let mime = match *extension {
1799                "png" => "image/png",
1800                "jpeg" => "image/jpeg",
1801                "bmp" => "image/bmp",
1802                "tiff" => "image/tiff",
1803                "emf" => "image/x-emf",
1804                "wmf" => "image/x-wmf",
1805                "pict" => "image/x-pict",
1806                _ => continue,
1807            };
1808            content_types.push_str(&format!(
1809                r#"<Default Extension="{extension}" ContentType="{mime}"/>"#
1810            ));
1811        }
1812        let mut add_override = |part: &str, content_type: &str| {
1813            content_types.push_str(&format!(
1814                r#"<Override PartName="/{part}" ContentType="{content_type}"/>"#
1815            ));
1816        };
1817        add_override(
1818            "word/document.xml",
1819            "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
1820        );
1821        add_override(
1822            "word/styles.xml",
1823            "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml",
1824        );
1825        add_override(
1826            "word/settings.xml",
1827            "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml",
1828        );
1829        if numbering_xml.is_some() {
1830            add_override(
1831                "word/numbering.xml",
1832                "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml",
1833            );
1834        }
1835        if footnotes_xml.is_some() {
1836            add_override(
1837                "word/footnotes.xml",
1838                "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml",
1839            );
1840        }
1841        if endnotes_xml.is_some() {
1842            add_override(
1843                "word/endnotes.xml",
1844                "application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml",
1845            );
1846        }
1847        if comments_xml.is_some() {
1848            add_override(
1849                "word/comments.xml",
1850                "application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml",
1851            );
1852        }
1853        for index in 1..=self.header_count {
1854            add_override(
1855                &format!("word/header{index}.xml"),
1856                "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
1857            );
1858        }
1859        for index in 1..=self.footer_count {
1860            add_override(
1861                &format!("word/footer{index}.xml"),
1862                "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
1863            );
1864        }
1865        content_types.push_str("</Types>");
1866
1867        let root_rels = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/></Relationships>"#;
1868
1869        // Assemble the package.
1870        let mut parts = HashMap::new();
1871        let mut insert = |name: &str, content: String| {
1872            parts.insert(
1873                name.to_string(),
1874                OoxmlPart {
1875                    name: name.to_string(),
1876                    content,
1877                },
1878            );
1879        };
1880        insert("[Content_Types].xml", content_types);
1881        insert("_rels/.rels", root_rels.to_string());
1882        insert("word/document.xml", document_xml);
1883        insert("word/_rels/document.xml.rels", document_rels_xml);
1884        insert("word/styles.xml", styles_xml);
1885        insert("word/settings.xml", settings_xml);
1886        if let Some(numbering) = numbering_xml {
1887            insert("word/numbering.xml", numbering);
1888        }
1889        if let Some(footnotes) = footnotes_xml {
1890            insert("word/footnotes.xml", footnotes);
1891        }
1892        if let Some(endnotes) = endnotes_xml {
1893            insert("word/endnotes.xml", endnotes);
1894        }
1895        if let Some(comments) = comments_xml {
1896            insert("word/comments.xml", comments);
1897        }
1898        for (name, content) in self.parts.drain() {
1899            if name.starts_with("__sect_refs_") {
1900                continue;
1901            }
1902            parts.insert(name.clone(), OoxmlPart { name, content });
1903        }
1904
1905        let mut unanchored: Vec<(u32, u32)> = self
1906            .text_boxes_by_anchor
1907            .drain()
1908            .flat_map(|(cp, specs)| specs.into_iter().map(move |spec| (cp, spec.shape_id)))
1909            .collect();
1910        unanchored.sort_unstable();
1911        for (cp, shape_id) in unanchored {
1912            self.warnings.push(format!(
1913                "Legacy DOC textbox shape {shape_id} could not be attached at CP {cp}"
1914            ));
1915        }
1916
1917        Ok(OoxmlPackage {
1918            parts,
1919            binary_assets: self.media.drain().collect(),
1920            warnings: std::mem::take(&mut self.warnings),
1921        })
1922    }
1923}
1924
1925fn format_twips_as_points(twips: i64) -> String {
1926    let sign = if twips < 0 { "-" } else { "" };
1927    let magnitude = twips.unsigned_abs();
1928    let whole = magnitude / 20;
1929    let remainder = magnitude % 20;
1930    if remainder == 0 {
1931        format!("{sign}{whole}")
1932    } else {
1933        let hundredths = remainder * 5;
1934        format!("{sign}{whole}.{hundredths:02}")
1935    }
1936}
1937
1938fn xml_push_grid_span(tc_pr: &mut String, grid_span: usize) {
1939    tc_pr.push_str(&format!(r#"<w:gridSpan w:val="{grid_span}"/>"#));
1940}