Commit f9b13f69 authored by Matthias Simon's avatar Matthias Simon
Browse files

Render monospace and PL spans as backtick code.

Co-authored-by: default avatarCursor <cursoragent@cursor.com>
parent a49e219e
Loading
Loading
Loading
Loading
+190 −46
Changes for tools/docx2md/main.go: 190 added lines, 46 removed lines.
Original line number Diff line number Diff line
@@ -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 {
@@ -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)
	}
@@ -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
@@ -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
			}
@@ -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)
			}
		}
@@ -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 "", ""
@@ -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, "|", "\\|"))
				}
+26 −0
Changes for tools/docx2md/main_test.go: 26 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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>`
@@ -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 |",
	} {
@@ -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)
	}
}