This commit is contained in:
Taylor Wilsdon
2026-02-17 14:49:49 -05:00
38 changed files with 2232 additions and 489 deletions

View File

@@ -104,6 +104,86 @@ def build_text_style(
return text_style, fields
def build_paragraph_style(
heading_level: int = None,
alignment: str = None,
line_spacing: float = None,
indent_first_line: float = None,
indent_start: float = None,
indent_end: float = None,
space_above: float = None,
space_below: float = None,
) -> tuple[Dict[str, Any], list[str]]:
"""
Build paragraph style object for Google Docs API requests.
Args:
heading_level: Heading level 0-6 (0 = NORMAL_TEXT, 1-6 = HEADING_N)
alignment: Text alignment - 'START', 'CENTER', 'END', or 'JUSTIFIED'
line_spacing: Line spacing multiplier (1.0 = single, 2.0 = double)
indent_first_line: First line indent in points
indent_start: Left/start indent in points
indent_end: Right/end indent in points
space_above: Space above paragraph in points
space_below: Space below paragraph in points
Returns:
Tuple of (paragraph_style_dict, list_of_field_names)
"""
paragraph_style = {}
fields = []
if heading_level is not None:
if heading_level < 0 or heading_level > 6:
raise ValueError("heading_level must be between 0 (normal text) and 6")
if heading_level == 0:
paragraph_style["namedStyleType"] = "NORMAL_TEXT"
else:
paragraph_style["namedStyleType"] = f"HEADING_{heading_level}"
fields.append("namedStyleType")
if alignment is not None:
valid_alignments = ["START", "CENTER", "END", "JUSTIFIED"]
alignment_upper = alignment.upper()
if alignment_upper not in valid_alignments:
raise ValueError(
f"Invalid alignment '{alignment}'. Must be one of: {valid_alignments}"
)
paragraph_style["alignment"] = alignment_upper
fields.append("alignment")
if line_spacing is not None:
if line_spacing <= 0:
raise ValueError("line_spacing must be positive")
paragraph_style["lineSpacing"] = line_spacing * 100
fields.append("lineSpacing")
if indent_first_line is not None:
paragraph_style["indentFirstLine"] = {
"magnitude": indent_first_line,
"unit": "PT",
}
fields.append("indentFirstLine")
if indent_start is not None:
paragraph_style["indentStart"] = {"magnitude": indent_start, "unit": "PT"}
fields.append("indentStart")
if indent_end is not None:
paragraph_style["indentEnd"] = {"magnitude": indent_end, "unit": "PT"}
fields.append("indentEnd")
if space_above is not None:
paragraph_style["spaceAbove"] = {"magnitude": space_above, "unit": "PT"}
fields.append("spaceAbove")
if space_below is not None:
paragraph_style["spaceBelow"] = {"magnitude": space_below, "unit": "PT"}
fields.append("spaceBelow")
return paragraph_style, fields
def create_insert_text_request(index: int, text: str) -> Dict[str, Any]:
"""
Create an insertText request for Google Docs API.
@@ -211,6 +291,59 @@ def create_format_text_request(
}
def create_update_paragraph_style_request(
start_index: int,
end_index: int,
heading_level: int = None,
alignment: str = None,
line_spacing: float = None,
indent_first_line: float = None,
indent_start: float = None,
indent_end: float = None,
space_above: float = None,
space_below: float = None,
) -> Optional[Dict[str, Any]]:
"""
Create an updateParagraphStyle request for Google Docs API.
Args:
start_index: Start position of paragraph range
end_index: End position of paragraph range
heading_level: Heading level 0-6 (0 = NORMAL_TEXT, 1-6 = HEADING_N)
alignment: Text alignment - 'START', 'CENTER', 'END', or 'JUSTIFIED'
line_spacing: Line spacing multiplier (1.0 = single, 2.0 = double)
indent_first_line: First line indent in points
indent_start: Left/start indent in points
indent_end: Right/end indent in points
space_above: Space above paragraph in points
space_below: Space below paragraph in points
Returns:
Dictionary representing the updateParagraphStyle request, or None if no styles provided
"""
paragraph_style, fields = build_paragraph_style(
heading_level,
alignment,
line_spacing,
indent_first_line,
indent_start,
indent_end,
space_above,
space_below,
)
if not paragraph_style:
return None
return {
"updateParagraphStyle": {
"range": {"startIndex": start_index, "endIndex": end_index},
"paragraphStyle": paragraph_style,
"fields": ",".join(fields),
}
}
def create_find_replace_request(
find_text: str, replace_text: str, match_case: bool = False
) -> Dict[str, Any]:
@@ -294,18 +427,31 @@ def create_insert_image_request(
def create_bullet_list_request(
start_index: int, end_index: int, list_type: str = "UNORDERED"
) -> Dict[str, Any]:
start_index: int,
end_index: int,
list_type: str = "UNORDERED",
nesting_level: int = None,
paragraph_start_indices: Optional[list[int]] = None,
) -> list[Dict[str, Any]]:
"""
Create a createParagraphBullets request for Google Docs API.
Create requests to apply bullet list formatting with optional nesting.
Google Docs infers list nesting from leading tab characters. To set a nested
level, this helper inserts literal tab characters before each targeted
paragraph, then calls createParagraphBullets. This is a Docs API workaround
and does temporarily mutate content/index positions while the batch executes.
Args:
start_index: Start of text range to convert to list
end_index: End of text range to convert to list
list_type: Type of list ("UNORDERED" or "ORDERED")
nesting_level: Nesting level (0-8, where 0 is top level). If None or 0, no tabs added.
paragraph_start_indices: Optional paragraph start positions for ranges with
multiple paragraphs. If omitted, only start_index is tab-prefixed.
Returns:
Dictionary representing the createParagraphBullets request
List of request dictionaries (insertText for nesting tabs if needed,
then createParagraphBullets)
"""
bullet_preset = (
"BULLET_DISC_CIRCLE_SQUARE"
@@ -313,12 +459,60 @@ def create_bullet_list_request(
else "NUMBERED_DECIMAL_ALPHA_ROMAN"
)
return {
"createParagraphBullets": {
"range": {"startIndex": start_index, "endIndex": end_index},
"bulletPreset": bullet_preset,
# Validate nesting level
if nesting_level is not None:
if not isinstance(nesting_level, int):
raise ValueError("nesting_level must be an integer between 0 and 8")
if nesting_level < 0 or nesting_level > 8:
raise ValueError("nesting_level must be between 0 and 8")
requests = []
# Insert tabs for nesting if needed (nesting_level > 0).
# For multi-paragraph ranges, callers should provide paragraph_start_indices.
if nesting_level and nesting_level > 0:
tabs = "\t" * nesting_level
paragraph_starts = paragraph_start_indices or [start_index]
paragraph_starts = sorted(set(paragraph_starts))
if any(not isinstance(idx, int) for idx in paragraph_starts):
raise ValueError("paragraph_start_indices must contain only integers")
original_start = start_index
original_end = end_index
inserted_char_count = 0
for paragraph_start in paragraph_starts:
adjusted_start = paragraph_start + inserted_char_count
requests.append(
{
"insertText": {
"location": {"index": adjusted_start},
"text": tabs,
}
}
)
inserted_char_count += nesting_level
# Keep createParagraphBullets range aligned to the same logical content.
start_index += (
sum(1 for idx in paragraph_starts if idx < original_start) * nesting_level
)
end_index += (
sum(1 for idx in paragraph_starts if idx < original_end) * nesting_level
)
# Create the bullet list
requests.append(
{
"createParagraphBullets": {
"range": {"startIndex": start_index, "endIndex": end_index},
"bulletPreset": bullet_preset,
}
}
}
)
return requests
def validate_operation(operation: Dict[str, Any]) -> tuple[bool, str]:
@@ -341,6 +535,7 @@ def validate_operation(operation: Dict[str, Any]) -> tuple[bool, str]:
"delete_text": ["start_index", "end_index"],
"replace_text": ["start_index", "end_index", "text"],
"format_text": ["start_index", "end_index"],
"update_paragraph_style": ["start_index", "end_index"],
"insert_table": ["index", "rows", "columns"],
"insert_page_break": ["index"],
"find_replace": ["find_text", "replace_text"],