# Design Once, Generate Anywhere: How Our Unified Template System Works

---

Most document generation tools make you choose: build a PDF template *or* an Excel template. Need both formats? Build two templates, maintain two schemas, handle two sets of bugs.

We thought that was unnecessary. So, we built a unified template system where you design once and generate in any format. This post explains how it works under the hood.

## The Problem with Separate Templates

Consider a typical invoice. Your sales team wants PDF invoices for customers. Your finance team wants the same data as Excel for their accounting software. Your ops team wants Excel reports they can sort and filter.

With traditional tools, you'd maintain:

* An HTML template for the PDF invoice
    
* A separate Excel template (or code that builds spreadsheets)
    
* Logic to keep both templates in sync when the invoice format changes
    

When someone adds a "discount" field, you update the PDF template, then remember to update the Excel template too. Inevitably, they drift apart.

## The Rynko Approach

In Rynko, a template is a JSON document that describes the **structure and data** of your document, not the final visual output. The renderers — one for PDF, one for Excel — interpret that structure for their respective formats.

Here's a simplified view of an invoice template:

```json
{
  "name": "Invoice",
  "format": "both",
  "variables": [
    { "name": "invoiceNumber", "type": "string" },
    { "name": "customerName", "type": "string" },
    { "name": "items", "type": "array", "itemType": "object",
      "schema": {
        "itemSchema": {
          "properties": {
            "description": { "type": "string" },
            "quantity": { "type": "number" },
            "price": { "type": "number" }
          }
        }
      }
    },
    { "name": "total", "type": "number" }
  ],
  "sections": [
    {
      "components": [
        { "type": "heading", "props": { "text": "Invoice #{{invoiceNumber}}" } },
        { "type": "text", "props": { "text": "Bill to: {{customerName}}" } },
        {
          "type": "dataTable",
          "props": {
            "dataSource": "{{items}}",
            "columns": [
              { "header": "Description", "field": "description" },
              { "header": "Qty", "field": "quantity" },
              { "header": "Price", "field": "price", "format": "currency" }
            ]
          }
        },
        { "type": "text", "props": { "text": "Total: ${{total}}", "bold": true } }
      ]
    }
  ]
}
```

This single template produces both a formatted PDF and a structured Excel workbook. The same variable data feeds both.

