Loading tools/docx2md/main.go +190 −46 Changes for tools/docx2md/main.go: 190 added lines, 46 removed lines. Original line number Diff line number Diff line Loading @@ -64,6 +64,19 @@ type runProperties struct { Bold *struct{} `xml:"b"` Italic *struct{} `xml:"i"` Style styleRef `xml:"rStyle"` Fonts runFonts `xml:"rFonts"` } type runFonts struct { ASCII string `xml:"ascii,attr"` HAnsi string `xml:"hAnsi,attr"` } type wordStyle struct { ID string `xml:"styleId,attr"` Name styleRef `xml:"name"` BasedOn styleRef `xml:"basedOn"` RunProps runProperties `xml:"rPr"` } type table struct { Loading @@ -89,11 +102,11 @@ func main() { os.Exit(2) } xmlData, err := wordDocument(flag.Arg(0)) xmlData, stylesXML, err := wordDocument(flag.Arg(0)) if err != nil { fatal(err) } markdown, err := convert(xmlData) markdown, err := convertWithStyles(xmlData, monoStyleIDs(stylesXML)) if err != nil { fatal(err) } Loading @@ -107,31 +120,44 @@ func main() { } } func wordDocument(path string) ([]byte, error) { func wordDocument(path string) (document, styles []byte, err error) { zr, err := zip.OpenReader(path) if err != nil { return nil, fmt.Errorf("open %s: %w", path, err) return nil, nil, fmt.Errorf("open %s: %w", path, err) } defer zr.Close() for _, f := range zr.File { if f.Name != "word/document.xml" { switch f.Name { case "word/document.xml", "word/styles.xml": default: continue } r, err := f.Open() if err != nil { return nil, fmt.Errorf("open document.xml: %w", err) return nil, nil, fmt.Errorf("open %s: %w", f.Name, err) } defer r.Close() data, err := io.ReadAll(r) if err != nil { return nil, fmt.Errorf("read document.xml: %w", err) data, readErr := io.ReadAll(r) r.Close() if readErr != nil { return nil, nil, fmt.Errorf("read %s: %w", f.Name, readErr) } return data, nil if f.Name == "word/document.xml" { document = data } else { styles = data } } if document == nil { return nil, nil, fmt.Errorf("%s is not a DOCX file (word/document.xml is missing)", path) } return nil, fmt.Errorf("%s is not a DOCX file (word/document.xml is missing)", path) return document, styles, nil } func convert(data []byte) (string, error) { return convertWithStyles(data, nil) } func convertWithStyles(data []byte, monoStyles map[string]struct{}) (string, error) { decoder := xml.NewDecoder(bytes.NewReader(data)) var blocks []string var code strings.Builder Loading Loading @@ -176,7 +202,7 @@ func convert(data []byte) (string, error) { if err := decoder.DecodeElement(&p, &start); err != nil { return "", err } kind, text := markdownParagraph(p) kind, text := markdownParagraph(p, monoStyles) if text == "" { continue } Loading @@ -195,7 +221,7 @@ func convert(data []byte) (string, error) { return "", err } flushCode() if text := markdownTable(tbl); text != "" { if text := markdownTable(tbl, monoStyles); text != "" { blocks = append(blocks, text) } } Loading @@ -204,11 +230,11 @@ func convert(data []byte) (string, error) { return strings.Join(blocks, "\n\n") + "\n", nil } func markdownParagraph(p paragraph) (kind, markdown string) { func markdownParagraph(p paragraph, monoStyles map[string]struct{}) (kind, markdown string) { style := strings.ToLower(p.Properties.Style.Value) text := strings.TrimSpace(runsMarkdown(p.Runs)) text := strings.TrimSpace(runsMarkdown(p.Runs, monoStyles)) for _, link := range p.Hyperlinks { text += runsMarkdown(link.Runs) text += runsMarkdown(link.Runs, monoStyles) } if text == "" { return "", "" Loading Loading @@ -257,68 +283,186 @@ func isCodeStyle(style string) bool { return false } func runsMarkdown(runs []run) string { // monoStyleIDs returns style IDs whose resolved font is monospace or whose // style is PL. Runs reference these IDs without repeating the font. func monoStyleIDs(stylesXML []byte) map[string]struct{} { if len(stylesXML) == 0 { return nil } var doc struct { Styles []wordStyle `xml:"style"` } if err := xml.Unmarshal(stylesXML, &doc); err != nil { return nil } byID := make(map[string]wordStyle, len(doc.Styles)) for _, st := range doc.Styles { byID[st.ID] = st } mono := make(map[string]struct{}) for id := range byID { if styleIsMono(id, byID, map[string]bool{}) { mono[id] = struct{}{} } } return mono } func styleIsMono(id string, styles map[string]wordStyle, seen map[string]bool) bool { if id == "" || seen[id] { return false } seen[id] = true st, ok := styles[id] if !ok { return isPLName(id) } if isPLName(id) || isPLName(st.Name.Value) { return true } if font := latinFont(st.RunProps.Fonts); font != "" { return isMonoFont(font) } return styleIsMono(st.BasedOn.Value, styles, seen) } func latinFont(f runFonts) string { if f.ASCII != "" { return f.ASCII } return f.HAnsi } func isPLName(name string) bool { switch strings.ToLower(strings.TrimSpace(name)) { case "pl", "plchar", "pl char": return true default: return false } } func isMonoFont(name string) bool { n := strings.ToLower(strings.TrimSpace(name)) switch { case n == "": return false case strings.Contains(n, "courier"), strings.Contains(n, "mono"), strings.Contains(n, "typewriter"), strings.Contains(n, "consolas"), strings.Contains(n, "cascadia"), n == "lucida console", n == "monaco", n == "menlo", n == "inconsolata": return true default: return false } } func runIsCode(r run, monoStyles map[string]struct{}) bool { if isPLName(r.Properties.Style.Value) { return true } if _, ok := monoStyles[r.Properties.Style.Value]; ok { return true } return isMonoFont(latinFont(r.Properties.Fonts)) } func runsMarkdown(runs []run, monoStyles map[string]struct{}) string { var b strings.Builder for _, r := range runs { var text strings.Builder for _, t := range r.Text { text.WriteString(t.Value) var code strings.Builder flushCode := func() { raw := code.String() code.Reset() if raw == "" { return } for range r.Tabs { text.WriteByte('\t') if strings.TrimSpace(raw) == "" { b.WriteString(raw) return } for range r.Breaks { text.WriteString(" \n") left := 0 for left < len(raw) && (raw[left] == ' ' || raw[left] == '\t') { left++ } s := text.String() right := len(raw) for right > left && (raw[right-1] == ' ' || raw[right-1] == '\t') { right-- } b.WriteString(raw[:left]) b.WriteByte('`') b.WriteString(strings.ReplaceAll(raw[left:right], "`", "\\`")) b.WriteByte('`') b.WriteString(raw[right:]) } for _, r := range runs { s := runText(r) if s == "" { continue } if *plain { b.WriteString(s) if !*plain && runIsCode(r, monoStyles) { code.WriteString(s) continue } inlineCode := strings.EqualFold(r.Properties.Style.Value, "PL") flushCode() b.WriteString(formatRun(s, r)) } flushCode() return b.String() } func formatRun(s string, r run) string { if *plain { return s } switch { case inlineCode: b.WriteString("`" + strings.ReplaceAll(s, "`", "\\`") + "`") case r.Properties.Bold != nil && r.Properties.Italic != nil: b.WriteString("***" + s + "***") return "***" + s + "***" case r.Properties.Bold != nil: b.WriteString("**" + s + "**") return "**" + s + "**" case r.Properties.Italic != nil: b.WriteString("_" + s + "_") return "_" + s + "_" default: b.WriteString(s) } return s } return b.String() } func runsPlain(runs []run) string { var b strings.Builder for _, r := range runs { func runText(r run) string { var text strings.Builder for _, t := range r.Text { b.WriteString(t.Value) text.WriteString(t.Value) } for range r.Tabs { b.WriteByte('\t') text.WriteByte('\t') } for range r.Breaks { b.WriteByte('\n') text.WriteString(" \n") } return text.String() } func runsPlain(runs []run) string { var b strings.Builder for _, r := range runs { text := runText(r) text = strings.ReplaceAll(text, " \n", "\n") b.WriteString(text) } return b.String() } func markdownTable(t table) string { func markdownTable(t table, monoStyles map[string]struct{}) string { var rows [][]string for _, row := range t.Rows { var cells []string for _, cell := range row.Cells { var parts []string for _, p := range cell.Paragraphs { _, text := markdownParagraph(p) _, text := markdownParagraph(p, monoStyles) if text != "" { parts = append(parts, strings.ReplaceAll(text, "|", "\\|")) } Loading tools/docx2md/main_test.go +26 −0 Changes for tools/docx2md/main_test.go: 26 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -10,6 +10,9 @@ func TestConvert(t *testing.T) { <w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>5 Concepts</w:t></w:r></w:p> <w:p><w:r><w:t>Ordinary text.</w:t></w:r></w:p> <w:p><w:pPr><w:pStyle w:val="NO"/></w:pPr><w:r><w:t>NOTE: A note.</w:t></w:r></w:p> <w:p><w:r><w:t>Use </w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/><w:b/></w:rPr><w:t>runs</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/><w:b/></w:rPr><w:t xml:space="preserve"> on</w:t></w:r><w:r><w:t> here.</w:t></w:r></w:p> <w:p><w:r><w:rPr><w:rStyle w:val="PLChar"/><w:b/></w:rPr><w:t>altstep</w:t></w:r></w:p> <w:p><w:r><w:rPr><w:b/></w:rPr><w:t>bold term</w:t></w:r></w:p> <w:p><w:pPr><w:pStyle w:val="PL"/></w:pPr><w:r><w:t>Identifier ::= "A" .</w:t></w:r></w:p> <w:tbl><w:tr><w:tc><w:p><w:r><w:t>Column</w:t></w:r></w:p></w:tc></w:tr><w:tr><w:tc><w:p><w:r><w:t>Value</w:t></w:r></w:p></w:tc></w:tr></w:tbl> </w:body></w:document>` Loading @@ -22,6 +25,9 @@ func TestConvert(t *testing.T) { "# Concepts", "Ordinary text.", "> NOTE: A note.", "Use `runs on` here.", "`altstep`", "**bold term**", "```ebnf\nIdentifier ::= \"A\" .\n```", "| Column |\n| --- |\n| Value |", } { Loading @@ -30,3 +36,23 @@ func TestConvert(t *testing.T) { } } } func TestStyledMonospaceRun(t *testing.T) { const styles = `<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"> <w:style w:styleId="HTMLCode"><w:name w:val="HTML Code"/><w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/></w:rPr></w:style> </w:styles>` const document = `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body> <w:p><w:r><w:rPr><w:rStyle w:val="HTMLCode"/><w:b/></w:rPr><w:t>keyword</w:t></w:r></w:p> </w:body></w:document>` got, err := convertWithStyles([]byte(document), monoStyleIDs([]byte(styles))) if err != nil { t.Fatal(err) } if !strings.Contains(got, "`keyword`") { t.Fatalf("got %q", got) } if strings.Contains(got, "**") { t.Fatalf("bold monospace span kept bold markup: %q", got) } } Loading
tools/docx2md/main.go +190 −46 Changes for tools/docx2md/main.go: 190 added lines, 46 removed lines. Original line number Diff line number Diff line Loading @@ -64,6 +64,19 @@ type runProperties struct { Bold *struct{} `xml:"b"` Italic *struct{} `xml:"i"` Style styleRef `xml:"rStyle"` Fonts runFonts `xml:"rFonts"` } type runFonts struct { ASCII string `xml:"ascii,attr"` HAnsi string `xml:"hAnsi,attr"` } type wordStyle struct { ID string `xml:"styleId,attr"` Name styleRef `xml:"name"` BasedOn styleRef `xml:"basedOn"` RunProps runProperties `xml:"rPr"` } type table struct { Loading @@ -89,11 +102,11 @@ func main() { os.Exit(2) } xmlData, err := wordDocument(flag.Arg(0)) xmlData, stylesXML, err := wordDocument(flag.Arg(0)) if err != nil { fatal(err) } markdown, err := convert(xmlData) markdown, err := convertWithStyles(xmlData, monoStyleIDs(stylesXML)) if err != nil { fatal(err) } Loading @@ -107,31 +120,44 @@ func main() { } } func wordDocument(path string) ([]byte, error) { func wordDocument(path string) (document, styles []byte, err error) { zr, err := zip.OpenReader(path) if err != nil { return nil, fmt.Errorf("open %s: %w", path, err) return nil, nil, fmt.Errorf("open %s: %w", path, err) } defer zr.Close() for _, f := range zr.File { if f.Name != "word/document.xml" { switch f.Name { case "word/document.xml", "word/styles.xml": default: continue } r, err := f.Open() if err != nil { return nil, fmt.Errorf("open document.xml: %w", err) return nil, nil, fmt.Errorf("open %s: %w", f.Name, err) } defer r.Close() data, err := io.ReadAll(r) if err != nil { return nil, fmt.Errorf("read document.xml: %w", err) data, readErr := io.ReadAll(r) r.Close() if readErr != nil { return nil, nil, fmt.Errorf("read %s: %w", f.Name, readErr) } return data, nil if f.Name == "word/document.xml" { document = data } else { styles = data } } if document == nil { return nil, nil, fmt.Errorf("%s is not a DOCX file (word/document.xml is missing)", path) } return nil, fmt.Errorf("%s is not a DOCX file (word/document.xml is missing)", path) return document, styles, nil } func convert(data []byte) (string, error) { return convertWithStyles(data, nil) } func convertWithStyles(data []byte, monoStyles map[string]struct{}) (string, error) { decoder := xml.NewDecoder(bytes.NewReader(data)) var blocks []string var code strings.Builder Loading Loading @@ -176,7 +202,7 @@ func convert(data []byte) (string, error) { if err := decoder.DecodeElement(&p, &start); err != nil { return "", err } kind, text := markdownParagraph(p) kind, text := markdownParagraph(p, monoStyles) if text == "" { continue } Loading @@ -195,7 +221,7 @@ func convert(data []byte) (string, error) { return "", err } flushCode() if text := markdownTable(tbl); text != "" { if text := markdownTable(tbl, monoStyles); text != "" { blocks = append(blocks, text) } } Loading @@ -204,11 +230,11 @@ func convert(data []byte) (string, error) { return strings.Join(blocks, "\n\n") + "\n", nil } func markdownParagraph(p paragraph) (kind, markdown string) { func markdownParagraph(p paragraph, monoStyles map[string]struct{}) (kind, markdown string) { style := strings.ToLower(p.Properties.Style.Value) text := strings.TrimSpace(runsMarkdown(p.Runs)) text := strings.TrimSpace(runsMarkdown(p.Runs, monoStyles)) for _, link := range p.Hyperlinks { text += runsMarkdown(link.Runs) text += runsMarkdown(link.Runs, monoStyles) } if text == "" { return "", "" Loading Loading @@ -257,68 +283,186 @@ func isCodeStyle(style string) bool { return false } func runsMarkdown(runs []run) string { // monoStyleIDs returns style IDs whose resolved font is monospace or whose // style is PL. Runs reference these IDs without repeating the font. func monoStyleIDs(stylesXML []byte) map[string]struct{} { if len(stylesXML) == 0 { return nil } var doc struct { Styles []wordStyle `xml:"style"` } if err := xml.Unmarshal(stylesXML, &doc); err != nil { return nil } byID := make(map[string]wordStyle, len(doc.Styles)) for _, st := range doc.Styles { byID[st.ID] = st } mono := make(map[string]struct{}) for id := range byID { if styleIsMono(id, byID, map[string]bool{}) { mono[id] = struct{}{} } } return mono } func styleIsMono(id string, styles map[string]wordStyle, seen map[string]bool) bool { if id == "" || seen[id] { return false } seen[id] = true st, ok := styles[id] if !ok { return isPLName(id) } if isPLName(id) || isPLName(st.Name.Value) { return true } if font := latinFont(st.RunProps.Fonts); font != "" { return isMonoFont(font) } return styleIsMono(st.BasedOn.Value, styles, seen) } func latinFont(f runFonts) string { if f.ASCII != "" { return f.ASCII } return f.HAnsi } func isPLName(name string) bool { switch strings.ToLower(strings.TrimSpace(name)) { case "pl", "plchar", "pl char": return true default: return false } } func isMonoFont(name string) bool { n := strings.ToLower(strings.TrimSpace(name)) switch { case n == "": return false case strings.Contains(n, "courier"), strings.Contains(n, "mono"), strings.Contains(n, "typewriter"), strings.Contains(n, "consolas"), strings.Contains(n, "cascadia"), n == "lucida console", n == "monaco", n == "menlo", n == "inconsolata": return true default: return false } } func runIsCode(r run, monoStyles map[string]struct{}) bool { if isPLName(r.Properties.Style.Value) { return true } if _, ok := monoStyles[r.Properties.Style.Value]; ok { return true } return isMonoFont(latinFont(r.Properties.Fonts)) } func runsMarkdown(runs []run, monoStyles map[string]struct{}) string { var b strings.Builder for _, r := range runs { var text strings.Builder for _, t := range r.Text { text.WriteString(t.Value) var code strings.Builder flushCode := func() { raw := code.String() code.Reset() if raw == "" { return } for range r.Tabs { text.WriteByte('\t') if strings.TrimSpace(raw) == "" { b.WriteString(raw) return } for range r.Breaks { text.WriteString(" \n") left := 0 for left < len(raw) && (raw[left] == ' ' || raw[left] == '\t') { left++ } s := text.String() right := len(raw) for right > left && (raw[right-1] == ' ' || raw[right-1] == '\t') { right-- } b.WriteString(raw[:left]) b.WriteByte('`') b.WriteString(strings.ReplaceAll(raw[left:right], "`", "\\`")) b.WriteByte('`') b.WriteString(raw[right:]) } for _, r := range runs { s := runText(r) if s == "" { continue } if *plain { b.WriteString(s) if !*plain && runIsCode(r, monoStyles) { code.WriteString(s) continue } inlineCode := strings.EqualFold(r.Properties.Style.Value, "PL") flushCode() b.WriteString(formatRun(s, r)) } flushCode() return b.String() } func formatRun(s string, r run) string { if *plain { return s } switch { case inlineCode: b.WriteString("`" + strings.ReplaceAll(s, "`", "\\`") + "`") case r.Properties.Bold != nil && r.Properties.Italic != nil: b.WriteString("***" + s + "***") return "***" + s + "***" case r.Properties.Bold != nil: b.WriteString("**" + s + "**") return "**" + s + "**" case r.Properties.Italic != nil: b.WriteString("_" + s + "_") return "_" + s + "_" default: b.WriteString(s) } return s } return b.String() } func runsPlain(runs []run) string { var b strings.Builder for _, r := range runs { func runText(r run) string { var text strings.Builder for _, t := range r.Text { b.WriteString(t.Value) text.WriteString(t.Value) } for range r.Tabs { b.WriteByte('\t') text.WriteByte('\t') } for range r.Breaks { b.WriteByte('\n') text.WriteString(" \n") } return text.String() } func runsPlain(runs []run) string { var b strings.Builder for _, r := range runs { text := runText(r) text = strings.ReplaceAll(text, " \n", "\n") b.WriteString(text) } return b.String() } func markdownTable(t table) string { func markdownTable(t table, monoStyles map[string]struct{}) string { var rows [][]string for _, row := range t.Rows { var cells []string for _, cell := range row.Cells { var parts []string for _, p := range cell.Paragraphs { _, text := markdownParagraph(p) _, text := markdownParagraph(p, monoStyles) if text != "" { parts = append(parts, strings.ReplaceAll(text, "|", "\\|")) } Loading
tools/docx2md/main_test.go +26 −0 Changes for tools/docx2md/main_test.go: 26 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -10,6 +10,9 @@ func TestConvert(t *testing.T) { <w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:t>5 Concepts</w:t></w:r></w:p> <w:p><w:r><w:t>Ordinary text.</w:t></w:r></w:p> <w:p><w:pPr><w:pStyle w:val="NO"/></w:pPr><w:r><w:t>NOTE: A note.</w:t></w:r></w:p> <w:p><w:r><w:t>Use </w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/><w:b/></w:rPr><w:t>runs</w:t></w:r><w:r><w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/><w:b/></w:rPr><w:t xml:space="preserve"> on</w:t></w:r><w:r><w:t> here.</w:t></w:r></w:p> <w:p><w:r><w:rPr><w:rStyle w:val="PLChar"/><w:b/></w:rPr><w:t>altstep</w:t></w:r></w:p> <w:p><w:r><w:rPr><w:b/></w:rPr><w:t>bold term</w:t></w:r></w:p> <w:p><w:pPr><w:pStyle w:val="PL"/></w:pPr><w:r><w:t>Identifier ::= "A" .</w:t></w:r></w:p> <w:tbl><w:tr><w:tc><w:p><w:r><w:t>Column</w:t></w:r></w:p></w:tc></w:tr><w:tr><w:tc><w:p><w:r><w:t>Value</w:t></w:r></w:p></w:tc></w:tr></w:tbl> </w:body></w:document>` Loading @@ -22,6 +25,9 @@ func TestConvert(t *testing.T) { "# Concepts", "Ordinary text.", "> NOTE: A note.", "Use `runs on` here.", "`altstep`", "**bold term**", "```ebnf\nIdentifier ::= \"A\" .\n```", "| Column |\n| --- |\n| Value |", } { Loading @@ -30,3 +36,23 @@ func TestConvert(t *testing.T) { } } } func TestStyledMonospaceRun(t *testing.T) { const styles = `<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"> <w:style w:styleId="HTMLCode"><w:name w:val="HTML Code"/><w:rPr><w:rFonts w:ascii="Courier New" w:hAnsi="Courier New"/></w:rPr></w:style> </w:styles>` const document = `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body> <w:p><w:r><w:rPr><w:rStyle w:val="HTMLCode"/><w:b/></w:rPr><w:t>keyword</w:t></w:r></w:p> </w:body></w:document>` got, err := convertWithStyles([]byte(document), monoStyleIDs([]byte(styles))) if err != nil { t.Fatal(err) } if !strings.Contains(got, "`keyword`") { t.Fatalf("got %q", got) } if strings.Contains(got, "**") { t.Fatalf("bold monospace span kept bold markup: %q", got) } }