Google Docs markDown text formatting

Using Google Docs as a tool for an ai agent, I want the agent to put all the output of the analysis in a google doc, the output is in MarkDown, I want the Headlines, Hyperlinks, titles,… to be formatted rather than just be normal text.

Should be more like this:

1 Like

It looks like your topic is missing some important information. Could you provide the following if applicable.

  • n8n version:
  • Database (default: SQLite):
  • n8n EXECUTIONS_PROCESS setting (default: own, main):
  • Running n8n via (Docker, npm, n8n cloud, desktop app):
  • Operating system:

Have you tried this?

Seems too complex of a solution for a simple problem, I just setup an ‘API’ in make.com as a work-around.

It receives the HTML content via webhook and puts it in a google doc then returns the link to the google doc:

A secondary solution I came up with is using the https://hackmd.io/ api to create formatted docs.

But having this within n8n natively is very important still!

Hi guys,

I ended up creating a code node to do this. Claude seemed better than ChatGPT in generating tihs code - and here are the docs if you need to get it to add more functionality: Requests  |  Google Docs  |  Google for Developers

const markdown = $input.first().json.data;

// 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 (const line of lines) {
        const rawText = line.trim();
        if (!rawText) continue;
        
        let text = rawText;
        const startIndex = index;
        
        // Remove the markdown symbols for heading and list indicators before inserting
        if (text.startsWith("# ")) {
            text = text.substring(2);
        } else if (text.startsWith("## ")) {
            text = text.substring(3);
        } else if (text.startsWith("### ")) {
            text = text.substring(4);
        } else if (text.startsWith("#### ")) {
            text = text.substring(5);
        } else if (text.startsWith("- ")) {
            text = text.substring(2);
        } else if (text.startsWith("* ")) {
            text = text.substring(2);
        } else if (/^\d+\.\s/.test(text)) {
            text = text.replace(/^\d+\.\s/, "");
        }
        
        // Remove bold and italic markdown before inserting text
        // Store positions for later styling
        const boldRanges = [];
        const italicRanges = [];
        
        // Find bold text positions (before removing markers)
        let boldText = text;
        const boldRegex = /\*\*(.*?)\*\*/g;
        let boldMatch;
        while ((boldMatch = boldRegex.exec(text)) !== null) {
            const innerText = boldMatch[1];
            const start = boldMatch.index;
            const end = start + boldMatch[0].length;
            
            boldRanges.push({
                text: innerText,
                originalStart: start,
                originalEnd: end
            });
        }
        
        // Find italic text positions (before removing markers)
        let italicMatch;
        const italicRegex = /(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g;
        while ((italicMatch = italicRegex.exec(text)) !== null) {
            const innerText = italicMatch[1];
            const start = italicMatch.index;
            const end = start + italicMatch[0].length;
            
            italicRanges.push({
                text: innerText,
                originalStart: start,
                originalEnd: end
            });
        }
        
        // Remove markdown indicators for bold and italic
        text = text.replace(/\*\*(.*?)\*\*/g, "$1");
        text = text.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, "$1");
        
        // Insert clean text without markdown
        requests.push({
            insertText: {
                location: { index: startIndex },
                text: text + "\n"
            }
        });
        
        const textEndIndex = startIndex + text.length;
        
        // Apply heading styles based on original markdown
        if (rawText.startsWith("# ")) {
            requests.push({
                updateParagraphStyle: {
                    range: { startIndex, endIndex: textEndIndex },
                    paragraphStyle: { namedStyleType: "HEADING_1" },
                    fields: "namedStyleType"
                }
            });
        } else if (rawText.startsWith("## ")) {
            requests.push({
                updateParagraphStyle: {
                    range: { startIndex, endIndex: textEndIndex },
                    paragraphStyle: { namedStyleType: "HEADING_2" },
                    fields: "namedStyleType"
                }
            });
        } else if (rawText.startsWith("### ")) {
            requests.push({
                updateParagraphStyle: {
                    range: { startIndex, endIndex: textEndIndex },
                    paragraphStyle: { namedStyleType: "HEADING_3" },
                    fields: "namedStyleType"
                }
            });
        } else if (rawText.startsWith("#### ")) {
            requests.push({
                updateParagraphStyle: {
                    range: { startIndex, endIndex: textEndIndex },
                    paragraphStyle: { namedStyleType: "HEADING_4" },
                    fields: "namedStyleType"
                }
            });
        } else if (rawText.startsWith("- ") || rawText.startsWith("* ")) {
            // Format as bulleted list
            requests.push({
                createParagraphBullets: {
                    range: { startIndex, endIndex: textEndIndex },
                    bulletPreset: "BULLET_DISC_CIRCLE_SQUARE"
                }
            });
            
            // Add proper indentation
            requests.push({
                updateParagraphStyle: {
                    range: { startIndex, 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, endIndex: textEndIndex },
                    bulletPreset: "NUMBERED_DECIMAL_ALPHA_ROMAN"
                }
            });
            
            // Add proper indentation
            requests.push({
                updateParagraphStyle: {
                    range: { startIndex, endIndex: textEndIndex },
                    paragraphStyle: {
                        indentStart: { magnitude: 36, unit: "PT" }
                    },
                    fields: "indentStart"
                }
            });
        }
        
        // Calculate adjusted positions for bold and italic after removing markdown
        let adjustment = 0;
        let lastProcessedPos = 0;
        
        // Apply bold formatting (with adjusted positions)
        for (let i = 0; i < boldRanges.length; i++) {
            const range = boldRanges[i];
            // Adjust for any previous formatting markers removed
            const adjustedStart = startIndex + range.originalStart - adjustment;
            // Update adjustment for next element
            adjustment += 4; // ** at beginning and end (4 characters total)
            
            requests.push({
                updateTextStyle: {
                    range: {
                        startIndex: adjustedStart,
                        endIndex: adjustedStart + range.text.length
                    },
                    textStyle: { bold: true },
                    fields: "bold"
                }
            });
        }
        
        // Reset adjustment for italic formatting
        adjustment = 0;
        
        // Apply italic formatting (with adjusted positions after bold markdown is removed)
        for (let i = 0; i < italicRanges.length; i++) {
            const range = italicRanges[i];
            // Adjust for previous bold formatting and previous italic markers
            let adjustedStart = startIndex + range.originalStart;
            
            // Count how many bold markers appear before this italic position
            let boldAdjustment = 0;
            for (const boldRange of boldRanges) {
                if (boldRange.originalEnd <= range.originalStart) {
                    boldAdjustment += 4; // ** at start and end
                }
            }
            
            // Count how many italic markers appear before this position
            let italicAdjustment = 0;
            for (let j = 0; j < i; j++) {
                if (italicRanges[j].originalEnd <= range.originalStart) {
                    italicAdjustment += 2; // * at start and end
                }
            }
            
            adjustedStart -= (boldAdjustment + italicAdjustment);
            
            requests.push({
                updateTextStyle: {
                    range: {
                        startIndex: adjustedStart,
                        endIndex: adjustedStart + range.text.length
                    },
                    textStyle: { italic: true },
                    fields: "italic"
                }
            });
        }
        
        index = textEndIndex + 1; // +1 for the newline
    }

    return requests;
}

