Google Docs markDown text formatting

could you show us the output results how it looks like in google docs?

Thank you, working nice!

Thanks brahimh. It works.

Hi! Thanks for sharing your approach, it works really well for converting Markdown to a nicely formatted Google Doc. I do have one question for the community:

How can we keep (or re-apply) an existing footer (for example, a watermark, copyright, or branded block with a logo) after converting the document from Markdown?
Currently, when the script creates a new Google Doc from Markdown, the new document does not keep the original footer. Has anyone found a reliable way to automate the process of copying or re-applying a footer/watermark after the conversion, preferably with Apps Script?

If anyone has solved this or has a best practice to share, I’d really appreciate it!

Thanks in advance — the Markdown conversion workflow is amazing otherwise!

I use pandoc for this

Hey man can you tell me how this works? I work a lot with pandadoc

Works great. Thanks, @brahimh!

1 Like

Thank you @scottyb !

This helped! I saw that the code you provided didn’t have link conversion support. I have updated it according to my needs. You can check this out if you need a reference for link support.

// Function to convert Markdown to Google Docs API requests
function convertMarkdownToGoogleDocsRequests(markdown) {
    const requests = [];
    let index = 1; // Start inserting at position 1

    // Split by line breaks to handle formatting separately
    const lines = markdown.split('\n');
    
    for (let lineNum = 0; lineNum < lines.length; lineNum++) {
        const rawText = lines[lineNum].trim();
        
        // Handle empty lines
        if (!rawText) {
            // Insert just a newline for empty lines
            requests.push({
                insertText: {
                    location: { index },
                    text: "\n"
                }
            });
            index += 1;
            continue;
        }
        
        let text = rawText;
        const lineStartIndex = index;
        
        // Track how many characters we remove from the beginning
        let prefixRemoved = 0;
        
        // Remove the markdown symbols for heading and list indicators
        if (text.startsWith("# ")) {
            text = text.substring(2);
            prefixRemoved = 2;
        } else if (text.startsWith("## ")) {
            text = text.substring(3);
            prefixRemoved = 3;
        } else if (text.startsWith("### ")) {
            text = text.substring(4);
            prefixRemoved = 4;
        } else if (text.startsWith("#### ")) {
            text = text.substring(5);
            prefixRemoved = 5;
        } else if (text.startsWith("- ")) {
            text = text.substring(2).trimStart(); // Trim any extra spaces
            prefixRemoved = rawText.length - text.length;
        } else if (text.startsWith("* ")) {
            text = text.substring(2).trimStart(); // Trim any extra spaces
            prefixRemoved = rawText.length - text.length;
        } else if (/^\d+\.\s/.test(text)) {
            const match = text.match(/^\d+\.\s+/); // Match number, dot, and all spaces
            text = text.substring(match[0].length);
            prefixRemoved = match[0].length;
        }
        
        // Parse and process text with formatting
        const parsedContent = parseMarkdownLine(text);
        
        // Insert the clean text
        requests.push({
            insertText: {
                location: { index: lineStartIndex },
                text: parsedContent.cleanText + "\n"
            }
        });
        
        // Calculate the actual end position of the text (before newline)
        const textEndIndex = lineStartIndex + parsedContent.cleanText.length;
        
        // Apply paragraph styles ONLY if there's actual content
        if (parsedContent.cleanText.length > 0) {
            // Apply heading styles
            if (rawText.startsWith("# ")) {
                requests.push({
                    updateParagraphStyle: {
                        range: { startIndex: lineStartIndex, endIndex: textEndIndex },
                        paragraphStyle: { namedStyleType: "HEADING_1" },
                        fields: "namedStyleType"
                    }
                });
            } else if (rawText.startsWith("## ")) {
                requests.push({
                    updateParagraphStyle: {
                        range: { startIndex: lineStartIndex, endIndex: textEndIndex },
                        paragraphStyle: { namedStyleType: "HEADING_2" },
                        fields: "namedStyleType"
                    }
                });
            } else if (rawText.startsWith("### ")) {
                requests.push({
                    updateParagraphStyle: {
                        range: { startIndex: lineStartIndex, endIndex: textEndIndex },
                        paragraphStyle: { namedStyleType: "HEADING_3" },
                        fields: "namedStyleType"
                    }
                });
            } else if (rawText.startsWith("#### ")) {
                requests.push({
                    updateParagraphStyle: {
                        range: { startIndex: lineStartIndex, endIndex: textEndIndex },
                        paragraphStyle: { namedStyleType: "HEADING_4" },
                        fields: "namedStyleType"
                    }
                });
            } else if (rawText.startsWith("- ") || rawText.startsWith("* ")) {
                // Format as bulleted list
                requests.push({
                    createParagraphBullets: {
                        range: { startIndex: lineStartIndex, endIndex: textEndIndex },
                        bulletPreset: "BULLET_DISC_CIRCLE_SQUARE"
                    }
                });
                
                // Add proper indentation
                requests.push({
                    updateParagraphStyle: {
                        range: { startIndex: lineStartIndex, endIndex: textEndIndex },
                        paragraphStyle: {
                            indentStart: { magnitude: 36, unit: "PT" }
                        },
                        fields: "indentStart"
                    }
                });
            } else if (/^\d+\.\s/.test(rawText)) {
                // Format as numbered list
                requests.push({
                    createParagraphBullets: {
                        range: { startIndex: lineStartIndex, endIndex: textEndIndex },
                        bulletPreset: "NUMBERED_DECIMAL_ALPHA_ROMAN"
                    }
                });
                
                // Add proper indentation
                requests.push({
                    updateParagraphStyle: {
                        range: { startIndex: lineStartIndex, endIndex: textEndIndex },
                        paragraphStyle: {
                            indentStart: { magnitude: 36, unit: "PT" }
                        },
                        fields: "indentStart"
                    }
                });
            }
        }
        
        // Apply text formatting
        for (const format of parsedContent.formatting) {
            const startPos = lineStartIndex + format.start;
            const endPos = lineStartIndex + format.end;
            
            // Ensure we're not going beyond the text bounds
            if (endPos > textEndIndex) {
                console.warn(`Skipping formatting that would exceed text bounds at ${startPos}-${endPos}, text ends at ${textEndIndex}`);
                continue;
            }
            
            const textStyle = {};
            const fields = [];
            
            if (format.bold) {
                textStyle.bold = true;
                fields.push("bold");
            }
            if (format.italic) {
                textStyle.italic = true;
                fields.push("italic");
            }
            if (format.link) {
                textStyle.link = { url: format.link };
                fields.push("link");
            }
            
            if (fields.length > 0) {
                requests.push({
                    updateTextStyle: {
                        range: {
                            startIndex: startPos,
                            endIndex: endPos
                        },
                        textStyle: textStyle,
                        fields: fields.join(",")
                    }
                });
            }
        }
        
        // Update index for next line (clean text length + newline)
        index = textEndIndex + 1;
    }

    return requests;
}

