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

Add a DOCX to Markdown converter for version 5 chapters.

Co-authored-by: default avatarCursor <cursoragent@cursor.com>
parent 4bcfa05b
Loading
Loading
Loading
Loading

tools/docx2md/go.mod

0 → 100644
+3 −0
Changes for tools/docx2md/go.mod: 3 added lines, 0 removed lines.
Original line number Diff line number Diff line
module etsi.org/standard/tools/docx2md

go 1.23.0

tools/docx2md/main.go

0 → 100644
+375 −0
Changes for tools/docx2md/main.go: 375 added lines, 0 removed lines.
Original line number Diff line number Diff line
// docx2md converts a TTCN-3 working-document chapter to the Markdown format
// used by ../../src.
package main

import (
	"archive/zip"
	"bytes"
	"encoding/xml"
	"flag"
	"fmt"
	"io"
	"os"
	"regexp"
	"strings"
)

const wordNS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"

var (
	output = flag.String("o", "", "write Markdown to this file instead of standard output")
	plain  = flag.Bool("plain", false, "omit Markdown formatting inferred from Word run formatting")
)

type paragraph struct {
	Properties paragraphProperties `xml:"pPr"`
	Runs       []run               `xml:"r"`
	Hyperlinks []hyperlink         `xml:"hyperlink"`
}

type paragraphProperties struct {
	Style     styleRef  `xml:"pStyle"`
	Numbering numbering `xml:"numPr"`
}

type styleRef struct {
	Value string `xml:"val,attr"`
}

type numbering struct {
	Level numberRef `xml:"ilvl"`
	ID    numberRef `xml:"numId"`
}

type numberRef struct {
	Value string `xml:"val,attr"`
}

type hyperlink struct {
	Runs []run `xml:"r"`
}

type run struct {
	Properties runProperties `xml:"rPr"`
	Text       []text        `xml:"t"`
	Tabs       []struct{}    `xml:"tab"`
	Breaks     []struct{}    `xml:"br"`
}

type text struct {
	Value string `xml:",chardata"`
}

type runProperties struct {
	Bold   *struct{} `xml:"b"`
	Italic *struct{} `xml:"i"`
	Style  styleRef  `xml:"rStyle"`
}

type table struct {
	Rows []tableRow `xml:"tr"`
}

type tableRow struct {
	Cells []tableCell `xml:"tc"`
}

type tableCell struct {
	Paragraphs []paragraph `xml:"p"`
}

func main() {
	flag.Usage = func() {
		fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [-o output.md] [-plain] input.docx\n", os.Args[0])
		flag.PrintDefaults()
	}
	flag.Parse()
	if flag.NArg() != 1 {
		flag.Usage()
		os.Exit(2)
	}

	xmlData, err := wordDocument(flag.Arg(0))
	if err != nil {
		fatal(err)
	}
	markdown, err := convert(xmlData)
	if err != nil {
		fatal(err)
	}
	if *output == "" {
		_, err = os.Stdout.WriteString(markdown)
	} else {
		err = os.WriteFile(*output, []byte(markdown), 0o644)
	}
	if err != nil {
		fatal(err)
	}
}

func wordDocument(path string) ([]byte, error) {
	zr, err := zip.OpenReader(path)
	if err != nil {
		return nil, fmt.Errorf("open %s: %w", path, err)
	}
	defer zr.Close()
	for _, f := range zr.File {
		if f.Name != "word/document.xml" {
			continue
		}
		r, err := f.Open()
		if err != nil {
			return nil, fmt.Errorf("open document.xml: %w", err)
		}
		defer r.Close()
		data, err := io.ReadAll(r)
		if err != nil {
			return nil, fmt.Errorf("read document.xml: %w", err)
		}
		return data, nil
	}
	return nil, fmt.Errorf("%s is not a DOCX file (word/document.xml is missing)", path)
}

func convert(data []byte) (string, error) {
	decoder := xml.NewDecoder(bytes.NewReader(data))
	var blocks []string
	var code strings.Builder
	var inBody bool

	flushCode := func() {
		if code.Len() == 0 {
			return
		}
		text := code.String()
		language := "ttcn3"
		if strings.Contains(text, "::=") {
			language = "ebnf"
		}
		blocks = append(blocks, "```"+language+"\n"+text+"\n```")
		code.Reset()
	}

	for {
		tok, err := decoder.Token()
		if err == io.EOF {
			break
		}
		if err != nil {
			return "", fmt.Errorf("decode document.xml: %w", err)
		}
		start, ok := tok.(xml.StartElement)
		if !ok {
			continue
		}
		if start.Name.Space == wordNS && start.Name.Local == "body" {
			inBody = true
			continue
		}
		if !inBody || start.Name.Space != wordNS {
			continue
		}

		switch start.Name.Local {
		case "p":
			var p paragraph
			if err := decoder.DecodeElement(&p, &start); err != nil {
				return "", err
			}
			kind, text := markdownParagraph(p)
			if text == "" {
				continue
			}
			if kind == "code" {
				if code.Len() > 0 {
					code.WriteByte('\n')
				}
				code.WriteString(text)
			} else {
				flushCode()
				blocks = append(blocks, text)
			}
		case "tbl":
			var tbl table
			if err := decoder.DecodeElement(&tbl, &start); err != nil {
				return "", err
			}
			flushCode()
			if text := markdownTable(tbl); text != "" {
				blocks = append(blocks, text)
			}
		}
	}
	flushCode()
	return strings.Join(blocks, "\n\n") + "\n", nil
}

