<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Muhammet Savaş</title>
    <description>The latest articles on DEV Community by Muhammet Savaş (@msavas2544).</description>
    <link>https://dev.to/msavas2544</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4219211%2F74b1f761-0d29-492b-a008-ebf885479af3.jpg</url>
      <title>DEV Community: Muhammet Savaş</title>
      <link>https://dev.to/msavas2544</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/msavas2544"/>
    <language>en</language>
    <item>
      <title>Writing Google Docs from Markdown in Python, and the off-by-one I only found against the live API</title>
      <dc:creator>Muhammet Savaş</dc:creator>
      <pubDate>Sat, 31 Oct 2026 14:58:28 +0000</pubDate>
      <link>https://dev.to/msavas2544/writing-google-docs-from-markdown-in-python-and-the-off-by-one-i-only-found-against-the-live-api-3ok</link>
      <guid>https://dev.to/msavas2544/writing-google-docs-from-markdown-in-python-and-the-off-by-one-i-only-found-against-the-live-api-3ok</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;So I wrote &lt;a href="https://github.com/msavas2544/google-docs" rel="noopener noreferrer"&gt;google-docs&lt;/a&gt;, and this post is about the four traps I hit along the way.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;google_docs&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;DocsClient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;OAuthCredentials&lt;/span&gt;

&lt;span class="n"&gt;docs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;DocsClient&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OAuthCredentials&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;token.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Q3 report&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;report.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;docs&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_markdown&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;# and back again
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Trap 1: every insert moves everything after it
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The way out: &lt;strong&gt;only ever insert at a cursor that moves forward&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trap 2: tables have no "write into cell (r, c)"
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;insertTable&lt;/code&gt; 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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;T + 3 + r*(2C + 1) + 2c
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;h2&gt;
  
  
  Trap 3: bullets eat your tabs
&lt;/h2&gt;

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

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

&lt;h2&gt;
  
  
  Trap 4: the unit I couldn't find documented
&lt;/h2&gt;

&lt;p&gt;To test all this without hitting Google on every run, the library ships &lt;code&gt;FakeDocs&lt;/code&gt;, 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.&lt;/p&gt;

&lt;p&gt;Then I ran it against the real API, and it failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;400 INVALID_ARGUMENT: Invalid requests[8].insertText: The insertion index
must be inside the bounds of an existing paragraph.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Google                      FakeDocs
TABLE 127-144 3x2           TABLE 127-143 3x2
  ROW 138-143                 ROW 138-143
P 144-145 '\n'              P 143-144 '\n'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rows end at 143 but the table ends at 144. &lt;strong&gt;Every table has one extra unit that closes it.&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What you get
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;doc.to_markdown()&lt;/code&gt;: any document as GitHub-flavoured Markdown, with headings, nested lists, tables, links, smart chips and footnotes. Useful when you feed documents to an LLM.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;docs.append(doc, markdown)&lt;/code&gt;, &lt;code&gt;prepend&lt;/code&gt;, &lt;code&gt;insert_after("Heading", ...)&lt;/code&gt;, &lt;code&gt;replace_section("Status", ...)&lt;/code&gt;, all as one atomic &lt;code&gt;batchUpdate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Templates: &lt;code&gt;docs.fill(doc, {"name": "Ada", "terms": md("1. Remote\n2. Equity")})&lt;/code&gt;. Plain values replace text everywhere. Markdown values drop real lists and tables where the placeholder was.&lt;/li&gt;
&lt;li&gt;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.&lt;/li&gt;
&lt;li&gt;Sync and async clients built from one sans-I/O core, a CLI (&lt;code&gt;google-docs cat &amp;lt;doc&amp;gt; &amp;gt; doc.md&lt;/code&gt;), and &lt;code&gt;FakeDocs&lt;/code&gt; for your own tests.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;google-docs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Code, docs and the comparison script: &lt;a href="https://github.com/msavas2544/google-docs" rel="noopener noreferrer"&gt;https://github.com/msavas2544/google-docs&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Feedback and issues are very welcome, especially Docs features you'd want next.&lt;/p&gt;

</description>
      <category>python</category>
      <category>googledocs</category>
      <category>markdown</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
