Skip to main content

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