Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,16 @@
.PHONY: test samples
.PHONY: binaries test samples

SAMPLES := $(sort $(wildcard samples/*/*.go))
BIN_DIR := bin
BINARY_PKGS := ./cmd/render-ltml ./cmd/serve-ltml ./ttdump

binaries:
@mkdir -p $(BIN_DIR)
@for pkg in $(BINARY_PKGS); do \
name=$$(basename $$pkg); \
echo "==> go build -o $(BIN_DIR)/$$name $$pkg"; \
go build -o $(BIN_DIR)/$$name $$pkg || exit $$?; \
done

test:
go test ./...
Expand Down
49 changes: 49 additions & 0 deletions cmd/render-ltml/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# render-ltml

`render-ltml` converts an LTML document to PDF and writes the result to a file or stdout.

## Usage

```
render-ltml [flags] <file.ltml>
```

### Flags

| Flag | Short | Description |
|------|-------|-------------|
| `-assets <dir>` | `-a` | Directory of static assets available during rendering |
| `-extra <file>` | `-e` | Additional asset file (may be repeated) |
| `-output <file>` | `-o` | Write PDF to this file instead of stdout |

### Asset resolution

When `-assets` and/or `-extra` are given, a virtual filesystem is constructed and attached to the PDF writer before rendering. Asset-backed PDF operations resolve through this filesystem:

- Files supplied with `-extra` form the **upper layer** and shadow same-named files from `-assets`.
- Files in the `-assets` directory form the **lower layer** and are used when an asset is not supplied as an extra file.
- When neither flag is given, asset paths are resolved by the PDF writer directly (relative to the working directory).

When an asset filesystem is attached, asset names must be clean relative `fs.FS` paths such as `logo.png` or `assets/logo.png`. Paths like `./logo.png`, `a/../logo.png`, or absolute paths are rejected.

If the same base name is given more than once via `-extra`, the last occurrence wins.

## Examples

Render to stdout and pipe into a PDF viewer:

```sh
render-ltml report.ltml | zathura -
```

Render to a file with a directory of shared assets:

```sh
render-ltml -a ./assets -o report.pdf report.ltml
```

Override one asset without touching the shared directory:

```sh
render-ltml -a ./assets -e ./branded/logo.png -o report.pdf report.ltml
```
107 changes: 63 additions & 44 deletions cmd/render-ltml/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import (
"flag"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"

"github.com/rowland/leadtype/internal/overlayfs"
"github.com/rowland/leadtype/ltml"
"github.com/rowland/leadtype/ltml/ltpdf"
)
Expand Down Expand Up @@ -57,7 +59,6 @@ func main() {
}

func run(inputFile, assetsDir, outputPath string, extraFiles []string) error {
// Resolve paths before potentially changing directory.
absInput, err := filepath.Abs(inputFile)
if err != nil {
return fmt.Errorf("resolving input: %w", err)
Expand All @@ -71,26 +72,26 @@ func run(inputFile, assetsDir, outputPath string, extraFiles []string) error {
}
}

// Set up asset working directory when assets or extra files are provided.
if assetsDir != "" || len(extraFiles) > 0 {
workDir, cleanup, err := setupWorkDir(assetsDir, extraFiles)
if err != nil {
return err
}
defer cleanup()
if err := os.Chdir(workDir); err != nil {
return fmt.Errorf("chdir to work dir: %w", err)
}
}

// Parse LTML.
doc, err := ltml.ParseFile(absInput)
if err != nil {
return fmt.Errorf("parsing %s: %w", inputFile, err)
}

// Build and attach an asset filesystem when assets or extra files are provided.
assetFS, cleanup, err := buildOptionalAssetFS(assetsDir, extraFiles)
if err != nil {
return err
}
if cleanup != nil {
defer cleanup()
}

// Render to PDF.
w := ltpdf.NewDocWriter()
if assetFS != nil {
w.SetAssetFS(assetFS)
}
if err := doc.Print(w); err != nil {
return fmt.Errorf("rendering: %w", err)
}
Expand All @@ -114,49 +115,67 @@ func run(inputFile, assetsDir, outputPath string, extraFiles []string) error {
return nil
}

// setupWorkDir creates a temporary directory populated with symlinks to the
// contents of assetsDir and each file in extraFiles. The caller must invoke
// the returned cleanup function when rendering is complete.
func setupWorkDir(assetsDir string, extraFiles []string) (string, func(), error) {
tmpDir, err := os.MkdirTemp("", "render-ltml-*")
if err != nil {
return "", nil, fmt.Errorf("creating work dir: %w", err)
// buildOptionalAssetFS constructs an optional fs.FS that covers assetsDir
// (lower layer) and the named extraFiles (upper layer, each stored under its
// base name). Extra files shadow same-named entries in assetsDir rather than
// erroring on conflict.
//
// Returns nil, nil, nil when neither assetsDir nor extraFiles are provided.
// When a non-nil cleanup function is returned, the caller must invoke it after
// rendering is complete.
func buildOptionalAssetFS(assetsDir string, extraFiles []string) (fs.FS, func(), error) {
hasAssets := assetsDir != ""
hasExtras := len(extraFiles) > 0

if !hasAssets && !hasExtras {
return nil, nil, nil
}
cleanup := func() { os.RemoveAll(tmpDir) }

if assetsDir != "" {
absAssets, err := filepath.Abs(assetsDir)
// Place extra files in a temp directory so they can be addressed as an
// os.DirFS. Symlinks keep memory use low for large files.
var extraDir string
var cleanup func()
if hasExtras {
tmpDir, err := os.MkdirTemp("", "render-ltml-*")
if err != nil {
cleanup()
return "", nil, fmt.Errorf("resolving assets dir: %w", err)
}
entries, err := os.ReadDir(absAssets)
if err != nil {
cleanup()
return "", nil, fmt.Errorf("reading assets dir: %w", err)
return nil, nil, fmt.Errorf("creating work dir: %w", err)
}
for _, entry := range entries {
src := filepath.Join(absAssets, entry.Name())
dst := filepath.Join(tmpDir, entry.Name())
if err := os.Symlink(src, dst); err != nil {
cleanup = func() { os.RemoveAll(tmpDir) }
extraDir = tmpDir

for _, f := range extraFiles {
abs, err := filepath.Abs(f)
if err != nil {
cleanup()
return nil, nil, fmt.Errorf("resolving extra file %s: %w", f, err)
}
dst := filepath.Join(extraDir, filepath.Base(f))
// If two extra files share a base name, the last one wins.
os.Remove(dst)
if err := os.Symlink(abs, dst); err != nil {
cleanup()
return "", nil, fmt.Errorf("linking asset %s: %w", entry.Name(), err)
return nil, nil, fmt.Errorf("linking extra file %s: %w", filepath.Base(f), err)
}
}
}

for _, f := range extraFiles {
abs, err := filepath.Abs(f)
switch {
case hasExtras && hasAssets:
absAssets, err := filepath.Abs(assetsDir)
if err != nil {
cleanup()
return "", nil, fmt.Errorf("resolving extra file %s: %w", f, err)
return nil, nil, fmt.Errorf("resolving assets dir: %w", err)
}
dst := filepath.Join(tmpDir, filepath.Base(f))
if err := os.Symlink(abs, dst); err != nil {
cleanup()
return "", nil, fmt.Errorf("linking extra file %s: %w", filepath.Base(f), err)
return overlayfs.New(os.DirFS(extraDir), os.DirFS(absAssets)), cleanup, nil

case hasExtras:
return os.DirFS(extraDir), cleanup, nil

default: // hasAssets only
absAssets, err := filepath.Abs(assetsDir)
if err != nil {
return nil, nil, fmt.Errorf("resolving assets dir: %w", err)
}
return os.DirFS(absAssets), nil, nil
}

return tmpDir, cleanup, nil
}
67 changes: 67 additions & 0 deletions cmd/render-ltml/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
// Copyright 2026 Brent Rowland.
// Use of this source code is governed by the Apache License, Version 2.0, as described in the LICENSE file.

package main

import (
"io/fs"
"os"
"path/filepath"
"testing"
)

func TestBuildOptionalAssetFS_ExtraOverridesAssetsDir(t *testing.T) {
assetsDir := t.TempDir()
if err := os.WriteFile(filepath.Join(assetsDir, "logo.txt"), []byte("lower"), 0o600); err != nil {
t.Fatal(err)
}

extraDir := t.TempDir()
extraFile := filepath.Join(extraDir, "logo.txt")
if err := os.WriteFile(extraFile, []byte("upper"), 0o600); err != nil {
t.Fatal(err)
}

assetFS, cleanup, err := buildOptionalAssetFS(assetsDir, []string{extraFile})
if err != nil {
t.Fatal(err)
}
if cleanup != nil {
defer cleanup()
}

data, err := fs.ReadFile(assetFS, "logo.txt")
if err != nil {
t.Fatal(err)
}
if string(data) != "upper" {
t.Fatalf("expected upper override, got %q", data)
}
}

func TestBuildOptionalAssetFS_PreservesNestedAssetPathsFromAssetsDir(t *testing.T) {
assetsDir := t.TempDir()
nestedDir := filepath.Join(assetsDir, "assets")
if err := os.MkdirAll(nestedDir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(nestedDir, "logo.txt"), []byte("nested"), 0o600); err != nil {
t.Fatal(err)
}

assetFS, cleanup, err := buildOptionalAssetFS(assetsDir, nil)
if err != nil {
t.Fatal(err)
}
if cleanup != nil {
defer cleanup()
}

data, err := fs.ReadFile(assetFS, "assets/logo.txt")
if err != nil {
t.Fatal(err)
}
if string(data) != "nested" {
t.Fatalf("expected nested asset, got %q", data)
}
}
92 changes: 92 additions & 0 deletions cmd/serve-ltml/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# serve-ltml

`serve-ltml` is an HTTP server that renders LTML documents to PDF on demand. Clients submit an LTML document and optional asset files in a single `multipart/form-data` request and receive a PDF response.

## Usage

```
serve-ltml [flags]
```

Every flag can also be set via the corresponding environment variable.

### Flags and environment variables

| Flag | Environment variable | Default | Description |
|------|----------------------|---------|-------------|
| `-listen <addr>` | `LISTEN` | `:8080` | Address to listen on |
| `-base-path <dir>` | `BASE_PATH` | *(required)* | Directory of static assets available to all requests |
| `-max-upload-bytes <n>` | `MAX_UPLOAD_BYTES` | `33554432` (32 MiB) | Maximum request body size |
| `-read-timeout <duration>` | `READ_TIMEOUT` | none | HTTP server read timeout (e.g. `30s`) |
| `-write-timeout <duration>` | `WRITE_TIMEOUT` | none | HTTP server write timeout (e.g. `60s`) |

`BASE_PATH` must exist and be a directory; the server refuses to start otherwise.

## API

### `POST /render`

Render an LTML document to PDF.

**Request**

`Content-Type: multipart/form-data`

| Part | Field name | Required | Description |
|------|------------|----------|-------------|
| LTML document | `ltml` | Yes | Must be the **first** part. Preferred content type: `application/vnd.rowland.leadtype.ltml+xml`; `application/xml`, `text/xml`, and no content type are also accepted. |
| Asset file | `file` | No | May be repeated. The part's `filename` parameter is used as the virtual asset path (e.g. `logo.png` or `assets/logo.png`). |

**Response**

| Status | Meaning |
|--------|---------|
| `200 OK` | PDF rendered successfully. Body is the PDF; `Content-Type: application/pdf`; `Content-Disposition: inline; filename="output.pdf"`. |
| `400 Bad Request` | Malformed multipart body, missing or misplaced `ltml` part, empty LTML, or invalid upload filename. |
| `405 Method Not Allowed` | Request method is not `POST`. |
| `413 Request Entity Too Large` | Request body exceeds `-max-upload-bytes`. |
| `500 Internal Server Error` | Temp-file, parse, render, or stream failure. |

### Asset resolution

Uploaded files form a **per-request upper layer** that shadows same-named files in the configured `base-path` for the duration of that request only. Parallel requests never share upload state. Uploaded filenames must be clean relative `fs.FS` paths such as `logo.png` or `assets/logo.png`; empty names, `.`, paths containing `.` / `..` segments, and absolute paths are rejected.

## Examples

Start the server:

```sh
serve-ltml -base-path /var/lib/ltml/assets
```

Or with environment variables:

```sh
BASE_PATH=/var/lib/ltml/assets READ_TIMEOUT=30s WRITE_TIMEOUT=60s serve-ltml
```

Render a document with no uploaded assets:

```sh
curl -s \
-F 'ltml=@report.ltml;type=application/vnd.rowland.leadtype.ltml+xml' \
http://localhost:8080/render -o report.pdf
```

Render with an asset that overrides the server's base-path copy:

```sh
curl -s \
-F 'ltml=@report.ltml;type=application/vnd.rowland.leadtype.ltml+xml' \
-F 'file=@./branded/logo.png;filename=logo.png' \
http://localhost:8080/render -o report.pdf
```

Place an asset at a nested path:

```sh
curl -s \
-F 'ltml=@report.ltml;type=application/vnd.rowland.leadtype.ltml+xml' \
-F 'file=@./img/logo.png;filename=assets/logo.png' \
http://localhost:8080/render -o report.pdf
```
Loading
Loading