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.

(cherry picked from commit 92f8dd24d1)
This commit is contained in:
Dylan M. Taylor committed 2026-11-14 13:27:16 -05:00
1 parent 387eab39f4
commit 3032d970a6
2 files changed
+33 -13

No files matched your search

+13 -9
View File
@@ -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
+20 -4
View File
@@ -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<Content, DocbinError> {
_ => 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<Content, DocbinError> {
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.