// Docs · Apps Script

Convert Markdown text into a formatted Google Doc.

A step-by-step Apps Script that parses Markdown headings and bold runs line-by-line and writes them to a Google Doc as real paragraph styles and bold text—no third-party libraries needed.

I have a block of Markdown text and I want to paste it into Google Docs without manually reformatting every heading and bold span.

The script

copy · paste · trigger
markdownToDoc.gs
Apps Script
// Paste your Markdown into MD_TEXT, then run markdownToDoc()
const MD_TEXT = "# Title\n## Section\nThis has **bold** and normal text.";

function markdownToDoc() {
  const doc = DocumentApp.getActiveDocument();
  const body = doc.getBody();
  body.clear();

  const lines = MD_TEXT.split("\n");

  for (const line of lines) {
    if (line.startsWith("## ")) {
      body.appendParagraph(line.slice(3)).setHeading(DocumentApp.ParagraphHeading.HEADING2);
    } else if (line.startsWith("# ")) {
      body.appendParagraph(line.slice(2)).setHeading(DocumentApp.ParagraphHeading.HEADING1);
    } else {
      appendWithBold(body, line);
    }
  }
}

function appendWithBold(body, line) {
  const para = body.appendParagraph("");
  const parts = line.split("**");
  for (let i = 0; i < parts.length; i++) {
    const text = para.appendText(parts[i]);
    if (i % 2 === 1) text.setBold(true);
  }
}

Need a variant? Gnaw writes a custom version from one sentence — fields, triggers, edge cases handled.

Walkthrough

Why Docs has no Markdown import

Google Docs does not parse Markdown natively. Paste raw text and every # becomes a literal hash character, every **word** keeps its asterisks. The only sanctioned path is Apps Script, which exposes DocumentApp—the same DOM the editor uses—so you can write styled paragraphs programmatically rather than hoping for a format conversion.

The core insight is that Docs thinks in paragraph styles and text attribute runs, not markup. A HEADING1 paragraph is not a font-size trick; it is a first-class paragraph type that shows up in the document outline, gets picked up by the table of contents widget, and exports correctly to PDF. Mapping # lines to that type is therefore the right move, not a workaround.

How the line-by-line parser works

The script splits on newlines and inspects each line with startsWith. Order matters: check ## before # or every heading matches the single-hash branch. Matched lines get sliced (line.slice(3) strips the three-character '## ' prefix including the space) and handed to appendParagraph, which returns a Paragraph object you can immediately chain .setHeading() on.

Body paragraphs route to appendWithBold, which splits on '**'. After the split, odd-indexed chunks fall between a pair of asterisks—those are the bold runs. Even-indexed chunks are plain text. The i % 2 check handles this without any state machine. I keep this pattern in a utils file because it comes up every time I need to render LLM output into Docs.

One gotcha: appendText returns a Text element, not the Paragraph. setBold on a Text element applies only to the characters you just appended, which is what you want. Calling setBold on the Paragraph sets the entire paragraph bold and ignores your per-run work.

Running it and extending to more syntax

Open your target Doc, go to Extensions > Apps Script, paste both functions, set MD_TEXT to your content, and run markdownToDoc. The first run triggers an authorization prompt for DocumentApp scope—accept it, then run again. body.clear() wipes whatever was in the document first, so test on a throwaway doc until you are confident.

To add ### support, insert another startsWith('### ') branch before the ## check and map it to HEADING3. Italics follow the same split-on-delimiter pattern as bold: split on '*' (single asterisk) and apply setItalic(true) to odd chunks. Inline code is trickier because Docs has no built-in code character style—the nearest approximation is setFontFamily('Courier New') on those runs.

The script intentionally ignores blank lines because appendParagraph('') produces an empty paragraph that acts as vertical spacing in the final doc. If you want to suppress them, add a continue when the trimmed line is empty.

Want a custom version?

Describe your sheet and the rule you want. Gnaw writes the Apps Script — fields, triggers, edge cases — in one shot.

FAQ

4 questions
Can I run this on a Doc I don't currently have open?
Yes—replace DocumentApp.getActiveDocument() with DocumentApp.openById('YOUR_DOC_ID'). The ID is the long string in the Doc's URL between /d/ and /edit. The script needs Drive scope in addition to DocumentApp; Apps Script prompts for it automatically on first run.
The bold text isn't rendering—it still shows asterisks. What's wrong?
The most common cause is an odd number of ** delimiters in your input, which leaves one run unclosed. The split produces an even number of chunks only when asterisks are properly paired. Check your source text for stray or unmatched ** sequences, especially around list items.
Does this handle nested formatting like **bold _italic_**?
No. The split-on-delimiter approach processes one marker type per pass. Nested spans require a proper recursive parser. For occasional one-off conversions the simple approach covers 90% of real-world Markdown; for complex documents with nested formatting you'd need a library or a pre-processing step that flattens nesting before the script runs.
My document already has content I want to keep. Can I append instead of replacing?
Remove the body.clear() call. The script will append after existing content. If you need a separator, call body.appendPageBreak() or body.appendParagraph('---') before the loop.
// one good script a week

Get a working Apps Script snippet in your inbox, weekly.