Skip to content

Repository files navigation

pdf-forge

Multi-tenant PDF template engine powered by Typst
Forkeable · Agent-Friendly · Extensible · Production-Ready

Go VersionGo ReferenceLicenseGo Report CardCodeQLLatest ReleaseLast CommitRepo SizeContributorsAI Agent SkillAsk DeepWiki

pdf-forge Editor


Build document templates visually, inject dynamic data through plugins, generate PDFs on demand. Ships with React editor, multi-tenant RBAC, and OIDC auth.

Table of Contents

Screenshots

Templates
Template Management
Variables
Variable Injection
Preview
PDF Preview
Admin
Administration

How It Works

pdf-forge follows a plugin-based architecture:

  1. Templates - Create document templates in the visual editor with placeholders for dynamic content
  2. Injectables - Define variables (text, numbers, tables, images) that populate those placeholders
  3. Injectors - Write Go plugins that resolve variable values from any data source (CRM, DB, API)
  4. Render - Call the API with your payload, get a PDF back
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Template │ │ Injectors │ │ PDF │
│ (Visual Editor)│ ──▶ │ (Go Plugins) │ ──▶ │ (Typst) │
│ │ │ │ │ │
│ Placeholders: │ │ Resolve values │ │ Final document │
│ {{customer}} │ │ from any source │ │ with real data │
│ {{items_table}} │ │ │ │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘

Features

FeatureDescription
Visual EditorTipTap-based rich text with live PDF preview
Plugin ArchitectureCustom injectors for any data source (CRM, DB, API)
7 Value TypesString, Number, Bool, Time, Table, Image, List
Typst RenderingFast concurrent PDF generation with image caching
Multi-TenantTenant/workspace isolation with 3-level RBAC
Multi-OIDCSupport N identity providers (Keycloak, Auth0, etc.)
Embedded SPA (default)React 19 SPA embedded in the Go binary by default
ForkeableFork, customize core/extensions/, deploy
Lifecycle HooksOnStart() / OnShutdown() for background workers
Custom MiddlewareGlobal + API-only middleware chains
Dummy AuthDev mode without OIDC provider setup
Upgrade Doctormake check-upgrade verifies safety before merging

Tech Stack

LayerTechnology
BackendGo 1.25, Gin, PostgreSQL 16, golang-migrate
RenderingTypst (concurrent PDF generation with image caching)
FrontendReact 19, TypeScript, TanStack Router, Zustand
UITailwind CSS, Radix UI, TipTap (rich text editor)
AuthOIDC/JWKS (Keycloak, Auth0, Okta, Azure AD, etc.)
ServingGo HTTP server + embedded SPA (optional standalone app image)
InfraDocker Compose, multi-stage builds

Quick Start

Option A: Scaffold (recommended)

Create a new project using the SDK — no fork needed:

# 1. Scaffold a new project
go run github.com/rendis/pdf-forge/cmd/init@latest my-project --module github.com/myorg/my-project
# 2. Set upcd my-project
go mod tidy
# 3. Start everything (PostgreSQL + API + Frontend)
docker compose up --build

Local development (API only, no frontend):

make migrate
make dev

Option B: Fork

# 1. Fork on GitHub: click "Fork" at github.com/rendis/pdf-forge# 2. Clone your fork
git clone https://github.com/<you>/pdf-forge.git
cd pdf-forge
# 3. Set up upstream tracking
make init-fork
# 4. Start everything (PostgreSQL + API + Frontend)
docker compose up --build

Endpoints:

Local Development

Prerequisites:

DependencyVersionInstall
Go1.25+go.dev/dl
PostgreSQL16+brew install postgresql@16 or Docker
Typstlatestbrew install typst (included in Docker)
Node.js22+nodejs.org
pnpmlatestnpm install -g pnpm
# Start only PostgreSQL via Docker
docker compose up postgres -d
# Apply database migrations
make migrate
# Run backend with hot reload (terminal 1)
make dev
# Run frontend dev server (terminal 2)
make dev-app
# Verify system deps and build health
make doctor

Dev mode uses dummy auth (no OIDC setup needed) — auto-seeds an admin user on first run.

Fork Workflow

pdf-forge is designed to be forked and customized. You only modify core/extensions/ — the engine handles the rest.

