From 27b3d789e8fd3be68334f2769d477874ad444ca8 Mon Sep 17 00:00:00 2001 From: HanZephyr Date: Sun, 20 Sep 2026 14:25:36 +0800 Subject: [PATCH 1/2] fix(docx): preserve symbol checkbox states --- src/formats/docx/content.rs | 5 +++ src/formats/docx/mod.rs | 62 +++++++++++++++++++++++++++++++++++++ src/formats/docx/symbols.rs | 29 +++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 src/formats/docx/symbols.rs diff --git a/src/formats/docx/content.rs b/src/formats/docx/content.rs index c5a7c18b..b45f21ac 100644 --- a/src/formats/docx/content.rs +++ b/src/formats/docx/content.rs @@ -501,6 +501,11 @@ impl<'a, 'b, 'e> InlineWalker<'a, 'b, 'e> { self.push(Inline::Text { text, style }); } } + "sym" => { + if let Some(glyph) = super::symbols::checkbox(child) { + self.push(Inline::Text { text: glyph.to_string(), style }); + } + } "tab" | "ptab" => self.push(Inline::Text { text: " ".into(), style: Style::PLAIN }), // Markdown has no pages or columns, but every w:br still // separates the runs around it: dropping a page break diff --git a/src/formats/docx/mod.rs b/src/formats/docx/mod.rs index eee5317e..cde7efe5 100644 --- a/src/formats/docx/mod.rs +++ b/src/formats/docx/mod.rs @@ -6,6 +6,7 @@ mod content; mod numbering; mod styles; +mod symbols; use crate::error::ConvertError; use crate::model::{Document, Note, NoteKind}; @@ -247,6 +248,67 @@ mod tests { ); } + #[test] + fn symbol_checkboxes_preserve_states_and_run_style() { + for (font, empty, checked) in [ + ("Wingdings 2", "00A3", "0052"), + ("wingdings 2", "F0A3", "F052"), + ("Wingdings", "006F", "00FE"), + ("WINGDINGS", "F06F", "F0FE"), + ] { + let document = format!( + r#" + yes + no ☑ + "# + ); + let bytes = docx_parts(&[("word/document.xml", &document)]); + let doc = parse(&bytes).unwrap(); + let Some(Block::Paragraph(inlines)) = doc.blocks.first() else { panic!() }; + assert_eq!(crate::model::inlines_to_plain_text(inlines), "☑ yes □ no ☑"); + assert_eq!( + crate::to_markdown_bytes(&bytes, crate::Format::Docx).unwrap().trim(), + "**☑ yes** □ no ☑" + ); + } + } + + #[test] + fn symbol_checkboxes_in_table_keep_unselected_option_separate() { + let document = format!( + r#" + ☑ Required, amount: + Not required + "# + ); + let bytes = docx_parts(&[("word/document.xml", &document)]); + let markdown = crate::to_markdown_bytes(&bytes, crate::Format::Docx).unwrap(); + assert!(markdown.contains("☑ Required, amount:
□Not required"), "{markdown}"); + } + + #[test] + fn unknown_or_malformed_symbols_do_not_invent_checkbox_states() { + for attrs in [ + r#"w:font="Arial" w:char="0052""#, + r#"w:font="Wingdings 2" w:char="0001""#, + r#"w:font="Wingdings 2" w:char="not-hex""#, + r#"w:font="Wingdings 2" w:char="100A3""#, + r#"w:font="Wingdings 2""#, + r#"w:char="00A3""#, + ] { + let document = format!( + r#" + beforeafter + "# + ); + let bytes = docx_parts(&[("word/document.xml", &document)]); + assert_eq!( + crate::to_markdown_bytes(&bytes, crate::Format::Docx).unwrap().trim(), + "beforeafter" + ); + } + } + #[test] fn unmarked_run_edge_whitespace_is_kept() { // Converters that never write xml:space carry inter-word spacing on diff --git a/src/formats/docx/symbols.rs b/src/formats/docx/symbols.rs new file mode 100644 index 00000000..c8e55607 --- /dev/null +++ b/src/formats/docx/symbols.rs @@ -0,0 +1,29 @@ +//! Font-specific checkbox glyphs stored as WordprocessingML w:sym elements. +use crate::package::xml::{Element, ns}; + +pub(super) fn checkbox(symbol: &Element) -> Option { + let font = symbol.attr(ns::W, "font")?; + let code = u16::from_str_radix(symbol.attr(ns::W, "char")?, 16).ok()?; + // Word stores legacy symbol-font codes both as bytes and in U+F000..F0FF. + let code = match code { + 0xf000..=0xf0ff => code - 0xf000, + code => code, + }; + if font.eq_ignore_ascii_case("Wingdings 2") { + match code { + 0xa3 => Some('□'), + 0x52 => Some('☑'), + _ => None, + } + } else if font.eq_ignore_ascii_case("Wingdings") { + match code { + 0x6f => Some('□'), + 0xfe => Some('☑'), + _ => None, + } + } else { + // Unknown font/code pairs are not Unicode checkbox states. Keep the + // existing unsupported-symbol behavior instead of inventing a value. + None + } +} From 2f0907d3bb8920e4b44e39443d575db4ce2eb59a Mon Sep 17 00:00:00 2001 From: HanZephyr Date: Sun, 20 Sep 2026 16:35:18 +0800 Subject: [PATCH 2/2] fix(docx): reject malformed symbol character codes --- src/formats/docx/mod.rs | 3 +++ src/formats/docx/symbols.rs | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/formats/docx/mod.rs b/src/formats/docx/mod.rs index cde7efe5..b6a2f5e5 100644 --- a/src/formats/docx/mod.rs +++ b/src/formats/docx/mod.rs @@ -292,6 +292,9 @@ mod tests { r#"w:font="Arial" w:char="0052""#, r#"w:font="Wingdings 2" w:char="0001""#, r#"w:font="Wingdings 2" w:char="not-hex""#, + r#"w:font="Wingdings 2" w:char="+00A3""#, + r#"w:font="Wingdings 2" w:char="000A3""#, + r#"w:font="Wingdings 2" w:char="""#, r#"w:font="Wingdings 2" w:char="100A3""#, r#"w:font="Wingdings 2""#, r#"w:char="00A3""#, diff --git a/src/formats/docx/symbols.rs b/src/formats/docx/symbols.rs index c8e55607..d3e8adc0 100644 --- a/src/formats/docx/symbols.rs +++ b/src/formats/docx/symbols.rs @@ -3,7 +3,12 @@ use crate::package::xml::{Element, ns}; pub(super) fn checkbox(symbol: &Element) -> Option { let font = symbol.attr(ns::W, "font")?; - let code = u16::from_str_radix(symbol.attr(ns::W, "char")?, 16).ok()?; + let value = symbol.attr(ns::W, "char")?; + // Reject signs and overlong values even when integer parsing accepts them. + if value.is_empty() || value.len() > 4 || !value.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let code = u16::from_str_radix(value, 16).ok()?; // Word stores legacy symbol-font codes both as bytes and in U+F000..F0FF. let code = match code { 0xf000..=0xf0ff => code - 0xf000,