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
+ }
+}