func markdownParagraph(p paragraph) (kind, markdown string) {
	style := strings.ToLower(p.Properties.Style.Value)
	text := strings.TrimSpace(runsMarkdown(p.Runs))
	for _, link := range p.Hyperlinks {
		text += runsMarkdown(link.Runs)
	}
	if text == "" {
		return "", ""
	}

	if level := headingLevel(style); level > 0 {
		return "heading", strings.Repeat("#", level) + " " + withoutClauseNumber(text)
	}
	if isCodeStyle(style) {
		return "code", strings.TrimSpace(runsPlain(p.Runs))
	}
	if p.Properties.Numbering.ID.Value != "" {
		indent := strings.Repeat("  ", number(p.Properties.Numbering.Level.Value))
		if p.Properties.Numbering.ID.Value == "0" {
			return "paragraph", indent + "- " + text
		}
		return "paragraph", indent + "- " + text
	}
	if style == "no" || strings.HasPrefix(strings.ToUpper(text), "NOTE:") {
		return "paragraph", "> " + text
	}
	return "paragraph", text
}

var clauseNumber = regexp.MustCompile(`^\d+(?:\.\d+)*\.?\s+`)

func withoutClauseNumber(text string) string {
	return clauseNumber.ReplaceAllString(text, "")
}

var headingStyle = regexp.MustCompile(`(?:heading|h)[ _-]?([1-9])$`)

func headingLevel(style string) int {
	m := headingStyle.FindStringSubmatch(style)
	if len(m) != 2 {
		return 0
	}
	return int(m[1][0] - '0')
}

func isCodeStyle(style string) bool {
	switch style {
	case "pl", "code", "codeblock", "preformatted", "grammar":
		return true
	}
	return false
}

func runsMarkdown(runs []run) string {
	var b strings.Builder
	for _, r := range runs {
		var text strings.Builder
		for _, t := range r.Text {
			text.WriteString(t.Value)
		}
		for range r.Tabs {
			text.WriteByte('\t')
		}
		for range r.Breaks {
			text.WriteString("  \n")
		}
		s := text.String()
		if s == "" {
			continue
		}
		if *plain {
			b.WriteString(s)
			continue
		}
		inlineCode := strings.EqualFold(r.Properties.Style.Value, "PL")
		switch {
		case inlineCode:
			b.WriteString("`" + strings.ReplaceAll(s, "`", "\\`") + "`")
		case r.Properties.Bold != nil && r.Properties.Italic != nil:
			b.WriteString("***" + s + "***")
		case r.Properties.Bold != nil:
			b.WriteString("**" + s + "**")
		case r.Properties.Italic != nil:
			b.WriteString("_" + s + "_")
		default:
			b.WriteString(s)
		}
	}
	return b.String()
}

func runsPlain(runs []run) string {
	var b strings.Builder
	for _, r := range runs {
		for _, t := range r.Text {
			b.WriteString(t.Value)
		}
		for range r.Tabs {
			b.WriteByte('\t')
		}
		for range r.Breaks {
			b.WriteByte('\n')
		}
	}
	return b.String()
}

func markdownTable(t table) 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)
				if text != "" {
					parts = append(parts, strings.ReplaceAll(text, "|", "\\|"))
				}
			}
			cells = append(cells, strings.Join(parts, "<br>"))
		}
		if len(cells) > 0 {
			rows = append(rows, cells)
		}
	}
	if len(rows) == 0 {
		return ""
	}
	var b strings.Builder
	writeTableRow(&b, rows[0])
	b.WriteByte('\n')
	writeTableRow(&b, make([]string, len(rows[0])))
	b.WriteByte('\n')
	for _, row := range rows[1:] {
		for len(row) < len(rows[0]) {
			row = append(row, "")
		}
		writeTableRow(&b, row[:len(rows[0])])
		b.WriteByte('\n')
	}
	return strings.TrimSpace(b.String())
}

func writeTableRow(b *strings.Builder, cells []string) {
	b.WriteString("|")
	for _, cell := range cells {
		if cell == "" {
			b.WriteString(" --- |")
		} else {
			b.WriteString(" " + cell + " |")
		}
	}
}

func number(s string) int {
	var n int
	for _, c := range s {
		if c < '0' || c > '9' {
			return 0
		}
		n = n*10 + int(c-'0')
	}
	return n
}

func fatal(err error) {
	fmt.Fprintln(os.Stderr, "docx2md:", err)
	os.Exit(1)
}
+32 −0
Changes for tools/docx2md/main_test.go: 32 added lines, 0 removed lines.
Original line number Diff line number Diff line
package main

import (
	"strings"
	"testing"
)

func TestConvert(t *testing.T) {
	const document = `<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"><w:body>
<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: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>`

	got, err := convert([]byte(document))
	if err != nil {
		t.Fatal(err)
	}
	for _, want := range []string{
		"# Concepts",
		"Ordinary text.",
		"> NOTE: A note.",
		"```ebnf\nIdentifier ::= \"A\" .\n```",
		"| Column |\n| --- |\n| Value |",
	} {
		if !strings.Contains(got, want) {
			t.Errorf("converted Markdown does not contain %q:\n%s", want, got)
		}
	}
}