// Helper function to parse markdown formatting in a line
function parseMarkdownLine(text) {
    const formatting = [];
    let cleanText = '';
    let currentPos = 0;
    let cleanPos = 0;
    
    // Process character by character to handle nested formatting
    while (currentPos < text.length) {
        // Check for bold
        if (text.substring(currentPos, currentPos + 2) === '**') {
            const boldEnd = text.indexOf('**', currentPos + 2);
            if (boldEnd !== -1) {
                const boldContent = text.substring(currentPos + 2, boldEnd);
                
                // Check if bold contains a link
                const linkMatch = /^\[([^\]]+)\]\(([^)]+)\)$/.exec(boldContent);
                if (linkMatch) {
                    // Bold link combo
                    const linkText = linkMatch[1];
                    const linkUrl = linkMatch[2];
                    
                    formatting.push({
                        start: cleanPos,
                        end: cleanPos + linkText.length,
                        bold: true,
                        link: linkUrl
                    });
                    
                    cleanText += linkText;
                    cleanPos += linkText.length;
                    currentPos = boldEnd + 2;
                } else {
                    // Regular bold
                    const innerParsed = parseMarkdownLine(boldContent);
                    
                    // Add bold formatting to all inner content
                    for (const innerFormat of innerParsed.formatting) {
                        formatting.push({
                            start: cleanPos + innerFormat.start,
                            end: cleanPos + innerFormat.end,
                            bold: true,
                            italic: innerFormat.italic,
                            link: innerFormat.link
                        });
                    }
                    
                    // If no inner formatting, add bold to entire content
                    if (innerParsed.formatting.length === 0) {
                        formatting.push({
                            start: cleanPos,
                            end: cleanPos + innerParsed.cleanText.length,
                            bold: true
                        });
                    }
                    
                    cleanText += innerParsed.cleanText;
                    cleanPos += innerParsed.cleanText.length;
                    currentPos = boldEnd + 2;
                }
                continue;
            }
        }
        
        // Check for italic
        if (text[currentPos] === '*' && 
            (currentPos === 0 || text[currentPos - 1] !== '*') &&
            (currentPos + 1 < text.length && text[currentPos + 1] !== '*')) {
            const italicEnd = text.indexOf('*', currentPos + 1);
            if (italicEnd !== -1 && 
                (italicEnd + 1 >= text.length || text[italicEnd + 1] !== '*')) {
                const italicContent = text.substring(currentPos + 1, italicEnd);
                const innerParsed = parseMarkdownLine(italicContent);
                
                // Add italic formatting to all inner content
                for (const innerFormat of innerParsed.formatting) {
                    formatting.push({
                        start: cleanPos + innerFormat.start,
                        end: cleanPos + innerFormat.end,
                        bold: innerFormat.bold,
                        italic: true,
                        link: innerFormat.link
                    });
                }
                
                // If no inner formatting, add italic to entire content
                if (innerParsed.formatting.length === 0) {
                    formatting.push({
                        start: cleanPos,
                        end: cleanPos + innerParsed.cleanText.length,
                        italic: true
                    });
                }
                
                cleanText += innerParsed.cleanText;
                cleanPos += innerParsed.cleanText.length;
                currentPos = italicEnd + 1;
                continue;
            }
        }
        
        // Check for link
        if (text[currentPos] === '[') {
            const linkTextEnd = text.indexOf(']', currentPos + 1);
            if (linkTextEnd !== -1 && text[linkTextEnd + 1] === '(') {
                const linkUrlEnd = text.indexOf(')', linkTextEnd + 2);
                if (linkUrlEnd !== -1) {
                    const linkText = text.substring(currentPos + 1, linkTextEnd);
                    const linkUrl = text.substring(linkTextEnd + 2, linkUrlEnd);
                    
                    formatting.push({
                        start: cleanPos,
                        end: cleanPos + linkText.length,
                        link: linkUrl
                    });
                    
                    cleanText += linkText;
                    cleanPos += linkText.length;
                    currentPos = linkUrlEnd + 1;
                    continue;
                }
            }
        }
        
        // Regular character
        cleanText += text[currentPos];
        cleanPos += 1;
        currentPos += 1;
    }
    
    return {
        cleanText: cleanText,
        formatting: formatting
    };
}
1 Like