![](https://cdn.hashnode.com/res/hashnode/image/upload/v1771349251907/927e2f5f-813a-4b35-a6e2-42c3f6cd0db8.png align="center")

## How the Rendering Works

### The Layout Engine: Yoga

At the core of our **PDF and Designer** pipeline is [Yoga](https://yogalayout.dev/) — the same flexbox layout engine that powers React Native. Every component in a template becomes a Yoga node with flex properties:

```plaintext
Template Section
├── Heading node (flex: row, alignItems: center)
├── Text node (marginBottom: 10)
├── DataTable node
│   ├── Header row (flex: row, backgroundColor: #f5f5f5)
│   └── Data rows (flex: row, borderBottom: 1px)
└── Total text node (flex: row, justifyContent: flex-end)
```

Yoga calculates the exact position and size of every element. This gives us pixel-perfect PDF layout and live preview in the designer — without parsing HTML or running a browser.

For **Excel**, we bypass the pixel layout engine entirely and map the component tree directly to spreadsheet rows and columns. Yoga deals in X/Y coordinates; Excel deals in rows and cells — so each renderer interprets the same component tree in the way that's native to its format.

![The architecture fork: JSON Template → Abstract Component Tree → Yoga/PDFKit for PDF, Grid Mapper/ExcelJS for Excel. We don't convert HTML to Excel — we render from a shared abstract source.](https://cdn.hashnode.com/res/hashnode/image/upload/v1770996380159/6b161243-73f5-4db5-8ba7-27d6c412b655.png align="center")

### PDF Renderer

The PDF renderer takes the Yoga layout tree and draws directly to PDFKit:

1. **Layout pass**: Yoga calculates positions for all nodes
    
2. **Render pass**: Each component type has a renderer that draws to PDFKit
    
    * `heading` → `doc.fontSize(24).text(...)`
        
    * `dataTable` → Table drawn with lines, cells, formatting
        
    * `image` → `doc.image(...)` with proper scaling
        
    * `chart` → Rendered to canvas, then embedded as image
        
3. **Pagination**: When content exceeds page height, automatic page breaks with header/footer repetition
    
4. **Output**: Native PDF file, no browser involved
    

**Result**: 200-500ms generation, ~50MB memory.

![The rendering fork: same Yoga layout tree feeds both the PDFKit renderer and ExcelJS renderer in parallel.](https://cdn.hashnode.com/res/hashnode/image/upload/v1770990574049/9d74cf48-524b-4c9e-ac0b-32a7f5badb46.png align="center")

### Excel Renderer

The Excel renderer interprets the same template for spreadsheet format:

1. **Sheet mapping**: Each template section can map to an Excel sheet
    
2. **Component translation**:
    
    * `heading` → Merged cells with bold formatting
        
    * `text` → Cell with text and formatting
        
    * `dataTable` → Excel table with headers, data rows, and auto-filters
        
    * `chart` → Native Excel chart object
        
    * `image` → Embedded image in cell
        
3. **Formula support**: Define native Excel formulas in your template for cells that should calculate in the spreadsheet
    
4. **Formatting**: Fonts, colors, borders, number formats translate to Excel styles
    

**Result**: A proper `.xlsx` file with native Excel features — not a CSV, not a screenshot of a table.

## Component-by-Component Translation

All 28 component types and how they map across formats:

### Content Components

| Component | PDF Rendering | Excel Rendering |
| --- | --- | --- |
| **Text** | Positioned text with font styling | Cell with formatted text |
| **Rich Text** | Multiple styles per line (bold, italic, links) | Rich text cell with formatting runs |
| **Heading** | Large text with configurable level (h1-h6) | Merged cells with bold/large font |
| **Title** | Large heading variant with emphasis | Merged cells with prominent styling |
| **Image** | Embedded image with scaling | Embedded image anchored to cell |
| **List** | Bulleted/numbered list items | Rows with indent and bullet/number prefix |
| **Divider** | Horizontal line (solid/dashed/dotted) | Bottom border on cells |
| **Spacer** | Empty space with configurable height | Empty row(s) |
| **SVG** | Rendered as embedded image | Rendered as embedded image |
| **QR Code** | Rendered as embedded image | Rendered as embedded image |
| **Barcode** | Rendered as embedded image (10 formats) | Rendered as embedded image |

### Layout Components

| Component | PDF Rendering | Excel Rendering |
| --- | --- | --- |
| **Container** | Wrapper with background, border, padding | Styled cell region |
| **Columns** | Flex layout side-by-side | Adjacent cells |
| **Table Layout** | Grid with cell positioning and spans | Cells with colspan/rowspan |
| **Conditional** | Show/hide based on expression | Show/hide based on expression |
| **Loop** | Repeated section for each array item | Repeated rows for each array item |
| **Page Break** | New PDF page | New Excel sheet or row separator |

### Data & Visualization Components

| Component | PDF Rendering | Excel Rendering |
| --- | --- | --- |
| **Table** | Drawn table with borders and styling | Native Excel table with auto-filters |
| **Chart** | Rendered as embedded image (8 chart types) | Native Excel chart object |
| **Key-Value** | Label-value pairs with layout options | Two-column cell pairs |
| **Formula** | N/A | Native Excel formula cell (`=SUM(...)`) |

### PDF Form Components (PDF only)

| Component | PDF Rendering |
| --- | --- |
| **Form Text** | Single-line text input field |
| **Form Textarea** | Multi-line text input |
| **Form Checkbox** | Boolean checkbox with label |
| **Form Radio** | Radio button group with options |
| **Form Dropdown** | Select dropdown with options |
| **Form Date** | Date picker with format configuration |
| **Form Signature** | Signature placeholder (text, image, or digital) |
| **Form Button** | Interactive button (print, reset, link) |

### What's Format-Specific

Some features only make sense in one format:

**PDF only:**

* 8 fillable form field types (text, textarea, checkbox, radio, dropdown, date, signature, button)
    
* Custom fonts
    
* Precise Yoga flexbox positioning
    
* Page headers and footers
    

**Excel only:**

* Native Excel formulas via Formula component
    
* Auto-filters on data tables
    
* Sortable columns
    
* Cell-level data types (dates as dates, numbers as numbers)
    

## The Variable System

Variables are format-agnostic. You define them once, and both renderers use the same data:

### Scalar Variables

```json
{ "name": "customerName", "type": "string", "defaultValue": "John Doe" }
```

Works identically in both formats — the value appears wherever `{{customerName}}` is used.

### Array Variables (Dynamic Tables)

```json
{
  "name": "items",
  "type": "array",
  "itemType": "object",
  "schema": {
    "itemSchema": {
      "properties": {
        "description": { "type": "string" },
        "quantity": { "type": "number" },
        "price": { "type": "number" }
      }
    }
  }
}
```

* **PDF**: Renders as a table with one row per array item, automatically paginating
    
* **Excel**: Creates data rows with proper column types and formatting
    

### Calculated Variables

```json
{ "name": "subtotal", "type": "number", "expression": "items.reduce((sum, item) => sum + item.quantity * item.price, 0)" }
```

* **PDF**: Evaluated server-side, result rendered as text
    
* **Excel**: Evaluated server-side, result placed in the cell as a static value. If you need live Excel formulas (e.g., `=SUM(D2:D10)`), define them separately in the template's Excel formula configuration
    

### System Variables

Both formats support system-provided variables like `__CURRENT_DATE__`, `__COMPANY_NAME__`, `__TEMPLATE_NAME__`, etc. These are resolved identically regardless of output format.

## Why This Architecture?

We considered several approaches before settling on the Yoga-based unified template:

### Rejected: HTML as the Source

We could have used HTML templates and converted to both PDF and Excel. But:

* HTML-to-Excel conversion is lossy — you can't preserve semantic data
    
* Tables in HTML become images or flat text in Excel, losing sort/filter capability
    
* Browser-based rendering is slow and resource-heavy
    

### Rejected: Separate Templates with Shared Schema

We could have had separate PDF and Excel templates that share the same variables. But:

* Two templates to maintain = two places for bugs
    
* Templates inevitably drift apart
    
* More work for template designers
    

### Chosen: Abstract Component Tree

By defining templates as an abstract component tree (not HTML, not Excel XML), each renderer can interpret components optimally for its format:

* The PDF renderer uses Yoga for precise layout
    
* The Excel renderer uses native Excel features (real tables, real formulas)
    
* Both consume the same template and variables
    

## Try It Yourself

You can see this in action in under 5 minutes:

1. [Sign up free](https://app.rynko.dev/signup) at Rynko
    
2. Create a template in the visual designer
    
3. Click "Preview" and toggle between PDF and Excel output
    
4. Send the same data via API and get both formats
    

Here's the dual-format payoff in code — same template, same variables, two API calls:

```typescript
import { Rynko } from '@rynko/sdk';

const rynko = new Rynko({ apiKey: process.env.RYNKO_API_KEY! });

const invoiceData = {
  templateId: 'invoice',
  variables: {
    invoiceNumber: 'INV-2026-001',
    customerName: 'Acme Corp',
    items: [
      { description: 'Consulting', quantity: 10, price: 150.00 },
      { description: 'Software License', quantity: 5, price: 99.00 },
    ],
    total: 1995.00,
  },
};

// PDF for the customer
const pdf = await rynko.documents.generatePdf(invoiceData);

// Excel for the finance team — same template, same data
const excel = await rynko.documents.generateExcel(invoiceData);
```

Or, if you're using Claude or Cursor, ask the AI to create a template and generate both formats:

```plaintext
Create an invoice template, then generate it as both PDF and Excel
with sample data for 3 line items
```

The same JSON data, the same template, two perfectly formatted documents.

**New to Rynko?** Follow our [Getting Started in 5 Minutes](https://blog.rynko.dev/getting-started-in-5-minutes) guide. Evaluating alternatives? See the [PDF Generation API Comparison](https://blog.rynko.dev/pdf-generation-api-comparison).

[Get Started Free](https://app.rynko.dev/signup) | [Template Schema Reference](https://docs.rynko.dev/developer-guide/template-schema) | [Rendering Engine](https://www.rynko.dev/engine)

---

*Questions about the rendering architecture? Join our* [*Discord*](https://discord.gg/d8cU2MG6) *or check the* [*engine page*](https://www.rynko.dev/engine) *for the full breakdown.*

---
*<sub>Disclosure: I ideate and draft content, then refine it with the aid of artificial intelligence tools like Claude and revise it to reflect my intended message.</sub>*
