DEV Community

eimza
eimza

Posted on

Building XML-to-Markdown Converters: Algorithms and Edge Cases

Converting XML documents to Markdown sounds simple until you actually try it. Here's what we learned building a converter for Turkey's UYAP legal document format.

The Core Algorithm

XML DOM → Walk tree → Emit Markdown tokens → Join & clean
Enter fullscreen mode Exit fullscreen mode

Step 1: Parse XML

const parser = new DOMParser();
const doc = parser.parseFromString(xmlText, "text/xml");

// Always check for parse errors!
const err = doc.querySelector("parsererror");
if (err) throw new Error("Invalid XML: " + err.textContent);
Enter fullscreen mode Exit fullscreen mode

Step 2: Walk the Element Tree

function walkElements(container) {
    let md = "";
    for (const child of container.children) {
        switch (child.nodeName.toLowerCase()) {
            case "paragraph": md += paragraphToMd(child) + "\n\n"; break;
            case "table":     md += tableToMd(child) + "\n\n"; break;
            case "image":     md += imageToMd(child) + "\n\n"; break;
        }
    }
    return md;
}
Enter fullscreen mode Exit fullscreen mode

Step 3: Inline Formatting

The tricky part — XML attributes map to Markdown syntax:

function formatText(text, attrs) {
    if (attrs.bold && attrs.italic) return `***${text}***`;
    if (attrs.bold) return `**${text}**`;
    if (attrs.italic) return `*${text}*`;
    return text;
}
Enter fullscreen mode Exit fullscreen mode

Edge case: What if bold and italic spans overlap partially? XML handles this naturally with separate <content> elements, but you need to be careful not to produce broken Markdown like **bold *both** italic*.

Heading Detection Heuristics

UYAP XML doesn't have explicit heading elements. We detect headings via heuristics:

function isHeading(paragraph, firstContent) {
    const fontSize = parseInt(firstContent.getAttribute("fontSize") || "12");
    const isBold = firstContent.getAttribute("bold") === "true";
    const isCenter = paragraph.getAttribute("alignment") === "2";
    const text = paragraph.textContent.trim();

    // Short, large-font text → H1
    if (fontSize >= 18 && text.length < 120) return 1;
    // Bold + large or bold + centered → H2
    if (fontSize >= 14 && isBold && text.length < 120) return 2;
    if (isBold && isCenter && text.length < 120) return 2;

    return 0; // Not a heading
}
Enter fullscreen mode Exit fullscreen mode

Table Conversion

XML tables → Markdown tables require knowing the column count upfront:

function tableToMd(tableNode) {
    const rows = tableNode.querySelectorAll("row");
    let md = "";
    let headerDone = false;

    for (const row of rows) {
        const cells = [...row.querySelectorAll("cell")]
            .map(c => c.textContent.trim().replace(/\|/g, "\\|"));

        md += "| " + cells.join(" | ") + " |\n";

        if (!headerDone) {
            md += "| " + cells.map(() => "---").join(" | ") + " |\n";
            headerDone = true;
        }
    }
    return md;
}
Enter fullscreen mode Exit fullscreen mode

Edge case: Cells containing pipe characters (|) must be escaped, or the table breaks.

Character Encoding

Turkish legal documents may use Windows-1254 encoding:

// Try UTF-8 first
let text = new TextDecoder("utf-8").decode(buffer);

// If it looks garbled, try Windows-1254
if (!text.includes("<template")) {
    text = new TextDecoder("windows-1254").decode(buffer);
}
Enter fullscreen mode Exit fullscreen mode

The Result

Input: Opaque binary .udf file
Output: Clean, structured Markdown that any LLM can understand

Check out the full implementation: github.com/eimza-kep/udf2md

Top comments (0)