Great Starting point, thank you.

I’ve extended this to handle:

  1. formatting “strong” and “em”
  2. Tables

Hope this helps

/**
 * Google Apps Script to convert an HTML string into a formatted Google Doc.
 * Supports headings, paragraphs, nested lists, tables, and basic inline formatting (<strong>/<b>, <em>/<i>).
 *
 * USAGE:
 * 1. Add this script to a Google Apps Script project.
 * 2. Create a wrapper to pass your HTML string to the converter. For example:
 *     function runConversion() {
 *       var html = `<!DOCTYPE html>...YOUR HTML HERE...`;
 *       convertHtmlToGoogleDoc(html);
 *     }
 * 3. Run `runConversion` to generate the Google Doc.
 *    Make sure to include the ``(backtick) delimiters if using string literals.
 *
 * LIMITATIONS:
 * - CSS styles (in <style> tags or classes) are NOT preserved automatically.
 *   Google Docs only supports programmatic styling via DocumentApp APIs.
 * - You can extend applyInlineStyles() to handle <span style="..."> and map
 *   color/font/size manually using setForegroundColor, setFontFamily, etc.
 */
function convertHtmlToGoogleDoc(htmlString) {
  if (typeof htmlString !== 'string' || !htmlString.trim()) {
    throw new Error('Please provide a valid HTML string.');
  }
  // Extract body content
  var match = htmlString.match(/<body[^>]*>([\s\S]*)<\/body>/i);
  var html = match ? match[1] : htmlString;
  // Normalize line breaks and self-closing tags
  html = html.replace(/<br\s*\/?>/gi, '<br/>')
             .replace(/<hr\s*\/?>/gi, '<hr/>');

  var doc = DocumentApp.create('Converted Document');
  var body = doc.getBody();
  // Manual tokenization
  var tokenRegex = /(<\/?.+?>)|([^<]+)/g;
  var listStack = [];
  var collecting = null;
  var buffer = '';

  function flushBuffer() {
    if (!buffer) return;
    var element;
    // Prepare text: replace <br/> and strip inline tags
    var cleaned = buffer.trim().replace(/<br\/>/g, '\n').replace(/<\/?(?:strong|b|em|i)>/gi, '');
    if (collecting && collecting.match(/^h[1-6]$/)) {
      element = body.appendParagraph(cleaned);
      element.setHeading(DocumentApp.ParagraphHeading['HEADING' + collecting.charAt(1)]);
    } else if (collecting === 'li') {
      element = body.appendListItem(cleaned);
      var lvl = listStack.length - 1;
      element.setNestingLevel(Math.max(0, lvl));
      element.setGlyphType(listStack[listStack.length - 1] === 'ul'
        ? DocumentApp.GlyphType.BULLET
        : DocumentApp.GlyphType.NUMBER);
    } else {
      element = body.appendParagraph(cleaned);
    }
    applyInlineStyles(element, buffer);
    buffer = '';
  }

  var m;
  while ((m = tokenRegex.exec(html))) {
    var tag = m[1], text = m[2];
    if (tag) {
      var nm = tag.match(/^<\s*(\/)?\s*([a-z0-9]+)[^>]*>/i);
      if (!nm) continue;
      var closing = !!nm[1];
      var tname = nm[2].toLowerCase();
      if (closing && collecting === tname) {
        flushBuffer(); collecting = null;
      }
      switch (tname) {
        case 'strong': case 'b': case 'em': case 'i':
          // Keep inline tags in buffer for styling
          buffer += tag;
          break;
        case 'h1': case 'h2': case 'h3': case 'h4': case 'h5': case 'h6':
          if (!closing) collecting = tname;
          break;
        case 'p':
          if (!closing) collecting = 'p';
          break;
        case 'ul': case 'ol':
          if (!closing) {
            if (collecting === 'li' && buffer.trim()) { flushBuffer(); collecting = null; }
            listStack.push(tname);
          } else { listStack.pop(); }
          break;
        case 'li':
          if (!closing) collecting = 'li';
          break;
        case 'table':
          if (!closing) {
            var rest = html.substring(tokenRegex.lastIndex - tag.length);
            var end = rest.search(/<\/table>/i);
            if (end >= 0) {
              var tableHtml = rest.substring(0, end + 8);
              parseAndAppendTable(body, tableHtml);
              tokenRegex.lastIndex += end + 8 - tag.length;
            }
          }
          break;
        case 'br':
          buffer += '<br/>';
          break;
        default:
          break;
      }
    } else if (text !== undefined) {
      buffer += text;
    }
  }
  flushBuffer();
  Logger.log('Created doc at: ' + doc.getUrl());
}

