diff --git a/crates/docbin/src/notes.rs b/crates/docbin/src/notes.rs index 89b1db052..dac259099 100644 --- a/crates/docbin/src/notes.rs +++ b/crates/docbin/src/notes.rs @@ -89,29 +89,105 @@ pub(crate) fn parse_bookmarks(table: &[u8], fib: &crate::fib::Fib) -> Vec<(u32, out } -/// Strings of an extended STTB (fExtend = 0xFFFF, 2-byte chars, no extra data). +/// Strings of an STTB: extended (fExtend = 0xFFFF, 2-byte length and UTF-16 chars) or not +/// (1-byte length and Windows-1252 chars). `cbExtra` bytes after each string are skipped. +/// Names are kept to 40 characters, but the reader always advances by the stored length. fn sttb_strings(table: &[u8], fc: u32, lcb: u32) -> Option> { let b = table.get(fc as usize..(fc as usize).saturating_add(lcb as usize))?; - if b.len() < 6 { - return None; - } - let c_data = u16::from_le_bytes([b[2], b[3]]) as usize; + let extended = b.get(0..2)? == [0xFF, 0xFF]; + let head = if extended { 2 } else { 0 }; + let h = b.get(head..head + 4)?; + let c_data = u16::from_le_bytes([h[0], h[1]]) as usize; + let cb_extra = u16::from_le_bytes([h[2], h[3]]) as usize; if c_data > MAX { return None; } - let mut at = 6usize; + let mut at = head + 4; let mut out = Vec::with_capacity(c_data); for _ in 0..c_data { - let cch = match b.get(at..at + 2) { - Some(x) => u16::from_le_bytes([x[0], x[1]]) as usize, - None => break, + let (cch, unit) = if extended { + match b.get(at..at + 2) { + Some(x) => (u16::from_le_bytes([x[0], x[1]]) as usize, 2), + None => break, + } + } else { + match b.get(at) { + Some(&x) => (x as usize, 1), + None => break, + } + }; + at += unit; + let Some(chars) = b.get(at..at + cch * unit) else { break }; + at += cch * unit; + // The string's extra data must be there too, or the table is cut short. + if b.get(at..at + cb_extra).is_none() { + break; + } + at += cb_extra; + let s: String = if extended { + chars.as_chunks::<2>().0.iter().take(40).map(|c| char::from_u32(u16::from_le_bytes(*c) as u32).unwrap_or('\u{FFFD}')).collect() + } else { + chars.iter().take(40).map(|&c| wordcraft_doc::encoding::cp1252(c)).collect() }; - at += 2; - let cch = cch.min(40); - let chars = b.get(at..at + cch * 2)?; - at += cch * 2; - let s: String = chars.as_chunks::<2>().0.iter().map(|c| char::from_u32(u16::from_le_bytes(*c) as u32).unwrap_or('\u{FFFD}')).collect(); out.push(s); } Some(out) } + +#[cfg(test)] +mod tests { + use super::sttb_strings; + + fn ext_sttb(names: &[&str], extra: &[u8]) -> Vec { + let mut b = vec![0xFF, 0xFF]; + b.extend_from_slice(&(names.len() as u16).to_le_bytes()); + b.extend_from_slice(&(extra.len() as u16).to_le_bytes()); + for n in names { + let units: Vec = n.encode_utf16().collect(); + b.extend_from_slice(&(units.len() as u16).to_le_bytes()); + for u in units { + b.extend_from_slice(&u.to_le_bytes()); + } + b.extend_from_slice(extra); + } + b + } + + #[test] + fn long_bookmark_name_does_not_desync_table() { + let long = "A".repeat(50); + let b = ext_sttb(&[&long, "bm2"], &[]); + let got = sttb_strings(&b, 0, b.len() as u32).unwrap(); + assert_eq!(got, vec!["A".repeat(40), "bm2".to_string()]); + } + + #[test] + fn sttb_extra_data_is_skipped() { + let b = ext_sttb(&["first", "bm2"], &[1, 2, 3, 4]); + let got = sttb_strings(&b, 0, b.len() as u32).unwrap(); + assert_eq!(got, vec!["first".to_string(), "bm2".to_string()]); + } + + #[test] + fn a_name_without_its_extra_data_is_dropped() { + let mut b = ext_sttb(&["first", "bm2"], &[1, 2, 3, 4]); + b.truncate(b.len() - 2); + let got = sttb_strings(&b, 0, b.len() as u32).unwrap(); + assert_eq!(got, vec!["first".to_string()]); + } + + #[test] + fn non_extended_sttb_reads_byte_strings() { + let mut b = Vec::new(); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&2u16.to_le_bytes()); + for n in ["one", "caf\u{e9}"] { + let bytes: Vec = n.chars().map(|c| c as u8).collect(); + b.push(bytes.len() as u8); + b.extend_from_slice(&bytes); + b.extend_from_slice(&[9, 9]); + } + let got = sttb_strings(&b, 0, b.len() as u32).unwrap(); + assert_eq!(got, vec!["one".to_string(), "caf\u{e9}".to_string()]); + } +}