# How to Preserve Template Styles When Using the Zoho Writer Merge API
## The Problem
When using the Zoho Writer Merge API to generate documents from templates, the merged content does not inherit the template's custom paragraph styles. Instead, standard HTML tags like `<h1>`, `<h2>`, `<h3>`, and `<p>` are rendered with default styling (Liberation Serif, browser-default sizes, black text, bold headings) — completely ignoring the font families, colours, and sizes defined in the template's stylesheet.
This means if your template defines Heading 1 as *Heuristica 18pt dark blue*, your merged `<h1>` content will appear as *Liberation Serif 24pt bold black* instead.
## Root Cause
The Zoho Writer Merge API accepts HTML in richtext merge fields. However, when it processes standard HTML heading tags (`<h1>` through `<h6>`), it:
1. Maps them to Writer's internal heading classes (`heading1`, `heading2`, etc.)
2. Applies **default inline styles** on the rendered spans — overriding the template's CSS-defined styles
3. HTML heading tags also carry **implicit bold** (`font-weight: bold`) which may not match the template
The template's stylesheet defines styles like:
```css
#editorpane div.heading1 span.zw-portion {
font-family: Heuristica, Georgia;
font-size: 18pt;
color: rgb(0, 32, 96);
}
```
But the merge engine ignores these and applies its own defaults inline.
## The Solution
Instead of using semantic HTML heading tags, use `<p>` tags for **all** content and apply the template's exact styles as inline CSS. This prevents Writer from applying default heading styles, and gives you full control over the rendered appearance.
### Step 1: Extract Your Template's Styles
Open your template in Zoho Writer, save the page as HTML (browser "Save As"), and inspect the stylesheet block. Look for the `#editorpane div.headingN span.zw-portion` rules:
```css
/* Example from an IBRS template */
#editorpane div.heading1 span.zw-portion {
font-family: Heuristica, Georgia;
font-size: 18pt;
color: rgb(0, 32, 96);
}
#editorpane div.heading2 span.zw-portion {
font-family: Heuristica, Georgia;
font-size: 14pt;
color: rgb(192, 0, 0);
}
#editorpane div.heading3 span.zw-portion {
font-family: Heuristica, Georgia;
font-size: 12pt;
color: rgb(128, 128, 128);
}
#editorpane span.zw-portion {
font-family: 'Lato 2';
font-size: 12pt;
}
```
### Step 2: Build HTML with Inline Styles
Instead of:
```html
<!-- DON'T DO THIS — styles will be overridden -->
<h1>Cloud Infrastructure Advisory</h1>
<h2>Executive Summary</h2>
<p>This report examines cloud adoption trends.</p>
<ul>
<li>Finding one</li>
<li>Finding two</li>
</ul>
```
Do this:
```html
<!-- DO THIS — inline styles match the template exactly -->
<p style="font-family: Heuristica, Georgia; font-size: 18pt; color: rgb(0,32,96);">Cloud Infrastructure Advisory</p>
<p style="font-family: Heuristica, Georgia; font-size: 14pt; color: rgb(192,0,0);">Executive Summary</p>
<p style="font-family: 'Lato 2', Lato, sans-serif; font-size: 12pt;">This report examines cloud adoption trends.</p>
<ul>
<li style="font-family: 'Lato 2', Lato, sans-serif; font-size: 12pt;">Finding one</li>
<li style="font-family: 'Lato 2', Lato, sans-serif; font-size: 12pt;">Finding two</li>
</ul>
```
Key points:
- Use `<p>` tags for **everything** including headings — never `<h1>` through `<h6>`
- Apply the template's exact `font-family`, `font-size`, and `color` as inline styles
- Do **not** add `font-weight: bold` unless the template explicitly uses it
- List items (`<li>`) also need inline styles
### Step 3: Pass to the Merge API
Use the v2 Merge and Store endpoint to create a persistent Writer-native document:
```python
import json
import requests
ACCESS_TOKEN = "your_access_token"
TEMPLATE_ID = "your_template_id" # The merge template document ID
# Your styled HTML content
styled_html = (
'<p style="font-family: Heuristica, Georgia; font-size: 18pt; '
'color: rgb(0,32,96);">Cloud Infrastructure Advisory</p>'
'<p style="font-family: Heuristica, Georgia; font-size: 14pt; '
'color: rgb(192,0,0);">Executive Summary</p>'
'<p style="font-family: \'Lato 2\', Lato, sans-serif; font-size: 12pt;">'
'This report examines cloud adoption trends.</p>'
)
# Merge data must be wrapped in {"data": [...]}
merge_data = json.dumps({
"data": [{"content": styled_html}]
})
# Output settings — use "zdoc" for native Writer documents
output_settings = json.dumps({
"doc_name": "My Report",
"format": "zdoc"
})
response = requests.post(
f"{BASE_URL}/writer/api/v2/documents/{TEMPLATE_ID}/merge/store",
headers={"Authorization": f"Zoho-oauthtoken {ACCESS_TOKEN}"},
data={
"merge_data": merge_data,
"output_settings": output_settings,
},
)
result = response.json()
record = result["records"][0]
print(f"Document URL: {record['document_url']}")
print(f"Document ID: {record['document_id']}")
```
## Additional Gotchas Discovered
### 1. merge_data format
The merge data **must** be wrapped in a `{"data": [...]}` array, even for a single record:
```json
// WRONG — will return "Merge data not found" (R6001)
{"content": "<p>Hello</p>"}
// CORRECT
{"data": [{"content": "<p>Hello</p>"}]}
```
### 2. Output format for editable documents
Use `"format": "zdoc"` to create native Zoho Writer documents. Using `"format": "docx"` creates a Word file in WorkDrive that doesn't behave as a native Writer document.
### 3. No newlines in HTML content
Do not include `\n` newline characters between HTML elements in the merge data. Writer renders these as literal line breaks in the document. Concatenate your HTML elements directly:
```python
# WRONG — \n appears as visible line breaks
html = "<p>Paragraph one</p>\n<p>Paragraph two</p>"
# CORRECT
html = "<p>Paragraph one</p><p>Paragraph two</p>"
```
### 4. OAuth scopes required
The merge and store endpoint requires these scopes:
```
ZohoWriter.documentEditor.ALL
ZohoWriter.merge.ALL
ZohoPC.files.ALL
WorkDrive.files.ALL
WorkDrive.organization.ALL
WorkDrive.workspace.ALL
```
The first two alone are not sufficient — the WorkDrive and ZohoPC scopes are also required, even for basic template and folder operations.
### 5. Template field type must be "richtext"
Your merge template must have a field defined as `richtext` type to accept HTML content. You can verify fields via:
```
GET /writer/api/v1/documents/{template_id}/fields
```
Which returns:
```json
{
"merge": [
{
"id": "content",
"type": "richtext",
"display_name": "content",
"category": "created"
}
]
}
```
## Helper Function: Content Builder
Here is a reusable Python function that converts a structured content specification into correctly styled HTML for any template:
```python
from html import escape
# Define styles per template — extracted from the template's stylesheet
TEMPLATE_STYLES = {
"my_template": {
"heading1": "font-family: Heuristica, Georgia; font-size: 18pt; color: rgb(0,32,96);",
"heading2": "font-family: Heuristica, Georgia; font-size: 14pt; color: rgb(192,0,0);",
"heading3": "font-family: Heuristica, Georgia; font-size: 12pt; color: rgb(128,128,128);",
"paragraph": "font-family: 'Lato 2', Lato, sans-serif; font-size: 12pt;",
"list_item": "font-family: 'Lato 2', Lato, sans-serif; font-size: 12pt;",
},
}
def build_html(content_blocks, template_name=None):
"""
Convert structured content blocks into styled HTML.
content_blocks: list of dicts with keys:
- type: "heading1", "heading2", "heading3", "paragraph",
"bullet_list", "numbered_list"
- text: str (for headings/paragraphs)
- items: list[str] (for lists)
template_name: key into TEMPLATE_STYLES dict
"""
styles = TEMPLATE_STYLES.get(template_name, {}) if template_name else {}
parts = []
for block in content_blocks:
block_type = block.get("type", "paragraph")
text = block.get("text", "")
items = block.get("items", [])
if block_type in ("heading1", "heading2", "heading3", "paragraph"):
style = styles.get(block_type, "")
attr = f' style="{style}"' if style else ""
parts.append(f"<p{attr}>{escape(text)}</p>")
elif block_type == "bullet_list":
style = styles.get("list_item", "")
attr = f' style="{style}"' if style else ""
li = "".join(f"<li{attr}>{escape(item)}</li>" for item in items)
parts.append(f"<ul>{li}</ul>")
elif block_type == "numbered_list":
style = styles.get("list_item", "")
attr = f' style="{style}"' if style else ""
li = "".join(f"<li{attr}>{escape(item)}</li>" for item in items)
parts.append(f"<ol>{li}</ol>")
return "".join(parts) # No \n between elements!
# Usage
content = [
{"type": "heading1", "text": "Cloud Infrastructure Advisory"},
{"type": "heading2", "text": "Executive Summary"},
{"type": "paragraph", "text": "This report examines cloud adoption."},
{"type": "bullet_list", "items": ["Finding one", "Finding two"]},
{"type": "heading3", "text": "Recommendations"},
{"type": "numbered_list", "items": ["Action one", "Action two"]},
]
html = build_html(content, template_name="my_template")
```
## Summary
| What | Don't | Do |
|------|-------|----|
| Heading tags | `<h1>`, `<h2>`, `<h3>` | `<p style="...">` |
| Styling | Rely on template CSS inheritance | Inline the template's exact styles |
| Bold | Let heading tags add implicit bold | Only add `font-weight` if the template uses it |
| merge_data | `{"field": "value"}` | `{"data": [{"field": "value"}]}` |
| Output format | `"docx"` | `"zdoc"` for native Writer |
| HTML joins | `"\n".join(parts)` | `"".join(parts)` |
This approach preserves the visual identity of your Zoho Writer templates when programmatically generating documents through the Merge API.
---
*Tested with Zoho Writer API v1/v2, April 2026*