function applyInlineStyles(paragraph, rawHtml) {
  var txt = paragraph.getText();
  var textObj = paragraph.editAsText();
  var boldRegex = /<(?:strong|b)>([\s\S]*?)<\/(?:strong|b)>/gi;
  var match, offset = 0;
  while ((match = boldRegex.exec(rawHtml))) {
    var snippet = match[1];
    var idx = txt.indexOf(snippet, offset);
    if (idx >= 0) {
      textObj.setBold(idx, idx + snippet.length - 1, true);
      offset = idx + snippet.length;
    }
  }
  var italRegex = /<(?:em|i)>([\s\S]*?)<\/(?:em|i)>/gi;
  offset = 0;
  while ((match = italRegex.exec(rawHtml))) {
    var snippet = match[1];
    var idx = txt.indexOf(snippet, offset);
    if (idx >= 0) {
      textObj.setItalic(idx, idx + snippet.length - 1, true);
      offset = idx + snippet.length;
    }
  }
}

function parseAndAppendTable(body, html) {
  var table = body.appendTable();
  var rows = html.match(/<tr[\s\S]*?<\/tr>/gi) || [];
  rows.forEach(function(r) {
    var row = table.appendTableRow();
    var cells = r.match(/<(td|th)[^>]*>([\s\S]*?)<\/(?:td|th)>/gi) || [];
    cells.forEach(function(c) {
      var txt = c.replace(/<[^>]+>/g, '')
                 .replace(/&amp;/g, '&')
                 .trim();
      row.appendTableCell(txt);
    });
  });
}
2 Likes

