Skip to content

Commit 355ca49

Browse files
committed
md2gdoc: add --font, --font-size, --line-spacing formatting flags
Add optional post-upload formatting via Google Docs API. Font family and size apply only to NORMAL_TEXT paragraphs, preserving heading styles, bold, italic, and other inline formatting. Line spacing applies to all paragraphs. Update Starlight docs with formatting section and examples.
1 parent db73f8a commit 355ca49

2 files changed

Lines changed: 210 additions & 0 deletions

File tree

claude_code_tools/md2gdoc.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -809,6 +809,146 @@ def post_insert_images(
809809
return 0
810810

811811

812+
def apply_formatting(
813+
docs_service,
814+
doc_id: str,
815+
font_family: Optional[str] = None,
816+
font_size: Optional[float] = None,
817+
line_spacing: Optional[float] = None,
818+
) -> None:
819+
"""Apply font and spacing to the document body.
820+
821+
Font family and size are applied only to NORMAL_TEXT
822+
paragraphs so that headings, titles, and other named
823+
styles keep their distinct appearance. Bold, italic,
824+
and other inline formatting is preserved because the
825+
fields mask only touches font family and size.
826+
827+
Line spacing is applied to ALL paragraphs (including
828+
headings) using NEVER_COLLAPSE so the value is always
829+
respected.
830+
831+
Args:
832+
docs_service: Authenticated Google Docs service.
833+
doc_id: The Google Doc ID.
834+
font_family: Font name (e.g. "Arial").
835+
font_size: Font size in points (e.g. 10).
836+
line_spacing: Line spacing multiplier (e.g. 2.0
837+
for double spacing).
838+
"""
839+
if not any([font_family, font_size, line_spacing]):
840+
return
841+
842+
doc = (
843+
docs_service.documents()
844+
.get(documentId=doc_id)
845+
.execute()
846+
)
847+
body = doc.get("body", {})
848+
content = body.get("content", [])
849+
if not content:
850+
return
851+
852+
end_index = content[-1].get("endIndex", 1)
853+
if end_index <= 1:
854+
return
855+
856+
requests: list[dict] = []
857+
858+
# Build text style dict once (used per-paragraph)
859+
text_style: dict = {}
860+
text_fields: list[str] = []
861+
if font_family:
862+
text_style["weightedFontFamily"] = {
863+
"fontFamily": font_family,
864+
}
865+
text_fields.append("weightedFontFamily")
866+
if font_size:
867+
text_style["fontSize"] = {
868+
"magnitude": font_size,
869+
"unit": "PT",
870+
}
871+
text_fields.append("fontSize")
872+
873+
# Iterate paragraphs to selectively apply styles
874+
for element in content:
875+
paragraph = element.get("paragraph")
876+
if not paragraph:
877+
continue
878+
879+
p_style = paragraph.get("paragraphStyle", {})
880+
named_style = p_style.get(
881+
"namedStyleType", "NORMAL_TEXT"
882+
)
883+
start = element.get("startIndex", 0)
884+
end = element.get("endIndex", 0)
885+
if start >= end:
886+
continue
887+
888+
p_range = {
889+
"startIndex": start,
890+
"endIndex": end,
891+
}
892+
893+
# Font family/size: only on NORMAL_TEXT paragraphs
894+
# so headings keep their distinct style
895+
if text_fields and named_style == "NORMAL_TEXT":
896+
requests.append(
897+
{
898+
"updateTextStyle": {
899+
"range": p_range,
900+
"textStyle": text_style,
901+
"fields": ",".join(text_fields),
902+
}
903+
}
904+
)
905+
906+
# Line spacing: apply to all paragraphs
907+
if line_spacing:
908+
requests.append(
909+
{
910+
"updateParagraphStyle": {
911+
"range": p_range,
912+
"paragraphStyle": {
913+
"lineSpacing": (
914+
line_spacing * 100
915+
),
916+
"spacingMode": (
917+
"NEVER_COLLAPSE"
918+
),
919+
},
920+
"fields": (
921+
"lineSpacing,spacingMode"
922+
),
923+
}
924+
}
925+
)
926+
927+
if not requests:
928+
return
929+
930+
try:
931+
docs_service.documents().batchUpdate(
932+
documentId=doc_id,
933+
body={"requests": requests},
934+
).execute()
935+
parts = []
936+
if font_family:
937+
parts.append(f"font={font_family}")
938+
if font_size:
939+
parts.append(f"size={font_size}pt")
940+
if line_spacing:
941+
parts.append(f"spacing={line_spacing}x")
942+
console.print(
943+
f"[green]Applied formatting:[/green] "
944+
f"{', '.join(parts)}"
945+
)
946+
except Exception as e:
947+
console.print(
948+
f"[red]Error applying formatting:[/red] {e}"
949+
)
950+
951+
812952
def cleanup_temp_images(
813953
service, file_ids: list[str]
814954
) -> None:
@@ -878,6 +1018,7 @@ def main() -> None:
8781018
md2gdoc report.md --name "Q4 Summary"
8791019
md2gdoc report.md --on-existing version
8801020
md2gdoc report.md --max-image-width 5.0
1021+
md2gdoc report.md --font Arial --font-size 10 --line-spacing 2.0
8811022
""",
8821023
)
8831024

@@ -908,6 +1049,20 @@ def main() -> None:
9081049
help="Max image display width in inches "
9091050
"(default: 6.5 = full page width)",
9101051
)
1052+
parser.add_argument(
1053+
"--font", type=str, default=None,
1054+
help="Font family for body text "
1055+
"(e.g., 'Arial', 'Times New Roman')",
1056+
)
1057+
parser.add_argument(
1058+
"--font-size", type=float, default=None,
1059+
help="Font size in points (e.g., 10, 12)",
1060+
)
1061+
parser.add_argument(
1062+
"--line-spacing", type=float, default=None,
1063+
help="Line spacing multiplier "
1064+
"(e.g., 1.0, 1.5, 2.0 for double)",
1065+
)
9111066

9121067
args = parser.parse_args()
9131068

@@ -1025,6 +1180,7 @@ def main() -> None:
10251180

10261181
# --- Post-insert images via Docs API ---
10271182
n_images = 0
1183+
docs_svc = None
10281184
if image_infos and doc_id:
10291185
max_w_pt = args.max_image_width * 72 # in→pt
10301186
docs_svc = get_docs_service()
@@ -1044,6 +1200,29 @@ def main() -> None:
10441200
"placeholders."
10451201
)
10461202

1203+
# --- Apply formatting ---
1204+
has_formatting = any([
1205+
args.font, args.font_size, args.line_spacing,
1206+
])
1207+
if has_formatting and doc_id:
1208+
fmt_svc = docs_svc or get_docs_service()
1209+
if fmt_svc:
1210+
console.print(
1211+
"[cyan]Applying formatting...[/cyan]"
1212+
)
1213+
apply_formatting(
1214+
fmt_svc,
1215+
doc_id,
1216+
font_family=args.font,
1217+
font_size=args.font_size,
1218+
line_spacing=args.line_spacing,
1219+
)
1220+
else:
1221+
console.print(
1222+
"[yellow]Warning:[/yellow] Could not "
1223+
"get Docs service — formatting skipped."
1224+
)
1225+
10471226
# --- Cleanup ---
10481227
if drive_file_ids:
10491228
console.print(

docs-site/src/content/docs/integrations/google-docs.mdx

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,34 @@ If a file with the same name already exists,
122122
md2gdoc report.md --on-existing overwrite
123123
```
124124

125+
### Formatting
126+
127+
By default, Google's markdown converter picks the
128+
font, size, and spacing. Use these flags to override
129+
body text formatting after upload:
130+
131+
```bash
132+
# 10pt Arial, double-spaced
133+
md2gdoc report.md --font Arial --font-size 10 --line-spacing 2.0
134+
135+
# Just change the font
136+
md2gdoc report.md --font "Times New Roman"
137+
138+
# 1.5x spacing, keep default font
139+
md2gdoc report.md --line-spacing 1.5
140+
```
141+
142+
- `--font` -- font family (e.g. `Arial`,
143+
`"Times New Roman"`)
144+
- `--font-size` -- size in points (e.g. `10`, `12`)
145+
- `--line-spacing` -- multiplier (`1.0` = single,
146+
`1.5`, `2.0` = double)
147+
148+
All flags are optional and can be combined. Heading
149+
styles (H1, H2, etc.) and inline formatting (bold,
150+
italic, etc.) are preserved -- only body text
151+
paragraphs get the font/size override.
152+
125153
### Features
126154

127155
- Native markdown conversion (same quality as manual
@@ -130,6 +158,9 @@ md2gdoc report.md --on-existing overwrite
130158
markdown (e.g. `![alt](diagram.png)`) are uploaded
131159
to Drive at full resolution, then inserted into
132160
the Google Doc with proper sizing
161+
- **Formatting** -- optional `--font`, `--font-size`,
162+
and `--line-spacing` flags to control body text
163+
appearance (see above)
133164
- `--max-image-width` controls display width in
134165
inches (default: 6.5 = full page width)
135166
- `--no-images` skips image processing entirely

0 commit comments

Comments
 (0)