┌─────────────────────────────────────────────────────────────────┐
│ SETUP (once) │
│ │
│ Fork on GitHub → git clone → make init-fork │
│ │
│ Customize: │
│ core/extensions/ ← Your injectors, mapper, middleware │
│ core/settings/ ← Your config (DB, auth, CORS) │
│ │
│ Deploy: docker compose up --build │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ UPGRADE (per release) │
│ │
│ 1. make check-upgrade VERSION=v1.2.0 │
│ ✓ Merge conflicts....... ok │
│ ✓ Build after merge..... ok │
│ ✓ Interface changes..... ok │
│ ✓ New migrations........ 2 new │
│ │
│ 2. make sync-upstream VERSION=v1.2.0 │
│ 3. make build && make test │
│ 4. make migrate (if new migrations) │
│ 5. docker compose up --build │
└─────────────────────────────────────────────────────────────────┘

Your extensions are safe: .gitattributes ensures your code in core/extensions/ takes priority on merge conflicts. check-upgrade simulates the merge and verifies your code still compiles before you commit.

See FORKING.md for the complete guide: Docker customization, Go module FAQ, alternative clone workflow, and contributing back.

Project Structure

core/ ← Backend Go (module: github.com/rendis/pdf-forge)
sdk/ ← PUBLIC API for external consumers (type aliases)
cmd/api/ ← Server entrypoint + bootstrap
extensions/ ← YOUR CODE: injectors, mapper, middleware, hooks
internal/ ← Engine internals (don't modify)
frontend/ ← Embedded SPA assets served by Go (`go:embed`)
settings/ ← Default configuration
docs/ ← Architecture, auth, extensibility docs
Makefile ← Backend-specific targets
app/ ← Frontend React SPA source
src/ ← React 19 + TypeScript + TanStack Router
dist/ ← Built SPA assets
Dockerfile ← Optional standalone frontend image (nginx)
nginx.conf ← Optional standalone SPA config
Makefile ← Frontend-specific targets
Makefile ← Root orchestrator (delegates to core/ and app/)
Dockerfile ← Unified image: frontend build + embedded backend
docker-compose.yaml ← Full stack: postgres + api

You only need to modify core/extensions/ to customize the engine.

SDK (Public API)

External consumers import github.com/rendis/pdf-forge/core/sdk — a single package that re-exports all extension types without exposing internal implementation:

import"github.com/rendis/pdf-forge/core/sdk"engine:=sdk.New()
engine.RegisterInjector(&MyInjector{})
engine.SetMapper(&MyMapper{})
engine.Run()

The SDK exposes:

CategoryTypes
EngineEngine, New(), NewWithConfig()
InterfacesInjector, RequestMapper, WorkspaceInjectableProvider, RenderAuthenticator
TypesInjectorContext, InjectorResult, InjectableValue, MapperContext, FormatConfig
ValuesStringValue(), NumberValue(), BoolValue(), TimeValue(), ImageValue()
TablesTableValue, TableColumn, Cell(), NewTableValue()
ListsListValue, ListSchema, NewListValue(), ListItemValue()
DesignTypstDesignTokens, DefaultDesignTokens()
ConstantsValueType*, InjectableDataType*, ListSymbol*

Note: Code inside core/extensions/ (within the module) can import internal/ directly. The sdk package is for external module consumers and forks.

Customizing (core/extensions/)

All user customization lives in core/extensions/register.go:

package extensions
import (
"github.com/rendis/pdf-forge/core/cmd/api/bootstrap""github.com/rendis/pdf-forge/core/extensions/injectors"
)
funcRegister(engine*bootstrap.Engine) {
// Register custom injectorsengine.RegisterInjector(&injectors.MyInjector{})
// Set request mapperengine.SetMapper(&MyMapper{})
// Set init function (runs before injectors on each render)engine.SetInitFunc(MyInit())
// Add middlewareengine.UseMiddleware(RequestLoggerMiddleware())
engine.UseAPIMiddleware(TenantValidationMiddleware())
// Lifecycle hooksengine.OnStart(func(ctx context.Context) error { returnnil })
engine.OnShutdown(func(ctx context.Context) error { returnnil })
}

See the stub files in core/extensions/ for documented examples of each extension point.

Writing an Injector

Injectors resolve dynamic values for template placeholders:

package injectors
import (
"context""time""github.com/rendis/pdf-forge/core/sdk"
)
typeCustomerNameInjectorstruct{}
func (i*CustomerNameInjector) Code() string { return"customer_name" }
func (i*CustomerNameInjector) DataType() sdk.ValueType {
returnsdk.ValueTypeString
}
func (i*CustomerNameInjector) Resolve() (sdk.ResolveFunc, []string) {
returnfunc(ctx context.Context, injCtx*sdk.InjectorContext) (*sdk.InjectorResult, error) {
payload:=injCtx.RequestPayload().(*MyPayload)
return&sdk.InjectorResult{
Value: sdk.StringValue(payload.CustomerName),
}, nil
}, nil// no dependencies
}
func (i*CustomerNameInjector) IsCritical() bool { returntrue }
func (i*CustomerNameInjector) Timeout() time.Duration { return5*time.Second }
func (i*CustomerNameInjector) DefaultValue() *sdk.InjectableValue { returnnil }
func (i*CustomerNameInjector) Formats() *sdk.FormatConfig { returnnil }

See Extensibility Guide for tables, images, lists, dependencies, and request mappers.

Configuration

# core/settings/app.yamlserver:
port: "8080"cors:
allowed_origins: ["*"]# allowed_headers: ["X-Environment"] # extra CORS headers (appended to built-in list)database:
host: localhostport: 5432name: pdf_forgetypst:
bin_path: typstmax_concurrent: 20# auth: omit for dummy mode (dev)

Environment Variables

Override any YAML key with DOC_ENGINE_ prefix (e.g., database.hostDOC_ENGINE_DATABASE_HOST):

Server

VariableDefaultDescription
DOC_ENGINE_SERVER_PORT8080HTTP port
DOC_ENGINE_SERVER_READ_TIMEOUT30Read timeout (seconds)
DOC_ENGINE_SERVER_WRITE_TIMEOUT30Write timeout (seconds)
DOC_ENGINE_SERVER_SHUTDOWN_TIMEOUT10Graceful shutdown (seconds)
DOC_ENGINE_SERVER_CORS_ALLOWED_HEADERS[]Extra CORS allowed headers

Database

VariableDefaultDescription
DOC_ENGINE_DATABASE_HOSTlocalhostPostgreSQL host
DOC_ENGINE_DATABASE_PORT5432PostgreSQL port
DOC_ENGINE_DATABASE_USERpostgresDB user
DOC_ENGINE_DATABASE_PASSWORD""DB password
DOC_ENGINE_DATABASE_NAMEpdf_forgeDB name
DOC_ENGINE_DATABASE_SSL_MODEdisableSSL mode
DOC_ENGINE_DATABASE_MAX_POOL_SIZE10Max open connections

Typst (Rendering)

VariableDefaultDescription
DOC_ENGINE_TYPST_BIN_PATHtypstPath to Typst binary
DOC_ENGINE_TYPST_MAX_CONCURRENT20Max parallel renders
DOC_ENGINE_TYPST_TIMEOUT_SECONDS10Max time per render
DOC_ENGINE_TYPST_ACQUIRE_TIMEOUT_SECONDS5Wait time for render slot
DOC_ENGINE_TYPST_IMAGE_CACHE_DIR""Persistent image cache directory

See Configuration Guide for OIDC, logging, performance tuning, and all options.

Docker

# Full stack (embedded frontend + API + PostgreSQL)
docker compose up --build
# Only database (for local dev)
docker compose up postgres
# Run migrations
make migrate

The default stack runs two services:

  • postgres (port 5432) - Database
  • api (port 8080) - Go backend with Typst and embedded frontend

Use docker-compose.override.yaml for local overrides (gitignored). See FORKING.md for examples.

Authentication

Development (Dummy Mode): Omit auth in config - auto-seeds admin user, no tokens required.

Production (OIDC): Configure providers in core/settings/app.yaml:

auth:
panel:
name: "keycloak"discovery_url: "https://auth.example.com/realms/web"client_id: "pdf-forge-web"render_providers: # Additional providers for render API only
- name: "internal-services"discovery_url: "https://auth.example.com/realms/services"

Supported providers: Keycloak, Auth0, Okta, Azure AD, AWS Cognito, Firebase.

Roles

Three-level RBAC: System > Tenant > Workspace

LevelRoles
SystemSUPERADMIN, PLATFORM_ADMIN
TenantTENANT_OWNER, TENANT_ADMIN
WorkspaceOWNER, ADMIN, EDITOR, OPERATOR, VIEWER

See Authorization Matrix for full permissions.

Architecture

POST /api/v1/workspace/document-types/{code}/render
│
▼
┌─────────────────────────────────────────────────────┐
│ 1. Mapper Parse request payload │
│ 2. Init Load shared data (CRM, DB) │
│ 3. Injectors Resolve values (topological) │
│ 4. Typst Generate PDF │
└─────────────────────────────────────────────────────┘
│
▼
PDF bytes

Endpoints

RouteDescriptionAuth
/api/v1/*Management API + render endpointsOIDC JWT / Dummy
/swagger/*API documentationNone
/health, /readyHealth checksNone
/*Embedded React SPA served by the Go HTTP serverNone

MCP Integration

Uses mcp-openapi-proxy — the repo now ships a default MCP setup that reads the committed OpenAPI 3.x spec and exposes a small navigator/executor surface instead of loading the whole schema into the model context.

Install:

go install github.com/rendis/mcp-openapi-proxy/cmd/mcp-openapi-proxy@latest

Versioned repo config:

Default contract:

  • MCP server name: pdf-forge
  • Tool prefix: pf
  • Registered tools:
    • pf_list_endpoints
    • pf_describe_endpoint
    • pf_call_endpoint

Endpoint discovery flow:

  1. pf_list_endpoints → find candidate endpoints
  2. pf_describe_endpoint → inspect the exact request/response contract
  3. pf_call_endpoint → execute the request with toolName

Example endpoint toolName values:

  • pf_get_api_v1_content_templates
  • pf_get_api_v1_content_templates_templateId
  • pf_post_api_v1_workspace_document_types_code_render
  • pf_post_api_v1_workspace_templates_versions_versionId_render

Important: mcp-openapi-proxy requires OpenAPI 3.x. This repo still generates Swagger 2.0 for Swagger UI, and make swagger now also converts it to core/docs/openapi.yaml for MCP use.

Multi-tenant headers: many panel routes require X-Tenant-ID and/or X-Workspace-ID; render routes require X-Tenant-Code, X-Workspace-Code, and X-Environment. Pass them per request in pf_call_endpoint.headers or set shared defaults via MCP_EXTRA_HEADERS.

See app/docs/mcp_setup.md for full setup (Claude Code, Codex, Gemini CLI, OIDC, troubleshooting) and skills/pdf-forge/SKILL.md for the agent-facing operating guide. For document editing through MCP, use the dedicated references: editor-capability-matrix.md, portable-document-contract.md, typst-rendering-boundaries.md, and mcp-editor-workflows.md.

Commands

# Build & Development
make build # Build backend + frontend
make build-core # Build Go backend only
make build-app # Build React frontend only
make run # Run API server
make dev # Hot reload backend (air)
make dev-app # Start Vite dev server
make migrate # Apply database migrations
make test# Run Go tests
make lint # Run golangci-lint
make swagger # Regenerate Swagger + OpenAPI specs# Docker
make docker-up # Start all services with Docker Compose
make docker-down # Stop all services# Fork Workflow
make init-fork # Set up upstream remote + merge drivers
make doctor # Check system dependencies and build health
make check-upgrade # Check if VERSION is safe to merge
make sync-upstream # Merge upstream VERSION into current branch
make clean # Remove all build artifacts

Documentation

DocumentDescription
FORKING.mdFork workflow, upgrading, FAQ
ArchitectureHexagonal design, domain organization
Extensibility GuideInjectors, mappers, init functions
ConfigurationYAML config, OIDC setup
Value TypesString, Number, Table, Image, List
Authorization MatrixRBAC roles and permissions
Database SchemaMulti-tenant model, ER diagrams
DeploymentDocker, Kubernetes patterns
TroubleshootingRendering, auth, DB, frontend issues
MCP Setupmcp-openapi-proxy, Claude, Codex, OIDC
Agent SkillOperational MCP guidance for agents
Editor Capability MatrixUI vs schema vs Typst vs agent-safe support
Portable Document ContractcontentStructure / PortableDoc envelope
Typst Rendering BoundariesRendering limits and safe assumptions
MCP Editor WorkflowsRead-modify-write playbooks for agents

AI Agent Skill

pdf-forge is agent-friendly. Install the skill to let AI agents (Claude Code, Codex, Gemini CLI, Cursor, etc.) operate templates and rendering through MCP with full project context:

npx skills add https://github.com/rendis/pdf-forge --skill pdf-forge

Start with skills/pdf-forge/SKILL.md, then follow the linked editor/PortableDoc/Typst references when editing contentStructure.

Contributing

make build && make test&& make lint
make swagger # if API changed

See FORKING.md for the PR workflow.

License

MIT

About

Installable Go module for multi-tenant document template building with on-demand PDF generation via Typst.

Resources

Code of conduct

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages