Skip to main content

extend_docx/doc/
stsh.rs

1//! Stylesheet (STSH, [MS-DOC] 2.9.271): STSHI header plus an array of STD
2//! style definitions whose formatting lives in UPX grpprls. Emitted as a
3//! synthesized word/styles.xml so the existing OOXML style-resolution code
4//! (basedOn chains, defaults) is reused as-is.
5
6use std::collections::HashSet;
7
8use super::chp::{Chp, ToggleSet};
9use super::fmt::escape_xml;
10use super::fonts::FontTable;
11use super::pap::Pap;
12
13const ISTD_NIL: u16 = 0x0FFF;
14pub const STK_PARAGRAPH: u8 = 1;
15pub const STK_CHARACTER: u8 = 2;
16
17#[derive(Debug, Clone)]
18pub struct StyleDef {
19    pub istd: u16,
20    pub sti: u16,
21    pub stk: u8,
22    pub istd_base: u16,
23    pub istd_next: u16,
24    pub name: String,
25    pub style_id: String,
26    pub papx: Vec<u8>,
27    pub chpx: Vec<u8>,
28    pub resolved_toggles: ToggleSet,
29}
30
31pub struct StyleSheet {
32    pub styles: Vec<Option<StyleDef>>,
33    /// Default fonts (ftc ascii / FE / other) for the document, from STSHI.
34    pub ftc_standard: [Option<u16>; 3],
35}
36
37fn read_u16(bytes: &[u8], offset: usize) -> Option<u16> {
38    bytes
39        .get(offset..offset + 2)
40        .map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
41}
42
43fn style_id_from_name(name: &str, used: &HashSet<String>) -> String {
44    let mut id = String::new();
45    let mut capitalize = true;
46    for ch in name.chars() {
47        if ch.is_ascii_alphanumeric() {
48            if capitalize {
49                id.extend(ch.to_uppercase());
50                capitalize = false;
51            } else {
52                id.push(ch);
53            }
54        } else {
55            capitalize = true;
56        }
57    }
58    if id.is_empty() {
59        id = "Style".to_string();
60    }
61    let mut candidate = id.clone();
62    let mut counter = 1;
63    while used.contains(&candidate) {
64        counter += 1;
65        candidate = format!("{id}{counter}");
66    }
67    candidate
68}
69
70impl StyleSheet {
71    pub fn empty() -> StyleSheet {
72        StyleSheet {
73            styles: Vec::new(),
74            ftc_standard: [None, None, None],
75        }
76    }
77
78    pub fn parse(stsh: &[u8]) -> StyleSheet {
79        let Some(cb_stshi) = read_u16(stsh, 0) else {
80            return StyleSheet::empty();
81        };
82        let cb_stshi = cb_stshi as usize;
83        let stshi = &stsh[2..(2 + cb_stshi).min(stsh.len())];
84        let cstd = read_u16(stshi, 0).unwrap_or(0) as usize;
85        let cb_std_base = read_u16(stshi, 2).unwrap_or(10) as usize;
86        let ftc_standard = [
87            read_u16(stshi, 12),
88            read_u16(stshi, 14),
89            read_u16(stshi, 16),
90        ];
91
92        let mut styles: Vec<Option<StyleDef>> = Vec::with_capacity(cstd);
93        let mut used_ids: HashSet<String> = HashSet::new();
94        let mut pos = 2 + cb_stshi;
95
96        for istd in 0..cstd {
97            let Some(cb_std) = read_u16(stsh, pos) else {
98                break;
99            };
100            let cb_std = cb_std as usize;
101            let record_start = pos + 2;
102            pos = record_start + cb_std;
103            if cb_std % 2 == 1 {
104                pos += 1;
105            }
106            if cb_std == 0 {
107                styles.push(None);
108                continue;
109            }
110            let Some(record) = stsh.get(record_start..record_start + cb_std) else {
111                styles.push(None);
112                continue;
113            };
114            if let Some(mut def) = parse_std(record, cb_std_base, istd as u16) {
115                def.style_id = style_id_from_name(&def.name, &used_ids);
116                used_ids.insert(def.style_id.clone());
117                styles.push(Some(def));
118            } else {
119                styles.push(None);
120            }
121        }
122
123        let mut sheet = StyleSheet {
124            styles,
125            ftc_standard,
126        };
127        sheet.resolve_toggles();
128        sheet
129    }
130
131    fn resolve_toggles(&mut self) {
132        let count = self.styles.len();
133        let mut resolved: Vec<Option<ToggleSet>> = vec![None; count];
134        for istd in 0..count {
135            self.resolve_toggles_for(istd, &mut resolved, 0);
136        }
137        for (istd, toggles) in resolved.into_iter().enumerate() {
138            if let (Some(style), Some(toggles)) = (self.styles[istd].as_mut(), toggles) {
139                style.resolved_toggles = toggles;
140            }
141        }
142    }
143
144    fn resolve_toggles_for(
145        &self,
146        istd: usize,
147        resolved: &mut Vec<Option<ToggleSet>>,
148        depth: usize,
149    ) -> ToggleSet {
150        if depth > 16 {
151            return ToggleSet::default();
152        }
153        if let Some(Some(toggles)) = resolved.get(istd) {
154            return *toggles;
155        }
156        let Some(Some(style)) = self.styles.get(istd) else {
157            return ToggleSet::default();
158        };
159        let mut toggles =
160            if style.istd_base != ISTD_NIL && (style.istd_base as usize) < self.styles.len() {
161                self.resolve_toggles_for(style.istd_base as usize, resolved, depth + 1)
162            } else {
163                ToggleSet::default()
164            };
165        toggles.apply_style_grpprl(&style.chpx);
166        resolved[istd] = Some(toggles);
167        toggles
168    }
169
170    pub fn style(&self, istd: u16) -> Option<&StyleDef> {
171        self.styles
172            .get(istd as usize)
173            .and_then(|slot| slot.as_ref())
174    }
175
176    pub fn style_id(&self, istd: u16) -> Option<String> {
177        self.style(istd).map(|style| style.style_id.clone())
178    }
179
180    pub fn toggles(&self, istd: u16) -> ToggleSet {
181        self.style(istd)
182            .map(|style| style.resolved_toggles)
183            .unwrap_or_default()
184    }
185
186    /// Toggle operands refer to the current character style, or to the
187    /// paragraph style when no character style is applied.
188    pub fn run_toggle_base(&self, para_istd: u16, char_istd: Option<u16>) -> ToggleSet {
189        let para = self.toggles(para_istd);
190        match char_istd {
191            Some(istd) if self.style(istd).is_some() => self.toggles(istd),
192            Some(_) => para,
193            None => para,
194        }
195    }
196
197    /// Synthesizes word/styles.xml.
198    pub fn to_styles_xml(&self, fonts: &FontTable, default_hps: u16) -> String {
199        let mut xml = String::from(
200            r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?><w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">"#,
201        );
202
203        // Document defaults: standard fonts from STSHI, Word's 10pt baseline.
204        xml.push_str("<w:docDefaults><w:rPrDefault><w:rPr>");
205        if let Some(ftc) = self.ftc_standard[0] {
206            if let Some(name) = fonts.name(ftc) {
207                let escaped = escape_xml(&name);
208                let east_asia = self.ftc_standard[1]
209                    .and_then(|ftc| fonts.name(ftc))
210                    .map(|name| escape_xml(&name))
211                    .unwrap_or_else(|| escaped.clone());
212                let other = self.ftc_standard[2]
213                    .and_then(|ftc| fonts.name(ftc))
214                    .map(|name| escape_xml(&name))
215                    .unwrap_or_else(|| escaped.clone());
216                xml.push_str(&format!(
217                    r#"<w:rFonts w:ascii="{escaped}" w:hAnsi="{escaped}" w:eastAsia="{east_asia}" w:cs="{other}"/>"#
218                ));
219            }
220        }
221        xml.push_str(&format!(
222            r#"<w:sz w:val="{default_hps}"/><w:szCs w:val="{default_hps}"/>"#
223        ));
224        xml.push_str("</w:rPr></w:rPrDefault><w:pPrDefault/></w:docDefaults>");
225
226        for style in self.styles.iter().flatten() {
227            if style.stk != STK_PARAGRAPH && style.stk != STK_CHARACTER {
228                continue; // table/numbering styles are not emitted
229            }
230            let style_type = if style.stk == STK_PARAGRAPH {
231                "paragraph"
232            } else {
233                "character"
234            };
235            let is_default = style.sti == 0 || style.sti == 65;
236            xml.push_str(&format!(
237                r#"<w:style w:type="{style_type}" w:styleId="{}"{}>"#,
238                escape_xml(&style.style_id),
239                if is_default { r#" w:default="1""# } else { "" }
240            ));
241            xml.push_str(&format!(r#"<w:name w:val="{}"/>"#, escape_xml(&style.name)));
242            if style.istd_base != ISTD_NIL {
243                if let Some(base_id) = self.style_id(style.istd_base) {
244                    xml.push_str(&format!(r#"<w:basedOn w:val="{}"/>"#, escape_xml(&base_id)));
245                }
246            }
247            if style.istd_next != style.istd && style.istd_next != ISTD_NIL {
248                if let Some(next_id) = self.style_id(style.istd_next) {
249                    xml.push_str(&format!(r#"<w:next w:val="{}"/>"#, escape_xml(&next_id)));
250                }
251            }
252
253            if style.stk == STK_PARAGRAPH && !style.papx.is_empty() {
254                let mut pap = Pap::default();
255                pap.apply_papx(&style.papx);
256                let ppr_inner = pap.to_ppr_inner_xml(&|_| None);
257                if !ppr_inner.is_empty() {
258                    xml.push_str(&format!("<w:pPr>{ppr_inner}</w:pPr>"));
259                }
260            }
261            if !style.chpx.is_empty() {
262                let base = if style.istd_base != ISTD_NIL {
263                    self.toggles(style.istd_base)
264                } else {
265                    ToggleSet::default()
266                };
267                let mut chp = Chp::default();
268                chp.apply_chpx(&style.chpx, &base);
269                let rpr = chp.to_rpr_xml(&|_| None, &|ftc| fonts.name(ftc));
270                xml.push_str(&rpr);
271            }
272            xml.push_str("</w:style>");
273        }
274
275        xml.push_str("</w:styles>");
276        xml
277    }
278}
279
280/// Parses one STD record. `cb_std_base` is STSHI.cbSTDBaseInFile (10 for
281/// Word 97, 18 when StdfPost2000 is present).
282fn parse_std(record: &[u8], cb_std_base: usize, istd: u16) -> Option<StyleDef> {
283    let word0 = read_u16(record, 0)?;
284    let word1 = read_u16(record, 2)?;
285    let word2 = read_u16(record, 4)?;
286    let sti = word0 & 0x0FFF;
287    let stk = (word1 & 0x000F) as u8;
288    let istd_base = word1 >> 4;
289    let cupx = (word2 & 0x000F) as usize;
290    let istd_next = word2 >> 4;
291
292    // Xstz name directly after the fixed STDF part.
293    let name_offset = cb_std_base;
294    let cch = read_u16(record, name_offset)? as usize;
295    let mut name = String::new();
296    for index in 0..cch {
297        let unit = read_u16(record, name_offset + 2 + index * 2)?;
298        name.push_str(&String::from_utf16_lossy(&[unit]));
299    }
300
301    // grupx starts after the name's null terminator, 2-byte aligned relative
302    // to the start of the STD.
303    let mut pos = name_offset + 2 + cch * 2 + 2;
304    if pos % 2 == 1 {
305        pos += 1;
306    }
307
308    let mut papx = Vec::new();
309    let mut chpx = Vec::new();
310    for upx_index in 0..cupx {
311        let cb_upx = read_u16(record, pos)? as usize;
312        let payload_start = pos + 2;
313        let payload = record.get(payload_start..payload_start + cb_upx)?;
314        if stk == STK_PARAGRAPH && upx_index == 0 {
315            // upxPapx: istd (u16) then paragraph sprms.
316            if payload.len() >= 2 {
317                papx = payload[2..].to_vec();
318            }
319        } else {
320            // The (only/last) chpx UPX.
321            if chpx.is_empty() {
322                chpx = payload.to_vec();
323            }
324        }
325        pos = payload_start + cb_upx;
326        if pos % 2 == 1 {
327            pos += 1;
328        }
329    }
330
331    Some(StyleDef {
332        istd,
333        sti,
334        stk,
335        istd_base,
336        istd_next,
337        name,
338        style_id: String::new(),
339        papx,
340        chpx,
341        resolved_toggles: ToggleSet::default(),
342    })
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    fn build_std(
350        sti: u16,
351        stk: u8,
352        istd_base: u16,
353        istd_next: u16,
354        name: &str,
355        papx: &[u8],
356        chpx: &[u8],
357    ) -> Vec<u8> {
358        let mut record = Vec::new();
359        record.extend_from_slice(&sti.to_le_bytes());
360        record.extend_from_slice(&((istd_base << 4) | stk as u16).to_le_bytes());
361        let cupx: u16 = if stk == STK_PARAGRAPH { 2 } else { 1 };
362        record.extend_from_slice(&((istd_next << 4) | cupx).to_le_bytes());
363        record.extend_from_slice(&0u16.to_le_bytes()); // bchUpe
364        record.extend_from_slice(&0u16.to_le_bytes()); // grfstd
365        let units: Vec<u16> = name.encode_utf16().collect();
366        record.extend_from_slice(&(units.len() as u16).to_le_bytes());
367        for unit in &units {
368            record.extend_from_slice(&unit.to_le_bytes());
369        }
370        record.extend_from_slice(&0u16.to_le_bytes());
371        if record.len() % 2 == 1 {
372            record.push(0);
373        }
374        if stk == STK_PARAGRAPH {
375            let cb = (papx.len() + 2) as u16;
376            record.extend_from_slice(&cb.to_le_bytes());
377            record.extend_from_slice(&0u16.to_le_bytes()); // istd inside upxPapx
378            record.extend_from_slice(papx);
379            if record.len() % 2 == 1 {
380                record.push(0);
381            }
382        }
383        record.extend_from_slice(&(chpx.len() as u16).to_le_bytes());
384        record.extend_from_slice(chpx);
385        if record.len() % 2 == 1 {
386            record.push(0);
387        }
388        record
389    }
390
391    fn build_stsh(stds: &[Vec<u8>]) -> Vec<u8> {
392        let mut stshi = Vec::new();
393        stshi.extend_from_slice(&(stds.len() as u16).to_le_bytes());
394        stshi.extend_from_slice(&10u16.to_le_bytes()); // cbSTDBaseInFile
395        stshi.extend_from_slice(&[0u8; 8]); // flags, stiMax, istdMax, nVer
396        stshi.extend_from_slice(&0u16.to_le_bytes()); // ftc ascii
397        stshi.extend_from_slice(&0u16.to_le_bytes());
398        stshi.extend_from_slice(&0u16.to_le_bytes());
399
400        let mut stsh = Vec::new();
401        stsh.extend_from_slice(&(stshi.len() as u16).to_le_bytes());
402        stsh.extend_from_slice(&stshi);
403        for std_record in stds {
404            stsh.extend_from_slice(&(std_record.len() as u16).to_le_bytes());
405            stsh.extend_from_slice(std_record);
406            if std_record.len() % 2 == 1 {
407                stsh.push(0);
408            }
409        }
410        stsh
411    }
412
413    #[test]
414    fn parses_styles_and_resolves_toggles() {
415        // Normal (istd 0), then a bold "Strong Para" based on Normal (istd 1),
416        // then a style based on it that inverts bold (istd 2).
417        let bold_on: Vec<u8> = vec![0x35, 0x08, 0x01];
418        let bold_invert: Vec<u8> = vec![0x35, 0x08, 0x81];
419        let stds = vec![
420            build_std(0, STK_PARAGRAPH, ISTD_NIL, 0, "Normal", &[], &[]),
421            build_std(20, STK_PARAGRAPH, 0, 1, "Strong Para", &[], &bold_on),
422            build_std(21, STK_PARAGRAPH, 1, 2, "Unbold Para", &[], &bold_invert),
423        ];
424        let sheet = StyleSheet::parse(&build_stsh(&stds));
425
426        assert_eq!(sheet.style_id(0).as_deref(), Some("Normal"));
427        assert_eq!(sheet.style_id(1).as_deref(), Some("StrongPara"));
428        assert_eq!(
429            sheet.toggles(1).get(super::super::chp::Toggle::Bold),
430            Some(true)
431        );
432        assert_eq!(
433            sheet.toggles(2).get(super::super::chp::Toggle::Bold),
434            Some(false)
435        );
436
437        let xml = sheet.to_styles_xml(&FontTable::empty(), 20);
438        assert!(xml.contains(r#"<w:style w:type="paragraph" w:styleId="Normal" w:default="1">"#));
439        assert!(xml.contains(r#"<w:basedOn w:val="Normal"/>"#));
440        assert!(xml.contains("<w:b/>"));
441    }
442
443    #[test]
444    fn skips_empty_slots() {
445        let stds = vec![
446            build_std(0, STK_PARAGRAPH, ISTD_NIL, 0, "Normal", &[], &[]),
447            Vec::new(),
448            build_std(
449                65,
450                STK_CHARACTER,
451                ISTD_NIL,
452                2,
453                "Default Paragraph Font",
454                &[],
455                &[],
456            ),
457        ];
458        // Manually splice an empty (cbStd == 0) slot.
459        let mut stsh = build_stsh(&[stds[0].clone()]);
460        // Fix cstd to 3.
461        stsh[2] = 3;
462        stsh.extend_from_slice(&0u16.to_le_bytes());
463        let record = &stds[2];
464        stsh.extend_from_slice(&(record.len() as u16).to_le_bytes());
465        stsh.extend_from_slice(record);
466
467        let sheet = StyleSheet::parse(&stsh);
468        assert!(sheet.style(1).is_none());
469        assert_eq!(sheet.style_id(2).as_deref(), Some("DefaultParagraphFont"));
470    }
471}