// Convert Markdown to Google Docs API requests
const requests = convertMarkdownToGoogleDocsRequests(markdown);

return {
  docs_request: { requests }
};

Note that it returns the request you’d need to send to Google Docs in the docs_request parameter, and then you can use it in a node like this:

1 Like

Awesome!
Thanks for sharing this, been trying to make it work with the code node for a while…

Based on the script you’ve shared, I created an HTML version, but it’s not quite there yet.

const html = $input.first().json.data;

function convertHtmlToGoogleDocsRequests(html) {
    const requests = [];
    let index = 1;

    // First, normalize the HTML by removing unnecessary whitespace
    let normalizedHtml = html.replace(/\s+/g, ' ').trim();
    
    // Split HTML into segments
    const segments = [];
    let currentText = '';
    let inTag = false;
    let currentTag = '';
    
    for (let i = 0; i < normalizedHtml.length; i++) {
        const char = normalizedHtml[i];
        if (char === '<') {
            if (currentText.trim()) {
                segments.push({ type: 'text', content: currentText.trim() });
            }
            currentText = '';
            inTag = true;
        } else if (char === '>' && inTag) {
            segments.push({ type: 'tag', content: currentTag.trim() });
            currentTag = '';
            inTag = false;
        } else if (inTag) {
            currentTag += char;
        } else {
            currentText += char;
        }
    }
    
    if (currentText.trim()) {
        segments.push({ type: 'text', content: currentText.trim() });
    }

    // Process segments
    const tagStack = [];
    const formatStack = [];
    
    for (let i = 0; i < segments.length; i++) {
        const segment = segments[i];
        
        if (segment.type === 'tag') {
            if (!segment.content.startsWith('/')) {
                // Opening tag
                const tagMatch = segment.content.match(/^(\w+)(\s|$)/);
                if (tagMatch) {
                    const tagName = tagMatch[1].toLowerCase();
                    tagStack.push({ name: tagName, startIndex: index });
                    
                    switch (tagName) {
                        case 'b':
                        case 'strong':
                            formatStack.push('bold');
                            break;
                        case 'i':
                        case 'em':
                            formatStack.push('italic');
                            break;
                    }
                }
            } else {
                // Closing tag
                const tagName = segment.content.substring(1).toLowerCase();
                const lastTag = tagStack.pop();
                
                if (lastTag && lastTag.name === tagName) {
                    switch (tagName) {
                        case 'h1':
                        case 'h2':
                        case 'h3':
                        case 'h4':
                        case 'h5':
                        case 'h6':
                            const level = parseInt(tagName.substring(1));
                            requests.push({
                                updateParagraphStyle: {
                                    range: { startIndex: lastTag.startIndex, endIndex: index },
                                    paragraphStyle: { namedStyleType: `HEADING_${level}` },
                                    fields: 'namedStyleType'
                                }
                            });
                            requests.push({
                                insertText: {
                                    location: { index: index },
                                    text: '\n'
                                }
                            });
                            index += 1;
                            break;
                            
                        case 'p':
                        case 'div':
                            requests.push({
                                insertText: {
                                    location: { index: index },
                                    text: '\n'
                                }
                            });
                            index += 1;
                            break;
                            
                        case 'li':
                            const parentList = tagStack.find(tag => tag.name === 'ul' || tag.name === 'ol');
                            if (parentList) {
                                const bulletPreset = parentList.name === 'ul' ? 
                                    'BULLET_DISC_CIRCLE_SQUARE' : 
                                    'NUMBERED_DECIMAL_ALPHA_ROMAN';
                                
                                requests.push({
                                    createParagraphBullets: {
                                        range: { startIndex: lastTag.startIndex, endIndex: index },
                                        bulletPreset: bulletPreset
                                    }
                                });
                                
                                requests.push({
                                    updateParagraphStyle: {
                                        range: { startIndex: lastTag.startIndex, endIndex: index },
                                        paragraphStyle: {
                                            indentStart: { magnitude: 36, unit: 'PT' }
                                        },
                                        fields: 'indentStart'
                                    }
                                });
                                
                                requests.push({
                                    insertText: {
                                        location: { index: index },
                                        text: '\n'
                                    }
                                });
                                index += 1;
                            }
                            break;
                            
                        case 'b':
                        case 'strong':
                            const boldIndex = formatStack.lastIndexOf('bold');
                            if (boldIndex !== -1) {
                                formatStack.splice(boldIndex, 1);
                                requests.push({
                                    updateTextStyle: {
                                        range: { startIndex: lastTag.startIndex, endIndex: index },
                                        textStyle: { bold: true },
                                        fields: 'bold'
                                    }
                                });
                            }
                            break;
                            
                        case 'i':
                        case 'em':
                            const italicIndex = formatStack.lastIndexOf('italic');
                            if (italicIndex !== -1) {
                                formatStack.splice(italicIndex, 1);
                                requests.push({
                                    updateTextStyle: {
                                        range: { startIndex: lastTag.startIndex, endIndex: index },
                                        textStyle: { italic: true },
                                        fields: 'italic'
                                    }
                                });
                            }
                            break;
                    }
                }
            }
        } else if (segment.type === 'text') {
            const text = segment.content;
            if (text.trim()) {
                requests.push({
                    insertText: {
                        location: { index: index },
                        text: text
                    }
                });
                index += text.length;
            }
        }
    }

    return requests;
}

