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..b6a2f5e5 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,70 @@ 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="+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""#, + ] { + 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..d3e8adc0 --- /dev/null +++ b/src/formats/docx/symbols.rs @@ -0,0 +1,34 @@ +//! 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 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, + 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 + } +}