DEV Community

Muhammet Savaş
Muhammet Savaş

Posted on

Writing Google Docs from Markdown in Python, and the off-by-one I only found against the live API

I wanted something simple: take a Markdown report and turn it into a properly formatted Google Doc. Headings, bullet lists, a table, a few links. In Python.

Google doesn't publish a high-level Python client for Docs. google-api-python-client gives you the raw REST shape, and the raw shape of the Docs API is batchUpdate: a list of edits addressed by character index. That's where the fun starts.

So I wrote google-docs, and this post is about the four traps I hit along the way.

from google_docs import DocsClient, OAuthCredentials

docs = DocsClient(OAuthCredentials.load("token.json"))
doc = docs.create("Q3 report", open("report.md").read())
print(docs.get(doc).to_markdown())   # and back again
Enter fullscreen mode Exit fullscreen mode

Trap 1: every insert moves everything after it

Insert 10 characters at index 50 and the paragraph that was at 80 is now at 90. Every style you compute has to account for every insert before it in the same request.

The way out: only ever insert at a cursor that moves forward. If all text goes in front of the cursor, a range you computed for earlier content is still valid when later content is inserted. So the library does all inserts first, then applies every style on ranges that are final by then.

Trap 2: tables have no "write into cell (r, c)"

insertTable creates an empty table. To put text in a cell you need that cell's index, and nothing gives it to you. For an R×C table starting at T, the empty paragraph of cell (r, c) sits at:

T + 3 + r*(2C + 1) + 2c
Enter fullscreen mode Exit fullscreen mode

That is one unit for the table, one per row, and two per cell (the cell itself plus its empty paragraph). Fill the cells last to first, and each insert leaves the cells before it where they were.

Trap 3: bullets eat your tabs

Nested lists are made by putting leading tabs in front of paragraphs and calling createParagraphBullets. The API counts the tabs to set the nesting level and then deletes them, which moves all the text after them. So bullets go last, from the bottom of the document up.

Indexes are also counted in UTF-16 code units, so one 😀 is two units. Python's len() will quietly put every following style one character off.

Trap 4: the unit I couldn't find documented

To test all this without hitting Google on every run, the library ships FakeDocs, an in-memory Docs server that models documents the way Google does. Inserts shift indexes, bullets eat tabs, and invalid ranges get rejected. The whole Markdown → Docs → Markdown round trip passed against it.

Then I ran it against the real API, and it failed:

400 INVALID_ARGUMENT: Invalid requests[8].insertText: The insertion index
must be inside the bounds of an existing paragraph.
Enter fullscreen mode Exit fullscreen mode

The cell inserts were accepted. The text after the table wasn't. So I wrote a script that sends the planned requests one at a time to a real document and to the fake, and diffs the two structures after each step. It diverged at step 1, insertTable:

Google                      FakeDocs
TABLE 127-144 3x2           TABLE 127-143 3x2
  ROW 138-143                 ROW 138-143
P 144-145 '\n'              P 143-144 '\n'
Enter fullscreen mode Exit fullscreen mode

The rows end at 143 but the table ends at 144. Every table has one extra unit that closes it. The cell formula above was right, and the table's total length was off by exactly one. With that fixed, all 31 steps matched and the live check passed 9/9.

The lesson I keep relearning: a fake is only as good as the evidence behind it. A cheap way to get that evidence is to make the fake and the real thing take the same steps and diff them.

What you get

  • doc.to_markdown(): any document as GitHub-flavoured Markdown, with headings, nested lists, tables, links, smart chips and footnotes. Useful when you feed documents to an LLM.
  • docs.append(doc, markdown), prepend, insert_after("Heading", ...), replace_section("Status", ...), all as one atomic batchUpdate.
  • Templates: docs.fill(doc, {"name": "Ada", "terms": md("1. Remote\n2. Equity")}). Plain values replace text everywhere. Markdown values drop real lists and tables where the placeholder was.
  • Writes are guarded by the revision you read, so a concurrent edit makes Google reject the write instead of corrupting your document. The client then re-reads and retries once.
  • Sync and async clients built from one sans-I/O core, a CLI (google-docs cat <doc> > doc.md), and FakeDocs for your own tests.
pip install google-docs
Enter fullscreen mode Exit fullscreen mode

Code, docs and the comparison script: https://github.com/msavas2544/google-docs

Feedback and issues are very welcome, especially Docs features you'd want next.

Top comments (0)