●FLASH35 — Gemini 3.5 Flash is GA and now powers gemini-flash-latest, delivering sustained frontier performance on agentic and coding tasks●AGENTS — Managed Agents launch in public preview in the Gemini API, running stateful autonomous agents in isolated Google-hosted Linux sandboxes●ANTIGRAV — The general-purpose managed agent antigravity-preview-05-2026 enters public preview: it plans, reasons, runs code, manages files, and browses the web●TTS — Streaming speech generation is now supported for gemini-3.1-flash-tts-preview via streamGenerateContent●NANO — Nano Banana 2 Lite arrives as the fastest, most cost-efficient Gemini Image model●DEPRECATE — Legacy image generation models are deprecated and shut down on August 17, 2026; plan your migration early●FLASH35 — Gemini 3.5 Flash is GA and now powers gemini-flash-latest, delivering sustained frontier performance on agentic and coding tasks●AGENTS — Managed Agents launch in public preview in the Gemini API, running stateful autonomous agents in isolated Google-hosted Linux sandboxes●ANTIGRAV — The general-purpose managed agent antigravity-preview-05-2026 enters public preview: it plans, reasons, runs code, manages files, and browses the web●TTS — Streaming speech generation is now supported for gemini-3.1-flash-tts-preview via streamGenerateContent●NANO — Nano Banana 2 Lite arrives as the fastest, most cost-efficient Gemini Image model●DEPRECATE — Legacy image generation models are deprecated and shut down on August 17, 2026; plan your migration early
The Table Was There, but the Rows and Columns Weren't — Preserving Docs Structure Before It Reaches Gemini
getBody().getText() flattens Google Docs tables into a column of loose values. Here is what that cost me on a 42-row ledger, the Apps Script extraction layer that keeps the structure, and the acceptance test that keeps it honest.
Every number was correct. The answer still made no sense.
I keep a ledger in Google Docs for the four sites I run as an indie developer — articles published, tasks left, deadlines. An Apps Script job hands it to Gemini and asks which July deadlines haven't been started yet. Every date and count that came back existed somewhere in that ledger. They were just attached to the wrong rows.
Gemini wasn't the problem. One line of my own code was.
The values survive; the rows don't
Here's what I had written:
function askAboutLedger() { const text = DocumentApp.openById(DOC_ID).getBody().getText(); const prompt = 'From the operations ledger below, list every item with a July deadline, tagged by site, as a JSON array.\n\n' + text; return callGemini(prompt);}
getText() hands you the whole body as one string. It's convenient, and for a meeting note or a plain draft made of paragraphs, it's genuinely enough.
Tables are where it stops being enough. Logging the actual payload made the failure obvious:
SitePublished in JulyOpen tasksDeadlineOwnerClaude Lab1232026-07-20MeGemini Lab1412026-08-17Me
The cells are on the floor. The container is gone. Asking a model to find "Gemini Lab's deadline" in that is asking it to count in fives and rebuild the grid from memory. With a fixed column count it can sometimes manage. Insert one empty cell and every alignment after it shifts.
Gemini wasn't hallucinating. It was tidying up spilled values into something plausible.
What getText() throws away
DocumentApp holds the body as a tree of elements. getText() walks that tree depth-first and concatenates the text — which means everything that isn't text is discarded.
In the Doc
After getText()
Effect on extraction
Table cell boundaries and positions
Newline-separated plain text
Row/column mapping is gone (the worst one)
Heading levels (H1–H6)
Ordinary paragraphs
Section boundaries disappear
List nesting and numbering
A flat sequence of lines
Parent/child relations are lost
Link targets
Display text only
References can't be followed
Footnotes
Absent from the body
Caveats vanish entirely
Comments
Absent from the body
DocumentApp can't read them at all
That last row is different in kind. Comments live outside DocumentApp — you need Drive API's comments.list to reach them. If a caveat you're sure you wrote never seems to reach the model, it's worth checking whether it was left as a comment rather than typed into the body.
I lost real time to the footnote case. An exception condition I'd written as a footnote never showed up in any extraction, and I spent an afternoon rewriting prompts. The prompt was fine. The text simply wasn't being sent.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A measured comparison on a 42x5 ledger where row/column mix-ups dropped from 11 of 30 answers to 0
✦Complete Apps Script code that walks the Docs element tree and preserves headings, tables, and lists
✦An acceptance test on four structural invariants that catches silent decay when the Doc gets edited
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
The fix is direct: stop delegating to getText(), walk the tree yourself, and emit the structure as tags. I chose HTML — Gemini reads HTML tables as tables, and they survive rough edges better than pipe-delimited Markdown.
/** * Convert a Doc body into structured HTML, * preserving paragraphs, headings, tables, and lists. */function docToStructuredHtml(docId) { const body = DocumentApp.openById(docId).getBody(); const out = []; const total = body.getNumChildren(); for (let i = 0; i < total; i++) { const child = body.getChild(i); const type = child.getType(); if (type === DocumentApp.ElementType.PARAGRAPH) { out.push(paragraphToHtml(child.asParagraph())); } else if (type === DocumentApp.ElementType.TABLE) { out.push(tableToHtml(child.asTable())); } else if (type === DocumentApp.ElementType.LIST_ITEM) { out.push(listItemToHtml(child.asListItem())); } // Everything else (horizontal rules, TOC) is dropped on purpose } return out.filter(function (line) { return line !== ''; }).join('\n');}function escapeHtml(value) { return value .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>');}function headingTag(paragraph) { const H = DocumentApp.ParagraphHeading; switch (paragraph.getHeading()) { case H.TITLE: case H.HEADING1: return 'h1'; case H.HEADING2: return 'h2'; case H.HEADING3: return 'h3'; case H.HEADING4: case H.HEADING5: case H.HEADING6: return 'h4'; default: return 'p'; }}function paragraphToHtml(paragraph) { const text = paragraph.getText().trim(); if (text === '') return ''; const tag = headingTag(paragraph); return '<' + tag + '>' + escapeHtml(text) + '</' + tag + '>';}function listItemToHtml(item) { const text = item.getText().trim(); if (text === '') return ''; // Depth as an attribute — sturdier than opening and closing ul/ol return '<li data-depth="' + item.getNestingLevel() + '">' + escapeHtml(text) + '</li>';}function tableToHtml(table) { const rows = table.getNumRows(); if (rows === 0) return ''; const lines = ['<table>']; for (let r = 0; r < rows; r++) { const row = table.getRow(r); const cells = []; for (let c = 0; c < row.getNumCells(); c++) { const value = escapeHtml(row.getCell(c).getText().trim()); cells.push(r === 0 ? '<th>' + value + '</th>' : '<td>' + value + '</td>'); } lines.push('<tr>' + cells.join('') + '</tr>'); } lines.push('</table>'); return lines.join('\n');}
Why data-depth instead of properly nested <ul> elements? Because deriving open/close tags from depth transitions breaks the moment one empty list item slips in. Carrying depth as an attribute keeps the generator short and, in my runs, the model read it just as reliably. I traded visual correctness for resistance to breakage, deliberately.
escapeHtml isn't cosmetic either. One ledger cell held a scribbled <needs review>, and passing angle brackets through unescaped makes tag boundaries ambiguous.
HTML table or row records?
Structure doesn't have to mean HTML. You could emit one JSON record per row instead. I couldn't settle that on paper, so I measured it.
Setup: a 42-row, 5-column ledger (site, July publications, open tasks, deadline, owner), ten questions that each require crossing a row with a column, three runs per representation for 30 answers total. Model: gemini-flash-latest (as of July 15, 2026 that resolves to Gemini 3.5 Flash), temperature pinned to 0.
Representation
Row/column mix-ups
Input tokens (approx.)
Implementation cost
getText() plain text
11 / 30
~3,900
1 line
HTML table
0 / 30
~5,200
3 functions
Row records (JSON)
1 / 30
~6,800
3 functions + header inference
11 out of 30 unsettled me. Roughly a third of the time, a quietly wrong answer comes back. A crash you notice. This you don't.
Between HTML and JSON the error gap is small, so tokens and effort decided it. JSON needs header inference, and my ledger has tables with a note line sitting above the header row. Rather than write the branch that absorbs that, I'd rather keep cell positions where they already are. The extra ~1,300 tokens is a cheap price for removing silent errors, and I'd pay it again.
For a document that's mostly prose with a few tables, I think that generalizes. Flip the shape — hundreds of rows and almost no prose — and row records are the more honest fit. The representation should follow the document.
Headings as section boundaries
Keeping structure buys more than working tables. If headings survive as <h2>, they're natural seams.
Ledgers grow. Eventually one won't fit in a single request, and cutting by character count will slice a table in half — back to square one. Cutting on headings keeps meaning intact.
/** * Split structured HTML into sections at <h2> boundaries. * Each section carries its heading so a fragment still has context. */function splitByH2(html) { const lines = html.split('\n'); const sections = []; let current = { heading: '(preamble)', lines: [] }; lines.forEach(function (line) { const m = line.match(/^<h2>(.*?)<\/h2>$/); if (m) { if (current.lines.length > 0) sections.push(current); current = { heading: m[1], lines: [line] }; } else { current.lines.push(line); } }); if (current.lines.length > 0) sections.push(current); return sections.map(function (s) { return { heading: s.heading, html: s.lines.join('\n'), chars: s.lines.join('\n').length, }; });}
The heading field exists so that when a fragment goes to the model on its own, it can say which part of the ledger it came from. A fragment without context is just a new way to be misread.
You don't write this layer once and walk away. The thing that breaks it lives in the Doc. Someone adds a column. Someone turns a heading back into a paragraph. The extraction layer doesn't throw — it quietly emits something worse.
So I compare structural invariants across the conversion:
/** * Acceptance test for the extraction layer. * Verifies the Doc's structure matches the generated HTML. */function assertExtractionIntegrity(docId, html) { const body = DocumentApp.openById(docId).getBody(); const failures = []; // 1. Table count const docTables = body.getTables().length; const htmlTables = (html.match(/<table>/g) || []).length; if (docTables !== htmlTables) { failures.push('table count: doc=' + docTables + ' html=' + htmlTables); } // 2. Total rows across all tables const docRows = body.getTables().reduce(function (sum, t) { return sum + t.getNumRows(); }, 0); const htmlRows = (html.match(/<tr>/g) || []).length; if (docRows !== htmlRows) { failures.push('row count: doc=' + docRows + ' html=' + htmlRows); } // 3. Heading count (has the splitting granularity changed?) const htmlH2 = (html.match(/<h2>/g) || []).length; if (htmlH2 === 0) { failures.push('no h2: headings may have been demoted to paragraphs'); } // 4. Empty cells (the usual precursor to column drift) const emptyCells = (html.match(/<td><\/td>/g) || []).length; if (emptyCells > 0) { failures.push('empty cells: ' + emptyCells); } if (failures.length > 0) { throw new Error('extraction integrity failed:\n' + failures.join('\n')); } return { tables: docTables, rows: docRows, h2: htmlH2 };}
The fourth check is a different animal. An empty cell isn't invalid. But in my ledger, empty cells almost always appeared right after someone added a column — and that same day, other rows were misaligned. I treat it as a signal to go look, not as a hard error. It fired twice in July, and both times the culprit was my own editing.
Even when you're the only person touching the Doc, the check earns its keep. The version of you from three weeks ago is a stranger.
Where the 2.1 seconds go
Adding an extraction layer costs time. On my ledger (18 pages including the 42x5 table), getText() took about 0.4 seconds; walking the element tree took about 2.1 seconds. Five times slower — and still nothing in absolute terms.
The expensive part was never the walk. It was the Gemini call after it. Against a six-minute execution limit, 2.1 seconds is rounding error. Going back to plain text to save it doesn't look rational to me.
Caching the extracted result is a more interesting call. I decided against holding it in CacheService on a fixed key — the ledger changes several times a day, and grabbing a stale extraction scared me more than recomputing. Instead I key on the Doc's modification time.
function getStructuredHtmlCached(docId) { const file = DriveApp.getFileById(docId); const stamp = String(file.getLastUpdated().getTime()); const cache = CacheService.getScriptCache(); const key = 'doc_html_' + docId + '_' + stamp; const hit = cache.get(key); if (hit) return hit; const html = docToStructuredHtml(docId); // Values over 100KB won't be cached; behavior is unaffected either way if (html.length < 100 * 1024) { cache.put(key, html, 21600); } return html;}
With the timestamp in the key, an edit changes the key and the old result simply stops being reachable. No explicit invalidation to write, and nothing to forget to clear.
What the extraction layer shouldn't be asked to do
Having argued for it at length, let me argue the other side.
If a table in the Doc is a layout device rather than data, preserving it adds nothing. Tables with heavy cell merging can't be restored to their intended shape either, since getText() returns values per cell regardless. In my own ledger, a two-column split I'd made purely for looks ended up being fixed in the Doc rather than absorbed in code. Straightening the source was faster.
The extraction layer's job is narrow: recover structure that exists in the document but gets lost in transit. When the document has no structure to begin with, the document is what needs fixing.
If an automation of yours is giving answers that almost line up but not quite, take a look at Logger.log(body.getText()) before you touch the prompt. What you think you're sending and what you're actually sending may have parted ways. Mine had.
Thanks for reading — I hope it saves you an afternoon.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.