Hi! @brahimh
If you’re still looking for a solution to automatically format Markdown content into Google Docs via n8n, there is now a community node that does exactly that:
n8n-nodes-md-to-docs

  • You don’t need to use HTTP Request nodes or custom scripts – this node directly creates a properly formatted Google Doc in your Google Drive.
  • It supports headings, lists, bold, links, and other essential Markdown elements.
  • Works with n8n’s Google OAuth credentials – no extra authentication required.
  • The integration is as simple as possible: just provide your Markdown text and you’ll get a link to the formatted Google Doc.
  • It’s perfect for automating LLM/AI outputs that are in Markdown.

If you need more details or an example configuration, let me know!
For a real-world example and more discussion, check out my post here: Markdown to Google Docs
Hope this helps.

2 Likes

Do you know maybe solution to reverse this?

Like getting markdown from Google Docs which was formatted using yours solutions?

I’m thinking about reusing content that was saved to Google Docs format but right now when I get content using Google Docs node it’s returning me only text without formatting.

Hi!
I’m actually planning to work on the reverse process as well—converting Google Docs back to Markdown with formatting.
This feature will be added to the current “n8n-nodes-md-to-docs” node.
Once I have something ready, I’ll share an update here!

Hi @rafuru,

Thanks for your interest in the reverse conversion! I wanted to let you know that support for exporting Google Docs back to Markdown (with formatting preserved) is now available in the latest version of the “n8n-nodes-md-to-docs” node (v0.4.0).

You can use the new exportGoogleDoc operation to export any Google Doc directly to Markdown, PDF, or plain text. The Markdown export will retain headings, lists, bold/italic formatting, links, tables, and more—so it’s ideal for reusing content or moving documents between systems.

You can select the document by list, URL, or ID, and optionally save the output back to Google Drive. If you need an example workflow or have any questions, just let me know!

Hope this helps!

1 Like

wow, you saved me literally three days of work. I had like half a dozen literally different combinations. Nothing can really make it work and actually format the document. Whatever you got going on good. Good job, thumbs up.

1 Like

I had same problem, so i maked solution with less code and without extrnal hardcoded transforming MD to HTML. I shared it on templates: Convert Markdown content to Google Docs document with automatic formatting | n8n workflow template

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.