smlflg's picture
Initial public upload from Projekte/Connector (part 16)
bf06a0d verified
Raw History Blame Contribute Delete
21.6 kB
"""
Google Docs to Markdown Converter
Converts Google Docs API JSON responses to clean Markdown, preserving:
- Headings (H1-H6, Title, Subtitle)
- Bold, italic, strikethrough, code, links
- Ordered and unordered lists with nesting
- Checklists with checked/unchecked state
- Tables with header row separators
- Smart chips: person (@mentions), rich links, dates, inline images,
footnotes, horizontal rules, auto-text (page numbers), equations
- Document tabs (multi-tab and nested child tabs)
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
logger = logging.getLogger(__name__)
MONO_FONTS = {"Courier New", "Consolas", "Roboto Mono", "Source Code Pro"}
HEADING_MAP = {
"TITLE": "#",
"SUBTITLE": "##",
"HEADING_1": "#",
"HEADING_2": "##",
"HEADING_3": "###",
"HEADING_4": "####",
"HEADING_5": "#####",
"HEADING_6": "######",
}
def convert_doc_to_markdown(doc: dict[str, Any]) -> str:
"""Convert a Google Docs API document response to markdown.
Supports both legacy (top-level body) and tab-aware responses
(includeTabsContent=True). For multi-tab docs, each tab gets a
heading separator. Single-tab docs render without a tab heading.
Args:
doc: The document JSON from docs.documents.get()
Returns:
Markdown string
"""
tabs = doc.get("tabs", [])
if tabs:
return _convert_tabs_to_markdown(tabs)
# Legacy: no tabs, use top-level body/lists/footnotes/inlineObjects
return _convert_body_to_markdown(doc)
def _convert_tabs_to_markdown(tabs: list[dict[str, Any]]) -> str:
"""Convert a list of document tabs to markdown, recursing into child tabs."""
all_tab_docs: list[tuple[str, dict[str, Any]]] = []
_collect_tabs(tabs, all_tab_docs)
if len(all_tab_docs) == 1:
_, tab_doc = all_tab_docs[0]
return _convert_body_to_markdown(tab_doc)
sections: list[str] = []
for title, tab_doc in all_tab_docs:
tab_md = _convert_body_to_markdown(tab_doc)
sections.append(f"# {title}\n\n{tab_md}")
return "\n".join(sections).rstrip("\n") + "\n"
def _collect_tabs(
tabs: list[dict[str, Any]],
result: list[tuple[str, dict[str, Any]]],
) -> None:
"""Flatten tab hierarchy into (title, documentTab) pairs."""
for tab in tabs:
props = tab.get("tabProperties", {})
title = props.get("title", "Untitled Tab")
doc_tab = tab.get("documentTab", {})
if doc_tab:
result.append((title, doc_tab))
for child in tab.get("childTabs", []):
_collect_tabs([child], result)
def _convert_body_to_markdown(doc: dict[str, Any]) -> str:
"""Convert a single document body (or documentTab) to markdown."""
body = doc.get("body", {})
content = body.get("content", [])
lists_meta = doc.get("lists", {})
footnotes_meta = doc.get("footnotes", {})
inline_objects = doc.get("inlineObjects", {})
lines: list[str] = []
ordered_counters: dict[tuple[str, int], int] = {}
prev_was_list = False
footnote_defs: list[tuple[str, str]] = []
for element in content:
if "paragraph" in element:
para = element["paragraph"]
text = _convert_paragraph_text(
para,
footnotes_meta=footnotes_meta,
inline_objects=inline_objects,
footnote_defs=footnote_defs,
)
if not text.strip():
if prev_was_list:
prev_was_list = False
continue
bullet = para.get("bullet")
if bullet:
list_id = bullet["listId"]
nesting = bullet.get("nestingLevel", 0)
if _is_checklist(lists_meta, list_id, nesting):
checked = _is_checked(para)
checkbox = "[x]" if checked else "[ ]"
indent = " " * nesting
cb_text = (
_convert_paragraph_text(
para,
skip_strikethrough=True,
footnotes_meta=footnotes_meta,
inline_objects=inline_objects,
footnote_defs=footnote_defs,
)
if checked
else text
)
lines.append(f"{indent}- {checkbox} {cb_text}")
elif _is_ordered_list(lists_meta, list_id, nesting):
key = (list_id, nesting)
ordered_counters[key] = ordered_counters.get(key, 0) + 1
counter = ordered_counters[key]
indent = " " * nesting
lines.append(f"{indent}{counter}. {text}")
else:
indent = " " * nesting
lines.append(f"{indent}- {text}")
prev_was_list = True
else:
if prev_was_list:
ordered_counters.clear()
lines.append("")
prev_was_list = False
style = para.get("paragraphStyle", {})
named_style = style.get("namedStyleType", "NORMAL_TEXT")
prefix = HEADING_MAP.get(named_style, "")
if prefix:
lines.append(f"{prefix} {text}")
lines.append("")
else:
lines.append(text)
lines.append("")
elif "table" in element:
if prev_was_list:
ordered_counters.clear()
lines.append("")
prev_was_list = False
table_md = _convert_table(
element["table"],
footnotes_meta=footnotes_meta,
inline_objects=inline_objects,
footnote_defs=footnote_defs,
)
lines.append(table_md)
lines.append("")
if footnote_defs:
lines.append("")
for fn_id, fn_text in footnote_defs:
lines.append(f"[^{fn_id}]: {fn_text}")
result = "\n".join(lines).rstrip("\n") + "\n"
return result
def _convert_paragraph_text(
para: dict[str, Any],
skip_strikethrough: bool = False,
footnotes_meta: dict[str, Any] | None = None,
inline_objects: dict[str, Any] | None = None,
footnote_defs: list[tuple[str, str]] | None = None,
active_footnotes: set[str] | None = None,
) -> str:
"""Convert paragraph elements to inline markdown text."""
parts: list[str] = []
for elem in para.get("elements", []):
if "textRun" in elem:
parts.append(_convert_text_run(elem["textRun"], skip_strikethrough))
elif "person" in elem:
parts.append(_convert_person_chip(elem["person"]))
elif "richLink" in elem:
parts.append(_convert_rich_link_chip(elem["richLink"]))
elif "dateElement" in elem:
parts.append(_convert_date_chip(elem["dateElement"]))
elif "inlineObjectElement" in elem:
parts.append(
_convert_inline_object(elem["inlineObjectElement"], inline_objects)
)
elif "footnoteReference" in elem:
parts.append(
_convert_footnote_reference(
elem["footnoteReference"],
footnotes_meta,
inline_objects,
footnote_defs,
active_footnotes,
)
)
elif "horizontalRule" in elem:
parts.append("\n---\n")
elif "autoText" in elem:
parts.append(_convert_auto_text(elem["autoText"]))
elif "pageBreak" in elem or "columnBreak" in elem:
pass # No meaningful markdown representation
elif "equation" in elem:
parts.append(_convert_equation(elem["equation"]))
return "".join(parts).strip()
def _convert_text_run(
text_run: dict[str, Any], skip_strikethrough: bool = False
) -> str:
"""Convert a single text run to markdown."""
content = text_run.get("content", "")
style = text_run.get("textStyle", {})
text = content.rstrip("\n")
# Replace Google Docs Private Use Area chip placeholders (e.g. \ue907)
# that appear for unsupported chip types like vote, stopwatch, timer
text = text.replace("\ue907", "[Smart Chip]")
if not text:
return ""
return _apply_text_style(text, style, skip_strikethrough)
def _convert_person_chip(person: dict[str, Any]) -> str:
"""Convert a person smart chip to a mailto link."""
props = person.get("personProperties", {})
name = props.get("name", "")
email = props.get("email", "")
if email:
label = name or email
return f"[{label}](mailto:{email})"
return name or ""
def _convert_rich_link_chip(rich_link: dict[str, Any]) -> str:
"""Convert a rich link smart chip to markdown.
Rich link chips contain a richLinkProperties dict with title and uri.
"""
props = rich_link.get("richLinkProperties", {})
title = props.get("title", "")
uri = props.get("uri", "")
if title and uri:
return f"[{title}]({uri})"
if uri:
return uri
return title or ""
def _convert_date_chip(date_elem: dict[str, Any]) -> str:
"""Convert a date smart chip (dateElement) to markdown.
Date elements contain dateElementProperties with displayText (the
locale-formatted string) and a timestamp.
"""
props = date_elem.get("dateElementProperties", {})
display_text = props.get("displayText", "")
if display_text:
return display_text
# Fallback: parse the timestamp into a date string
timestamp = props.get("timestamp", "")
if timestamp:
try:
dt = datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
return dt.strftime("%Y-%m-%d")
except (ValueError, TypeError):
return timestamp
return ""
def _convert_inline_object(
element: dict[str, Any],
inline_objects: dict[str, Any] | None,
) -> str:
"""Convert an inline object (image/drawing) to markdown."""
obj_id = element.get("inlineObjectId", "")
if not inline_objects or obj_id not in inline_objects:
return ""
obj = inline_objects[obj_id]
props = obj.get("inlineObjectProperties", {}).get("embeddedObject", {})
title = props.get("title", "") or props.get("description", "")
uri = props.get("imageProperties", {}).get("contentUri", "")
if uri:
return f"![{title}]({uri})"
return f"[Image: {title}]" if title else "[Image]"
def _convert_footnote_reference(
ref: dict[str, Any],
footnotes_meta: dict[str, Any] | None,
inline_objects: dict[str, Any] | None,
footnote_defs: list[tuple[str, str]] | None,
active_footnotes: set[str] | None = None,
) -> str:
"""Convert a footnote reference to a markdown footnote marker.
Also collects the footnote definition text for appending at the end.
"""
fn_id = ref.get("footnoteId", "")
if not fn_id:
return ""
if footnotes_meta and footnote_defs is not None and fn_id in footnotes_meta:
# Avoid duplicates if the same footnote is referenced multiple times.
existing_ids = {fid for fid, _ in footnote_defs}
if fn_id not in existing_ids:
next_active = set(active_footnotes or ())
if fn_id not in next_active:
next_active.add(fn_id)
fn_content = footnotes_meta[fn_id].get("content", [])
fn_text = _convert_footnote_content(
fn_content,
footnotes_meta=footnotes_meta,
inline_objects=inline_objects,
footnote_defs=footnote_defs,
active_footnotes=next_active,
)
footnote_defs.append((fn_id, fn_text))
return f"[^{fn_id}]"
def _convert_footnote_content(
content: list[dict[str, Any]],
*,
footnotes_meta: dict[str, Any] | None,
inline_objects: dict[str, Any] | None,
footnote_defs: list[tuple[str, str]] | None,
active_footnotes: set[str] | None = None,
) -> str:
"""Convert footnote content with the same inline handling as body paragraphs."""
parts: list[str] = []
for element in content:
if "paragraph" in element:
text = _convert_paragraph_text(
element["paragraph"],
footnotes_meta=footnotes_meta,
inline_objects=inline_objects,
footnote_defs=footnote_defs,
active_footnotes=active_footnotes,
)
if text.strip():
parts.append(text.strip())
elif "table" in element:
table_text = _convert_table(
element["table"],
footnotes_meta=footnotes_meta,
inline_objects=inline_objects,
footnote_defs=footnote_defs,
active_footnotes=active_footnotes,
)
if table_text.strip():
parts.append(table_text.replace("\n", " "))
return " ".join(parts)
def _convert_auto_text(auto_text: dict[str, Any]) -> str:
"""Convert an autoText element (e.g. page number) to a placeholder."""
text_type = auto_text.get("type", "")
if text_type == "PAGE_NUMBER":
return "[Page #]"
if text_type == "PAGE_COUNT":
return "[Page Count]"
return ""
def _convert_equation(equation: dict[str, Any]) -> str:
"""Convert an equation element to markdown."""
# The Docs API does not expose equation content as text — only the
# suggestedInsertionIds / suggestedDeletionIds are available.
return "[Equation]"
def _apply_text_style(
text: str, style: dict[str, Any], skip_strikethrough: bool = False
) -> str:
"""Apply markdown formatting based on text style."""
link = style.get("link", {})
url = link.get("url")
font_family = style.get("weightedFontFamily", {}).get("fontFamily", "")
if font_family in MONO_FONTS:
return f"`{text}`"
bold = style.get("bold", False)
italic = style.get("italic", False)
strikethrough = style.get("strikethrough", False)
if bold and italic:
text = f"***{text}***"
elif bold:
text = f"**{text}**"
elif italic:
text = f"*{text}*"
if strikethrough and not skip_strikethrough:
text = f"~~{text}~~"
if url:
text = f"[{text}]({url})"
return text
def _is_ordered_list(lists_meta: dict[str, Any], list_id: str, nesting: int) -> bool:
"""Check if a list at a given nesting level is ordered."""
list_info = lists_meta.get(list_id, {})
nesting_levels = list_info.get("listProperties", {}).get("nestingLevels", [])
if nesting < len(nesting_levels):
level = nesting_levels[nesting]
glyph = level.get("glyphType", "")
return glyph not in ("", "GLYPH_TYPE_UNSPECIFIED")
return False
def _is_checklist(lists_meta: dict[str, Any], list_id: str, nesting: int) -> bool:
"""Check if a list at a given nesting level is a checklist.
Google Docs checklists are distinguished from regular bullet lists by having
GLYPH_TYPE_UNSPECIFIED with no glyphSymbol — the Docs UI renders interactive
checkboxes rather than a static glyph character.
"""
list_info = lists_meta.get(list_id, {})
nesting_levels = list_info.get("listProperties", {}).get("nestingLevels", [])
if nesting < len(nesting_levels):
level = nesting_levels[nesting]
glyph_type = level.get("glyphType", "")
has_glyph_symbol = "glyphSymbol" in level
return glyph_type in ("", "GLYPH_TYPE_UNSPECIFIED") and not has_glyph_symbol
return False
def _is_checked(para: dict[str, Any]) -> bool:
"""Check if a checklist item is checked.
Google Docs marks checked checklist items by applying strikethrough
formatting to the paragraph text.
"""
for elem in para.get("elements", []):
if "textRun" in elem:
content = elem["textRun"].get("content", "").strip()
if content:
return elem["textRun"].get("textStyle", {}).get("strikethrough", False)
return False
def _convert_table(
table: dict[str, Any],
*,
footnotes_meta: dict[str, Any] | None = None,
inline_objects: dict[str, Any] | None = None,
footnote_defs: list[tuple[str, str]] | None = None,
active_footnotes: set[str] | None = None,
) -> str:
"""Convert a table element to markdown."""
rows = table.get("tableRows", [])
if not rows:
return ""
md_rows: list[str] = []
for i, row in enumerate(rows):
cells: list[str] = []
for cell in row.get("tableCells", []):
cell_text = _extract_cell_text(
cell,
footnotes_meta=footnotes_meta,
inline_objects=inline_objects,
footnote_defs=footnote_defs,
active_footnotes=active_footnotes,
)
cells.append(cell_text)
md_rows.append("| " + " | ".join(cells) + " |")
if i == 0:
sep = "| " + " | ".join("---" for _ in cells) + " |"
md_rows.append(sep)
return "\n".join(md_rows)
def _extract_cell_text(
cell: dict[str, Any],
*,
footnotes_meta: dict[str, Any] | None = None,
inline_objects: dict[str, Any] | None = None,
footnote_defs: list[tuple[str, str]] | None = None,
active_footnotes: set[str] | None = None,
) -> str:
"""Extract text from a table cell."""
parts: list[str] = []
for content_elem in cell.get("content", []):
if "paragraph" in content_elem:
text = _convert_paragraph_text(
content_elem["paragraph"],
footnotes_meta=footnotes_meta,
inline_objects=inline_objects,
footnote_defs=footnote_defs,
active_footnotes=active_footnotes,
)
if text.strip():
parts.append(text.strip())
cell_text = " ".join(parts)
return cell_text.replace("|", "\\|")
def format_comments_inline(markdown: str, comments: list[dict[str, Any]]) -> str:
"""Insert footnote-style comment annotations inline in markdown.
For each comment, finds the anchor text in the markdown and inserts
a footnote reference. Unmatched comments go to an appendix at the bottom.
"""
if not comments:
return markdown
footnotes: list[str] = []
unmatched: list[dict[str, Any]] = []
for i, comment in enumerate(comments, 1):
ref = f"[^c{i}]"
anchor = comment.get("anchor_text", "")
if anchor and anchor in markdown:
markdown = markdown.replace(anchor, anchor + ref, 1)
footnotes.append(_format_footnote(i, comment))
else:
unmatched.append(comment)
if footnotes:
markdown = markdown.rstrip("\n") + "\n\n" + "\n".join(footnotes) + "\n"
if unmatched:
appendix = format_comments_appendix(unmatched)
if appendix.strip():
markdown = markdown.rstrip("\n") + "\n\n" + appendix
return markdown
def _format_footnote(num: int, comment: dict[str, Any]) -> str:
"""Format a single footnote."""
lines = [f"[^c{num}]: **{comment['author']}**: {comment['content']}"]
for reply in comment.get("replies", []):
lines.append(f" - **{reply['author']}**: {reply['content']}")
return "\n".join(lines)
def format_comments_appendix(comments: list[dict[str, Any]]) -> str:
"""Format comments as an appendix section with blockquoted anchors."""
if not comments:
return ""
lines = ["## Comments", ""]
for comment in comments:
resolved_tag = " *(Resolved)*" if comment.get("resolved") else ""
anchor = comment.get("anchor_text", "")
if anchor:
lines.append(f"> {anchor}")
lines.append("")
lines.append(f"- **{comment['author']}**: {comment['content']}{resolved_tag}")
for reply in comment.get("replies", []):
lines.append(f" - **{reply['author']}**: {reply['content']}")
lines.append("")
return "\n".join(lines)
def parse_drive_comments(
response: dict[str, Any], include_resolved: bool = False
) -> list[dict[str, Any]]:
"""Parse Drive API comments response into structured dicts.
Args:
response: Raw JSON from drive.comments.list()
include_resolved: Whether to include resolved comments
Returns:
List of comment dicts with keys: author, content, anchor_text,
replies, resolved
"""
results = []
for comment in response.get("comments", []):
if not include_resolved and comment.get("resolved", False):
continue
anchor_text = comment.get("quotedFileContent", {}).get("value", "")
replies = [
{
"author": r.get("author", {}).get("displayName", "Unknown"),
"content": r.get("content", ""),
}
for r in comment.get("replies", [])
]
results.append(
{
"author": comment.get("author", {}).get("displayName", "Unknown"),
"content": comment.get("content", ""),
"anchor_text": anchor_text,
"replies": replies,
"resolved": comment.get("resolved", False),
}
)
return results