From 92f8dd24d111045da3c60f8441d5dc401287028c Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Thu, 12 Nov 2026 03:02:36 +0000 Subject: [PATCH] craft-doc-binary: Word 6.0/95 text in an East Asian code page is refused, not lost The review of wordcraft#6345 found that text in code page 932, 936, 949 or 950 was read a byte at a time, each byte above ASCII becoming U+FFFD, and the file opened with its characters gone. Such a file is refused as every Word 6.0/95 file was before, until the piece walk decodes byte pairs. The test that asserted the replacement characters is replaced by one that asserts the refusal and that the same text in a Western font still opens. The module comment on the Word 6 stylesheet header now matches the reader (a u16 header size, then the header), as the other review comment asked. --- crates/doc-binary/src/tests/word6.rs | 22 +++++++++++++--------- crates/doc-binary/src/word6.rs | 24 ++++++++++++++++++++---- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/crates/doc-binary/src/tests/word6.rs b/crates/doc-binary/src/tests/word6.rs index 6bd9fb8f3..fa3f68a51 100644 --- a/crates/doc-binary/src/tests/word6.rs +++ b/crates/doc-binary/src/tests/word6.rs @@ -274,6 +274,19 @@ fn a_runs_font_charset_chooses_its_code_page() { assert_eq!(texts(&d), ["Da Да"]); } +/// Text in an East Asian code page (a Japanese font, charset 128: Shift-JIS) is refused, not +/// read a byte at a time into replacement characters (review of wordcraft#6345). +#[test] +fn east_asian_text_is_refused_not_lost() { + let fonts: &[(u8, &str)] = &[(0, "Times New Roman"), (128, "MS Mincho")]; + let runs: &[(u32, u32, &[u8])] = &[(0, 2, &[0x5D, 0x01, 0x00]), (2, 3, &[])]; + let d = Doc6 { text: b"\x93\xfa\r", fonts, runs, ..Default::default() }; + assert!(matches!(read(&word6_file(&d)), Err(DocbinError::NotWord(e)) if e.contains("East Asian"))); + // The same file in a Western font still opens. + let d = Doc6 { text: b"\x93\xfa\r", fonts, runs: &[], ..Default::default() }; + assert_eq!(texts(&d).len(), 1); +} + #[test] fn the_font_sprm_is_found_after_other_sprms() { // As in real files: bold-like toggles, a size, a picture anchor's length-prefixed sprm. @@ -302,15 +315,6 @@ fn an_unknown_sprm_leaves_font_0() { assert_eq!(texts(&d), ["Äà"]); } -#[test] -fn east_asian_font_bytes_read_as_replacement_characters() { - // A multibyte code page can't be read one byte per CP: ASCII stays, the rest is U+FFFD. - let text = b"ab\x82\xA0\r"; - let runs: &[(u32, u32, &[u8])] = &[(0, 4, &[0x5D, 0x01, 0x00]), (4, 5, &[])]; - let d = Doc6 { text, fonts: &[(0, "Arial"), (128, "MS Mincho")], runs, ..Default::default() }; - assert_eq!(texts(&d), ["ab\u{FFFD}\u{FFFD}"]); -} - #[test] fn the_toggle_sprms_before_the_font_are_framed() { // 0x57 (strike, a one-byte toggle in [MS-DOC]'s Prm0 isprm table) before the font sprm: the diff --git a/crates/doc-binary/src/word6.rs b/crates/doc-binary/src/word6.rs index 0139e3ce5..e9fab2d74 100644 --- a/crates/doc-binary/src/word6.rs +++ b/crates/doc-binary/src/word6.rs @@ -31,8 +31,9 @@ //! others were identified against Word. See [`chp`] and [`pap`]. //! - A font table entry is a size byte (the entry's length − 1), a flags byte, a 2-byte weight, the //! charset byte, one more byte, then the name in 8-bit characters. -//! - The stylesheet (pair 1) has the Word 97 shape with smaller parts: a 14-byte header whose -//! first two words are the style count and the size of a style's fixed part (8, Word 97: 10); +//! - The stylesheet (pair 1) has the Word 97 shape with smaller parts: a u16 header size (14), +//! then a header of that many bytes whose first two words are the style count and the size of +//! a style's fixed part (8, Word 97: 10); //! then per style a u16 size (0: an empty slot) and that many bytes: the fixed part (sti, then //! kind and base style, then UPX count and next style, then a size), the name (a length byte, //! 8-bit characters, a 0), and the UPXs, each a u16 size and its bytes, padded to even. A @@ -258,6 +259,13 @@ pub(crate) fn content(word: &[u8], fib: &Fib6) -> Result { _ => 0, }; let code_pages = if windows { code_page_runs(&chp_runs, &pap_runs, &facts, &fonts, default_code_page) } else { Vec::new() }; + // shortcut: East Asian (lead and trail byte) text is refused, as all Word 6.0/95 files were + // before; read it a character at a time when the piece walk can decode byte pairs. + let east_asian = || DocbinError::NotWord("Word 6.0/95 text in an East Asian code page is not read yet".into()); + if craft_codepage::is_multibyte_code_page(default_code_page) || code_pages.iter().any(|r| craft_codepage::is_multibyte_code_page(r.code_page)) { + return Err(east_asian()); + } + let multibyte_piece = std::cell::Cell::new(false); let text = Word6Text { wide: fib.wide, default_code_page, code_pages }; let pieces = if fib.complex { let (fc, lcb) = fib.pair(PAIR_CLX).ok_or_else(|| DocbinError::Malformed("the fast-saved file has no piece table".into()))?; @@ -267,12 +275,20 @@ pub(crate) fn content(word: &[u8], fib: &Fib6) -> Result { if !windows || prm & 1 == 0 { return None; } - last_font(prcs.get(usize::from(prm >> 1))?).and_then(|f| font_code_page(&fonts, f)) + let cp = last_font(prcs.get(usize::from(prm >> 1))?).and_then(|f| font_code_page(&fonts, f)); + if cp.is_some_and(craft_codepage::is_multibyte_code_page) { + multibyte_piece.set(true); + } + cp }; // The Prcs then carry Word 97 sprms, as the CHPXs and PAPXs do: the walk applies their // character sprms to the piece's text and their paragraph sprms to the paragraph whose mark // the piece holds. - PieceTable::parse_word6(word, fc, lcb, text, piece_code_page, translate_prc)? + let table = PieceTable::parse_word6(word, fc, lcb, text, piece_code_page, translate_prc)?; + if multibyte_piece.get() { + return Err(east_asian()); + } + table } else { // Word reads no more characters than the text's byte range (fcMin..fcMac) holds, one byte // each or two for UTF-16, whatever the character count says.