return {
    docs_request: { requests: convertHtmlToGoogleDocsRequests(html) }
};

You could use the Markdown node to convert HTML to markdown first?

1 Like

Tried that, it doesn’t really convert correctly

Also the Markdown to google docs script doesn’t handle embedded images, I’ll try to add that…

Hello.
Just solved this issue for myself and want to share my approach.

(Assuming you have your google doc file with UNFORMATTED markdown inside)

  1. Create Google AppScript function
function convertFromMarkdown(file_id) {
  // Get the original Google Doc
  const file = DriveApp.getFileById(file_id);
  const originalFileName = file.getName();
  
  // Get the document content as plain text
  const docFile = DocumentApp.openById(file_id);
  const markdownContent = docFile.getBody().getText();
  
  // Create a blob with the markdown content
  const blob = Utilities.newBlob(markdownContent, 'text/markdown');
  
  // Set file metadata
  const fileMetadata = {
    name: `${originalFileName} (Formatted)`,
    mimeType: MimeType.GOOGLE_DOCS,
  };
  
  // Create the new Google Doc with properly formatted markdown
  const newFile = Drive.Files.create(fileMetadata, blob, { supportsAllDrives: true });
  
  // Return the new file ID
  return newFile.id;
}
  1. Then you can easily deploy it as API endpoint using Deploy button in AppScripts editor:
    Deploy > New Deployment > Select Type > API Executable

  2. Then using Http node with your google docs credentials selected you can call it and get id of copied and formatted file. Curl below:

curl -X POST \
  https://script.googleapis.com/v1/scripts/YOUR_SCRIPT_ID_HERE:run \
  -H "Content-Type: application/json" \
  -d '{
    "function": "convertFromMarkdown",
    "parameters": [
      "YOUR_FILE_ID_HERE"
    ],
    "devMode": false
  }'

Thats all! I think this works much better then other custom scripts (especially with tables).

1 Like

I have a question, might be stupid but lets try it :slight_smile:

I found myself fighting with Document ID in my drive, PDf file change id everytime I try to reuse them for testing.

newFiles
newFiles[0]
id : 1oLuiX-vDS_BbVQnx9cwEcvydljzjthaf

name : Numérisation_20240828 (3).pdf

deletedFiles
deletedFiles[0] : 1jpsYB9_NIrAWjaCkA8p5dxvJyub_MuAw

I use this file once, the id was written to my Sheet, and move to a folder.
I pick it up agin and reprocess it to make sure the system wont reuse it but I am getting error because the file id has change!

Would this fix it or are you just taking the current id and printing it to a doc ?

Have you seen my situation before ?
Or should i publish a new post for this subject ?

Tanxs

I’m not sure if you referring specifically to my method. But if you’re there are some points that probably need more clarification.

  1. I work with Google docs not with PDF. You can easily convert to pdf later
  2. When You call AppScript endpoint with your (unformatted) file id it creates copy of it (formatted) and returns new id of new google docs file
  3. I process file this way only once. Do not reprocess it because probably if you need to reprocess you have both formatted markdown and unformated in one file.

I think maybe you have to create one file where you append formatted text (from AppScript response fileId) every time you need to reprocess. So you processing only one unformatted chunk at once.
You can after that remove chunk files and left with one final file with constant Id.

I’m not sure if this is helpful :slight_smile:

1 Like