Initial rDesign service — Scribus-based document design & PDF generation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-24 01:38:36 +00:00
commit a686a4a142
14 changed files with 1358 additions and 0 deletions

9
.env.example Normal file
View File

@ -0,0 +1,9 @@
# rDesign environment
# Copy to .env and fill in values
# Infisical (secrets fetched at runtime)
INFISICAL_CLIENT_ID=
INFISICAL_CLIENT_SECRET=
# VNC password for the studio container
VNC_PASSWORD=changeme

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
output/
jobs/
__pycache__/
*.pyc
.env
*.egg-info/
.venv/

41
Dockerfile Normal file
View File

@ -0,0 +1,41 @@
FROM python:3.11-slim AS base
# Install Scribus and dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
scribus \
scribus-data \
xvfb \
fonts-liberation \
fonts-dejavu \
fonts-noto \
fonts-noto-color-emoji \
imagemagick \
ghostscript \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt /app/requirements.txt
RUN pip install --no-cache-dir -r /app/requirements.txt
# Copy application code
COPY server/ /app/server/
COPY scripts/ /app/scripts/
WORKDIR /app
# Create directories
RUN mkdir -p /app/templates /app/output /app/jobs
# Allow ImageMagick to process PDFs (needed for thumbnails)
RUN if [ -f /etc/ImageMagick-6/policy.xml ]; then \
sed -i 's/rights="none" pattern="PDF"/rights="read|write" pattern="PDF"/' /etc/ImageMagick-6/policy.xml; \
fi
ENV SCRIBUS_PATH=/usr/bin/scribus
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD curl -f http://localhost:8080/health || exit 1
CMD ["uvicorn", "server.app:app", "--host", "0.0.0.0", "--port", "8080"]

42
Dockerfile.studio Normal file
View File

@ -0,0 +1,42 @@
FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive
ENV DISPLAY=:1
# Install Scribus, VNC, noVNC, and a lightweight window manager
RUN apt-get update && apt-get install -y --no-install-recommends \
scribus \
scribus-data \
tigervnc-standalone-server \
novnc \
websockify \
openbox \
xterm \
fonts-liberation \
fonts-dejavu \
fonts-noto \
fonts-noto-color-emoji \
dbus-x11 \
x11-utils \
procps \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create app user
RUN useradd -m -s /bin/bash designer
# Create directories
RUN mkdir -p /app/templates /app/output /app/rswag-designs \
&& chown -R designer:designer /app
# Copy startup script
COPY studio/start.sh /start.sh
COPY studio/openbox-rc.xml /home/designer/.config/openbox/rc.xml
RUN chmod +x /start.sh && chown -R designer:designer /home/designer
EXPOSE 6080
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD curl -sf http://localhost:6080/ || exit 1
CMD ["/start.sh"]

76
docker-compose.yml Normal file
View File

@ -0,0 +1,76 @@
services:
rdesign-api:
build:
context: .
dockerfile: Dockerfile
container_name: rdesign-api
restart: unless-stopped
volumes:
- ./templates:/app/templates
- ./output:/app/output
- ./jobs:/app/jobs
- ./scripts:/app/scripts
# Access rSwag designs for integration
- /opt/apps/rswag/designs:/app/rswag-designs:ro
environment:
- PYTHONUNBUFFERED=1
- BASE_URL=https://scribus.rspace.online
- RSWAG_DESIGNS_PATH=/app/rswag-designs
labels:
- "traefik.enable=true"
- "traefik.http.routers.rdesign-api.rule=Host(`scribus.rspace.online`) && !PathPrefix(`/vnc`)"
- "traefik.http.routers.rdesign-api.entrypoints=websecure"
- "traefik.http.routers.rdesign-api.tls.certresolver=letsencrypt"
- "traefik.http.services.rdesign-api.loadbalancer.server.port=8080"
- "traefik.http.middlewares.rdesign-cors.headers.accesscontrolallowmethods=GET,POST,OPTIONS"
- "traefik.http.middlewares.rdesign-cors.headers.accesscontrolallowheaders=*"
- "traefik.http.middlewares.rdesign-cors.headers.accesscontrolalloworiginlist=https://scribus.rspace.online,https://rswag.online"
- "traefik.http.middlewares.rdesign-cors.headers.accesscontrolmaxage=86400"
- "traefik.http.routers.rdesign-api.middlewares=rdesign-cors"
networks:
- traefik-public
deploy:
resources:
limits:
cpus: "4"
memory: 8G
reservations:
cpus: "1"
memory: 2G
rdesign-studio:
build:
context: .
dockerfile: Dockerfile.studio
container_name: rdesign-studio
restart: unless-stopped
volumes:
- ./templates:/app/templates
- ./output:/app/output
- /opt/apps/rswag/designs:/app/rswag-designs:ro
environment:
- VNC_PASSWORD=changeme
- DISPLAY_WIDTH=1920
- DISPLAY_HEIGHT=1080
labels:
- "traefik.enable=true"
- "traefik.http.routers.rdesign-studio.rule=Host(`scribus.rspace.online`) && PathPrefix(`/vnc`)"
- "traefik.http.routers.rdesign-studio.entrypoints=websecure"
- "traefik.http.routers.rdesign-studio.tls.certresolver=letsencrypt"
- "traefik.http.services.rdesign-studio.loadbalancer.server.port=6080"
- "traefik.http.routers.rdesign-studio.middlewares=rdesign-studio-strip"
- "traefik.http.middlewares.rdesign-studio-strip.stripprefix.prefixes=/vnc"
networks:
- traefik-public
deploy:
resources:
limits:
cpus: "2"
memory: 4G
reservations:
cpus: "0.5"
memory: 1G
networks:
traefik-public:
external: true

8
requirements.txt Normal file
View File

@ -0,0 +1,8 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
pydantic==2.10.3
python-multipart==0.0.19
pyyaml==6.0.2
aiofiles==24.1.0
httpx==0.28.1
Pillow==11.1.0

99
scripts/convert_idml.py Normal file
View File

@ -0,0 +1,99 @@
#!/usr/bin/env python3
"""
Scribus script: Import an IDML file (InDesign Markup Language) and export
to SLA (Scribus native) and/or PDF.
Scribus has a built-in IDML import filter. This script leverages it headlessly.
Called via: scribus -g -ns -py convert_idml.py -- [args]
"""
import sys
import os
def parse_args():
args = {}
argv = sys.argv[1:]
i = 0
while i < len(argv):
if argv[i] == "--input" and i + 1 < len(argv):
args["input"] = argv[i + 1]
i += 2
elif argv[i] == "--output-sla" and i + 1 < len(argv):
args["output_sla"] = argv[i + 1]
i += 2
elif argv[i] == "--output-pdf" and i + 1 < len(argv):
args["output_pdf"] = argv[i + 1]
i += 2
elif argv[i] == "--dpi" and i + 1 < len(argv):
args["dpi"] = int(argv[i + 1])
i += 2
else:
i += 1
return args
def main():
try:
import scribus
except ImportError:
print("ERROR: Must run inside Scribus (scribus -g -py)")
sys.exit(1)
args = parse_args()
input_file = args.get("input")
output_sla = args.get("output_sla")
output_pdf = args.get("output_pdf")
dpi = args.get("dpi", 300)
if not input_file:
print("ERROR: --input required")
sys.exit(1)
if not os.path.exists(input_file):
print(f"ERROR: File not found: {input_file}")
sys.exit(1)
if not output_sla and not output_pdf:
print("ERROR: At least one of --output-sla or --output-pdf required")
sys.exit(1)
# Open the IDML file — Scribus handles import via its built-in filter
try:
scribus.openDoc(input_file)
print(f"Opened: {input_file}")
print(f"Pages: {scribus.pageCount()}")
except Exception as e:
print(f"ERROR: Could not open {input_file}: {e}")
sys.exit(1)
# Save as SLA (Scribus native format)
if output_sla:
try:
scribus.saveDocAs(output_sla)
print(f"Saved SLA: {output_sla}")
except Exception as e:
print(f"ERROR saving SLA: {e}")
# Export to PDF
if output_pdf:
try:
pdf = scribus.PDFfile()
pdf.file = output_pdf
pdf.quality = 0
pdf.resolution = dpi
pdf.version = 14
pdf.compress = True
pdf.compressmtd = 0
pdf.save()
print(f"Exported PDF: {output_pdf}")
except Exception as e:
print(f"ERROR exporting PDF: {e}")
scribus.closeDoc()
print("Done.")
if __name__ == "__main__":
main()

115
scripts/export_document.py Normal file
View File

@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""
Scribus headless export script.
Called via: scribus -g -ns -py export_document.py -- [args]
Exports a .sla template to PDF or PNG, optionally substituting variables.
"""
import sys
import os
# Parse args after '--' separator
def parse_args():
args = {}
variables = {}
argv = sys.argv[1:] # Scribus passes script args after '--'
i = 0
while i < len(argv):
if argv[i] == "--input" and i + 1 < len(argv):
args["input"] = argv[i + 1]
i += 2
elif argv[i] == "--output" and i + 1 < len(argv):
args["output"] = argv[i + 1]
i += 2
elif argv[i] == "--format" and i + 1 < len(argv):
args["format"] = argv[i + 1]
i += 2
elif argv[i] == "--dpi" and i + 1 < len(argv):
args["dpi"] = int(argv[i + 1])
i += 2
elif argv[i] == "--var" and i + 1 < len(argv):
key, _, value = argv[i + 1].partition("=")
variables[key] = value
i += 2
else:
i += 1
args["variables"] = variables
return args
def main():
try:
import scribus
except ImportError:
print("ERROR: This script must be run inside Scribus (scribus -g -py)")
sys.exit(1)
args = parse_args()
input_file = args.get("input")
output_file = args.get("output")
fmt = args.get("format", "pdf")
dpi = args.get("dpi", 300)
variables = args.get("variables", {})
if not input_file or not output_file:
print("ERROR: --input and --output required")
sys.exit(1)
# Open the document
try:
scribus.openDoc(input_file)
except Exception as e:
print(f"ERROR: Could not open {input_file}: {e}")
sys.exit(1)
# Substitute variables in text frames
if variables:
page_count = scribus.pageCount()
for page_num in range(1, page_count + 1):
scribus.gotoPage(page_num)
items = scribus.getPageItems()
for item_name, item_type, _ in items:
# item_type 4 = text frame
if item_type == 4:
try:
text = scribus.getText(item_name)
modified = False
for key, value in variables.items():
placeholder = f"%{key}%"
if placeholder in text:
text = text.replace(placeholder, value)
modified = True
if modified:
scribus.selectText(0, scribus.getTextLength(item_name), item_name)
scribus.deleteText(item_name)
scribus.insertText(text, 0, item_name)
except Exception:
continue
# Export
if fmt == "pdf":
pdf = scribus.PDFfile()
pdf.file = output_file
pdf.quality = 0 # Maximum quality
pdf.resolution = dpi
pdf.version = 14 # PDF 1.4
pdf.compress = True
pdf.compressmtd = 0 # Automatic
pdf.save()
elif fmt == "png":
# Export each page as PNG
scribus.savePageAsEPS(output_file.replace(".png", ".eps"))
# Note: for PNG, we rely on post-processing with ghostscript/imagemagick
print(f"EPS exported to {output_file.replace('.png', '.eps')}")
print("Convert with: convert -density {dpi} file.eps file.png")
elif fmt == "svg":
# Scribus can export to SVG
scribus.saveDoc()
scribus.closeDoc()
print(f"Exported: {output_file}")
if __name__ == "__main__":
main()

180
scripts/rswag_export.py Normal file
View File

@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""
Scribus script: Create a print-ready PDF from an rSwag design image.
Generates a new Scribus document with the design placed on a page with
optional bleed and crop marks.
Called via: scribus -g -ns -py rswag_export.py -- [args]
"""
import sys
import os
# Paper sizes in mm (width, height)
PAPER_SIZES = {
"A4": (210, 297),
"A3": (297, 420),
"A5": (148, 210),
"Letter": (215.9, 279.4),
"Tabloid": (279.4, 431.8),
}
BLEED_MM = 3.0
CROP_MARK_LEN = 5.0
CROP_MARK_OFFSET = 3.0
def parse_args():
args = {"bleed": False, "crop_marks": False}
argv = sys.argv[1:]
i = 0
while i < len(argv):
if argv[i] == "--image" and i + 1 < len(argv):
args["image"] = argv[i + 1]
i += 2
elif argv[i] == "--output" and i + 1 < len(argv):
args["output"] = argv[i + 1]
i += 2
elif argv[i] == "--paper" and i + 1 < len(argv):
args["paper"] = argv[i + 1]
i += 2
elif argv[i] == "--dpi" and i + 1 < len(argv):
args["dpi"] = int(argv[i + 1])
i += 2
elif argv[i] == "--title" and i + 1 < len(argv):
args["title"] = argv[i + 1]
i += 2
elif argv[i] == "--bleed":
args["bleed"] = True
i += 1
elif argv[i] == "--crop-marks":
args["crop_marks"] = True
i += 1
else:
i += 1
return args
def main():
try:
import scribus
except ImportError:
print("ERROR: Must run inside Scribus")
sys.exit(1)
args = parse_args()
image_path = args.get("image")
output_path = args.get("output")
paper = args.get("paper", "A4")
dpi = args.get("dpi", 300)
title = args.get("title", "rSwag Design")
if not image_path or not output_path:
print("ERROR: --image and --output required")
sys.exit(1)
if not os.path.exists(image_path):
print(f"ERROR: Image not found: {image_path}")
sys.exit(1)
# Get paper dimensions
paper_w, paper_h = PAPER_SIZES.get(paper, PAPER_SIZES["A4"])
# Calculate page size with bleed
bleed = BLEED_MM if args["bleed"] else 0
page_w = paper_w + (bleed * 2)
page_h = paper_h + (bleed * 2)
# Create new document (dimensions in mm, SCRIBUS_UNIT_MILLIMETERS = 1)
scribus.newDocument(
(page_w, page_h), # page size
(10, 10, 10, 10), # margins (left, right, top, bottom)
scribus.PORTRAIT,
1, # first page number
scribus.UNIT_MILLIMETERS,
scribus.PAGE_1, # single page facing
0, # first page left
1, # number of pages
)
# Place the image centered on the page
# Calculate placement: center the image on the page
margin = 15 # mm margin around image
img_area_w = paper_w - (margin * 2)
img_area_h = paper_h - (margin * 2)
img_x = bleed + margin
img_y = bleed + margin
# Create image frame
img_frame = scribus.createImage(img_x, img_y, img_area_w, img_area_h, "design")
scribus.loadImage(image_path, img_frame)
# Scale image to fit frame proportionally
scribus.setScaleImageToFrame(True, True, img_frame)
# Add title text below the image
title_y = bleed + margin + img_area_h + 5
title_frame = scribus.createText(
bleed + margin, title_y,
img_area_w, 10,
"title"
)
scribus.setText(title, title_frame)
scribus.setFontSize(12, title_frame)
scribus.setTextAlignment(scribus.ALIGN_CENTERED, title_frame)
# Add crop marks if requested
if args["crop_marks"]:
mark_color = "Registration"
# Ensure Registration color exists (it's a default Scribus color)
line_width = 0.25 # mm
# Top-left corner
for mark_args in [
# Top-left
(bleed - CROP_MARK_OFFSET - CROP_MARK_LEN, bleed, CROP_MARK_LEN, 0),
(bleed, bleed - CROP_MARK_OFFSET - CROP_MARK_LEN, 0, CROP_MARK_LEN),
# Top-right
(bleed + paper_w + CROP_MARK_OFFSET, bleed, CROP_MARK_LEN, 0),
(bleed + paper_w, bleed - CROP_MARK_OFFSET - CROP_MARK_LEN, 0, CROP_MARK_LEN),
# Bottom-left
(bleed - CROP_MARK_OFFSET - CROP_MARK_LEN, bleed + paper_h, CROP_MARK_LEN, 0),
(bleed, bleed + paper_h + CROP_MARK_OFFSET, 0, CROP_MARK_LEN),
# Bottom-right
(bleed + paper_w + CROP_MARK_OFFSET, bleed + paper_h, CROP_MARK_LEN, 0),
(bleed + paper_w, bleed + paper_h + CROP_MARK_OFFSET, 0, CROP_MARK_LEN),
]:
x, y, w, h = mark_args
if w > 0:
line = scribus.createLine(x, y, x + w, y)
else:
line = scribus.createLine(x, y, x, y + h)
scribus.setLineWidth(line_width, line)
# Export to PDF
pdf = scribus.PDFfile()
pdf.file = output_path
pdf.quality = 0 # Maximum
pdf.resolution = dpi
pdf.version = 14 # PDF 1.4
pdf.compress = True
pdf.compressmtd = 0
if args["bleed"]:
pdf.useDocBleeds = False
pdf.bleedt = BLEED_MM
pdf.bleedb = BLEED_MM
pdf.bleedl = BLEED_MM
pdf.bleedr = BLEED_MM
if args["crop_marks"]:
pdf.cropMarks = True
pdf.markLength = CROP_MARK_LEN
pdf.markOffset = CROP_MARK_OFFSET
pdf.save()
scribus.closeDoc()
print(f"Exported: {output_path}")
if __name__ == "__main__":
main()

0
server/__init__.py Normal file
View File

716
server/app.py Normal file
View File

@ -0,0 +1,716 @@
"""
rDesign Document design & PDF generation service.
Headless Scribus automation API, following the blender-automation pattern.
"""
import asyncio
import os
import uuid
import glob as globmod
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import yaml
import aiofiles
from fastapi import FastAPI, BackgroundTasks, HTTPException, UploadFile, File, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Config
# ---------------------------------------------------------------------------
BASE_URL = os.getenv("BASE_URL", "https://scribus.rspace.online")
SCRIBUS_PATH = os.getenv("SCRIBUS_PATH", "/usr/bin/scribus")
TEMPLATES_DIR = Path(os.getenv("TEMPLATES_DIR", "/app/templates"))
OUTPUT_DIR = Path(os.getenv("OUTPUT_DIR", "/app/output"))
JOBS_DIR = Path(os.getenv("JOBS_DIR", "/app/jobs"))
RSWAG_DESIGNS_PATH = Path(os.getenv("RSWAG_DESIGNS_PATH", "/app/rswag-designs"))
# ---------------------------------------------------------------------------
# App
# ---------------------------------------------------------------------------
app = FastAPI(
title="rDesign",
description="Document design & PDF generation — headless Scribus automation",
version="0.1.0",
)
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://scribus.rspace.online",
"https://rswag.online",
],
allow_methods=["*"],
allow_headers=["*"],
)
# ---------------------------------------------------------------------------
# In-memory job store (same pattern as blender-automation)
# ---------------------------------------------------------------------------
jobs: dict[str, dict] = {}
# ---------------------------------------------------------------------------
# Models
# ---------------------------------------------------------------------------
class ExportRequest(BaseModel):
"""Generate a PDF from a Scribus template."""
template: str = Field(..., description="Template slug (directory name under /templates)")
output_format: str = Field("pdf", description="Output format: pdf, png, svg")
variables: dict[str, str] = Field(default_factory=dict, description="Template variables to substitute")
dpi: int = Field(300, ge=72, le=600)
class BatchExportRequest(BaseModel):
"""Generate multiple PDFs from a template + data rows (ScribusGenerator pattern)."""
template: str
output_format: str = Field("pdf", description="pdf or png")
rows: list[dict[str, str]] = Field(..., description="List of variable dicts, one per output document")
dpi: int = Field(300, ge=72, le=600)
class RswagExportRequest(BaseModel):
"""Export an rSwag design to print-ready PDF with bleed/crop marks."""
design_slug: str
category: str = Field("stickers", description="rSwag category: stickers, shirts, prints")
paper_size: str = Field("A4", description="Paper size: A4, A3, Letter, custom")
add_bleed: bool = Field(True, description="Add 3mm bleed")
add_crop_marks: bool = Field(True, description="Add crop/trim marks")
class ConvertIdmlRequest(BaseModel):
"""Convert an IDML file (InDesign interchange) to SLA and/or PDF."""
output_sla: bool = Field(True, description="Save as Scribus .sla file")
output_pdf: bool = Field(True, description="Export to PDF")
dpi: int = Field(300, ge=72, le=600)
class TemplateInfo(BaseModel):
slug: str
name: str
description: str
category: str
variables: list[str]
preview_url: Optional[str] = None
created: Optional[str] = None
class JobStatus(BaseModel):
job_id: str
status: str # queued, processing, completed, failed
progress: int = 0
result_url: Optional[str] = None
error: Optional[str] = None
created_at: str
completed_at: Optional[str] = None
# ---------------------------------------------------------------------------
# Template management
# ---------------------------------------------------------------------------
def scan_templates() -> list[TemplateInfo]:
"""Scan templates directory for available Scribus templates."""
templates = []
for meta_path in sorted(TEMPLATES_DIR.glob("*/metadata.yaml")):
try:
with open(meta_path) as f:
meta = yaml.safe_load(f)
slug = meta_path.parent.name
preview = None
for ext in ("png", "jpg", "webp"):
pf = meta_path.parent / f"preview.{ext}"
if pf.exists():
preview = f"{BASE_URL}/templates/{slug}/preview"
break
templates.append(TemplateInfo(
slug=slug,
name=meta.get("name", slug),
description=meta.get("description", ""),
category=meta.get("category", "general"),
variables=meta.get("variables", []),
preview_url=preview,
created=meta.get("created"),
))
except Exception:
continue
return templates
def find_template_sla(slug: str) -> Path:
"""Find the .sla file for a template slug."""
tpl_dir = TEMPLATES_DIR / slug
if not tpl_dir.is_dir():
raise HTTPException(404, f"Template '{slug}' not found")
sla_files = list(tpl_dir.glob("*.sla"))
if not sla_files:
raise HTTPException(404, f"No .sla file found in template '{slug}'")
return sla_files[0]
# ---------------------------------------------------------------------------
# Scribus headless execution
# ---------------------------------------------------------------------------
async def run_scribus_script(script_path: str, args: list[str], timeout: int = 120) -> tuple[str, str, int]:
"""Run a Scribus Python script in headless mode via xvfb-run."""
cmd = [
"xvfb-run", "--auto-servernum", "--server-args=-screen 0 1920x1080x24",
SCRIBUS_PATH, "-g", "-ns", "-py", script_path, "--", *args,
]
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
except asyncio.TimeoutError:
proc.kill()
await proc.communicate()
return "", "Process timed out", -1
return stdout.decode(), stderr.decode(), proc.returncode
# ---------------------------------------------------------------------------
# Job processing
# ---------------------------------------------------------------------------
async def process_export_job(job_id: str, req: ExportRequest):
"""Process a single template export job."""
jobs[job_id]["status"] = "processing"
jobs[job_id]["progress"] = 10
try:
sla_path = find_template_sla(req.template)
timestamp = int(time.time())
output_filename = f"{req.template}_{timestamp}.{req.output_format}"
output_path = OUTPUT_DIR / output_filename
# Build script args
script_args = [
"--input", str(sla_path),
"--output", str(output_path),
"--format", req.output_format,
"--dpi", str(req.dpi),
]
# Pass variables as --var key=value
for k, v in req.variables.items():
script_args.extend(["--var", f"{k}={v}"])
jobs[job_id]["progress"] = 30
stdout, stderr, returncode = await run_scribus_script(
"/app/scripts/export_document.py", script_args
)
if returncode != 0 or not output_path.exists():
jobs[job_id]["status"] = "failed"
jobs[job_id]["error"] = stderr[:500] if stderr else "Export failed — no output produced"
return
jobs[job_id]["progress"] = 100
jobs[job_id]["status"] = "completed"
jobs[job_id]["result_url"] = f"{BASE_URL}/output/{output_filename}"
jobs[job_id]["completed_at"] = datetime.now(timezone.utc).isoformat()
except HTTPException as e:
jobs[job_id]["status"] = "failed"
jobs[job_id]["error"] = e.detail
except Exception as e:
jobs[job_id]["status"] = "failed"
jobs[job_id]["error"] = str(e)[:500]
async def process_batch_job(job_id: str, req: BatchExportRequest):
"""Process a batch export job (ScribusGenerator pattern)."""
jobs[job_id]["status"] = "processing"
total = len(req.rows)
results = []
try:
sla_path = find_template_sla(req.template)
for i, row in enumerate(req.rows):
jobs[job_id]["progress"] = int((i / total) * 100)
timestamp = int(time.time())
row_id = row.get("id", str(i))
output_filename = f"{req.template}_{row_id}_{timestamp}.{req.output_format}"
output_path = OUTPUT_DIR / output_filename
script_args = [
"--input", str(sla_path),
"--output", str(output_path),
"--format", req.output_format,
"--dpi", str(req.dpi),
]
for k, v in row.items():
script_args.extend(["--var", f"{k}={v}"])
stdout, stderr, returncode = await run_scribus_script(
"/app/scripts/export_document.py", script_args
)
if returncode == 0 and output_path.exists():
results.append({
"row_id": row_id,
"url": f"{BASE_URL}/output/{output_filename}",
"status": "ok",
})
else:
results.append({
"row_id": row_id,
"status": "failed",
"error": (stderr or "unknown error")[:200],
})
jobs[job_id]["progress"] = 100
jobs[job_id]["status"] = "completed"
jobs[job_id]["results"] = results
jobs[job_id]["completed_at"] = datetime.now(timezone.utc).isoformat()
except Exception as e:
jobs[job_id]["status"] = "failed"
jobs[job_id]["error"] = str(e)[:500]
async def process_rswag_export(job_id: str, req: RswagExportRequest):
"""Export an rSwag design to print-ready PDF."""
jobs[job_id]["status"] = "processing"
jobs[job_id]["progress"] = 10
try:
# Find the rSwag design
design_dir = RSWAG_DESIGNS_PATH / req.category / req.design_slug
if not design_dir.is_dir():
raise FileNotFoundError(f"rSwag design not found: {req.category}/{req.design_slug}")
# Find the design image
design_image = None
for candidate in [
design_dir / "exports" / "300dpi" / f"{req.design_slug}.png",
design_dir / f"{req.design_slug}.png",
]:
if candidate.exists():
design_image = candidate
break
# Fallback: find any PNG
if not design_image:
pngs = list(design_dir.glob("*.png"))
if pngs:
design_image = pngs[0]
if not design_image:
raise FileNotFoundError(f"No image found for design: {req.design_slug}")
# Load design metadata if available
meta_path = design_dir / "metadata.yaml"
meta = {}
if meta_path.exists():
with open(meta_path) as f:
meta = yaml.safe_load(f) or {}
jobs[job_id]["progress"] = 30
timestamp = int(time.time())
output_filename = f"rswag_{req.design_slug}_{timestamp}.pdf"
output_path = OUTPUT_DIR / output_filename
script_args = [
"--image", str(design_image),
"--output", str(output_path),
"--paper", req.paper_size,
"--dpi", str(300),
"--title", meta.get("name", req.design_slug),
]
if req.add_bleed:
script_args.append("--bleed")
if req.add_crop_marks:
script_args.append("--crop-marks")
stdout, stderr, returncode = await run_scribus_script(
"/app/scripts/rswag_export.py", script_args, timeout=180
)
if returncode != 0 or not output_path.exists():
jobs[job_id]["status"] = "failed"
jobs[job_id]["error"] = stderr[:500] if stderr else "rSwag export failed"
return
jobs[job_id]["progress"] = 100
jobs[job_id]["status"] = "completed"
jobs[job_id]["result_url"] = f"{BASE_URL}/output/{output_filename}"
jobs[job_id]["completed_at"] = datetime.now(timezone.utc).isoformat()
except Exception as e:
jobs[job_id]["status"] = "failed"
jobs[job_id]["error"] = str(e)[:500]
async def process_idml_convert(job_id: str, idml_path: Path, req: ConvertIdmlRequest):
"""Convert an IDML file to SLA and/or PDF."""
jobs[job_id]["status"] = "processing"
jobs[job_id]["progress"] = 10
try:
timestamp = int(time.time())
stem = idml_path.stem
results = {}
script_args = ["--input", str(idml_path)]
if req.output_sla:
sla_filename = f"{stem}_{timestamp}.sla"
sla_path = OUTPUT_DIR / sla_filename
script_args.extend(["--output-sla", str(sla_path)])
if req.output_pdf:
pdf_filename = f"{stem}_{timestamp}.pdf"
pdf_path = OUTPUT_DIR / pdf_filename
script_args.extend(["--output-pdf", str(pdf_path)])
script_args.extend(["--dpi", str(req.dpi)])
jobs[job_id]["progress"] = 30
stdout, stderr, returncode = await run_scribus_script(
"/app/scripts/convert_idml.py", script_args, timeout=300
)
if returncode != 0:
jobs[job_id]["status"] = "failed"
jobs[job_id]["error"] = stderr[:500] if stderr else "IDML conversion failed"
return
if req.output_sla and sla_path.exists():
results["sla_url"] = f"{BASE_URL}/output/{sla_filename}"
if req.output_pdf and pdf_path.exists():
results["pdf_url"] = f"{BASE_URL}/output/{pdf_filename}"
# Save as template too (SLA goes into templates dir)
if req.output_sla and sla_path.exists():
tpl_dir = TEMPLATES_DIR / stem
tpl_dir.mkdir(parents=True, exist_ok=True)
import shutil
shutil.copy2(sla_path, tpl_dir / f"{stem}.sla")
meta = {
"name": stem.replace("-", " ").replace("_", " ").title(),
"description": f"Imported from IDML: {idml_path.name}",
"category": "imported",
"variables": [],
"created": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
"source": {"format": "idml", "original": idml_path.name},
}
with open(tpl_dir / "metadata.yaml", "w") as f:
yaml.dump(meta, f, default_flow_style=False)
results["template_slug"] = stem
jobs[job_id]["progress"] = 100
jobs[job_id]["status"] = "completed"
jobs[job_id]["results"] = results
jobs[job_id]["completed_at"] = datetime.now(timezone.utc).isoformat()
except Exception as e:
jobs[job_id]["status"] = "failed"
jobs[job_id]["error"] = str(e)[:500]
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.get("/", tags=["health"])
async def root():
return {"service": "rDesign", "version": "0.1.0", "status": "ok"}
@app.get("/health", tags=["health"])
async def health():
scribus_exists = os.path.isfile(SCRIBUS_PATH)
return {
"status": "ok" if scribus_exists else "degraded",
"scribus": "available" if scribus_exists else "missing",
"templates": len(scan_templates()),
}
# --- Templates ---
@app.get("/templates", response_model=list[TemplateInfo], tags=["templates"])
async def list_templates(category: Optional[str] = None):
"""List available Scribus templates."""
templates = scan_templates()
if category:
templates = [t for t in templates if t.category == category]
return templates
@app.get("/templates/{slug}", response_model=TemplateInfo, tags=["templates"])
async def get_template(slug: str):
"""Get template metadata."""
for t in scan_templates():
if t.slug == slug:
return t
raise HTTPException(404, f"Template '{slug}' not found")
@app.get("/templates/{slug}/preview", tags=["templates"])
async def get_template_preview(slug: str):
"""Get template preview image."""
tpl_dir = TEMPLATES_DIR / slug
if not tpl_dir.is_dir():
raise HTTPException(404, "Template not found")
for ext in ("png", "jpg", "webp"):
preview = tpl_dir / f"preview.{ext}"
if preview.exists():
return FileResponse(preview)
raise HTTPException(404, "No preview available")
@app.post("/templates/upload", tags=["templates"])
async def upload_template(
file: UploadFile = File(...),
name: str = Query(...),
description: str = Query(""),
category: str = Query("general"),
):
"""Upload a new Scribus template (.sla file)."""
if not file.filename or not file.filename.endswith(".sla"):
raise HTTPException(400, "File must be a .sla Scribus document")
slug = name.lower().replace(" ", "-").replace("_", "-")
tpl_dir = TEMPLATES_DIR / slug
tpl_dir.mkdir(parents=True, exist_ok=True)
sla_path = tpl_dir / file.filename
async with aiofiles.open(sla_path, "wb") as f:
content = await file.read()
await f.write(content)
# Create metadata
meta = {
"name": name,
"description": description,
"category": category,
"variables": [],
"created": datetime.now(timezone.utc).strftime("%Y-%m-%d"),
}
meta_path = tpl_dir / "metadata.yaml"
with open(meta_path, "w") as f:
yaml.dump(meta, f, default_flow_style=False)
return {"slug": slug, "message": f"Template '{name}' uploaded", "path": str(sla_path)}
# --- IDML Import / Convert ---
@app.post("/convert/idml", tags=["convert"])
async def convert_idml(
file: UploadFile = File(...),
output_sla: bool = Query(True, description="Save as Scribus .sla"),
output_pdf: bool = Query(True, description="Export to PDF"),
dpi: int = Query(300, ge=72, le=600),
background_tasks: BackgroundTasks = None,
):
"""Upload an IDML file (InDesign interchange format) and convert to SLA/PDF.
IDML is Adobe's open interchange format — export from InDesign via
File Save As InDesign Markup (IDML). Scribus imports IDML natively.
Note: Native .indd files cannot be converted directly. Use IDML instead.
"""
if not file.filename or not file.filename.lower().endswith((".idml", ".idms")):
raise HTTPException(400, "File must be .idml or .idms (InDesign Markup). "
"Native .indd files are not supported — export as IDML from InDesign first.")
# Save uploaded file
upload_dir = JOBS_DIR / "uploads"
upload_dir.mkdir(parents=True, exist_ok=True)
idml_path = upload_dir / f"{uuid.uuid4()}_{file.filename}"
async with aiofiles.open(idml_path, "wb") as f:
content = await file.read()
await f.write(content)
req = ConvertIdmlRequest(output_sla=output_sla, output_pdf=output_pdf, dpi=dpi)
job_id = str(uuid.uuid4())
jobs[job_id] = {
"job_id": job_id,
"status": "queued",
"progress": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
"source": file.filename,
"type": "idml_convert",
}
background_tasks.add_task(process_idml_convert, job_id, idml_path, req)
return {
"job_id": job_id,
"status": "queued",
"poll_url": f"{BASE_URL}/jobs/{job_id}",
"note": "IDML import may take a few minutes for complex documents.",
}
# --- Export (single document) ---
@app.post("/export", tags=["export"])
async def export_document(req: ExportRequest, background_tasks: BackgroundTasks):
"""Export a Scribus template to PDF/PNG (async job)."""
job_id = str(uuid.uuid4())
jobs[job_id] = {
"job_id": job_id,
"status": "queued",
"progress": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
"template": req.template,
}
background_tasks.add_task(process_export_job, job_id, req)
return {"job_id": job_id, "status": "queued", "poll_url": f"{BASE_URL}/jobs/{job_id}"}
@app.post("/export/sync", tags=["export"])
async def export_document_sync(req: ExportRequest):
"""Export a Scribus template synchronously (blocks until done)."""
job_id = str(uuid.uuid4())
jobs[job_id] = {
"job_id": job_id,
"status": "queued",
"progress": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
"template": req.template,
}
await process_export_job(job_id, req)
return jobs[job_id]
# --- Batch export ---
@app.post("/export/batch", tags=["export"])
async def export_batch(req: BatchExportRequest, background_tasks: BackgroundTasks):
"""Batch export: one template + multiple data rows = multiple PDFs."""
job_id = str(uuid.uuid4())
jobs[job_id] = {
"job_id": job_id,
"status": "queued",
"progress": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
"template": req.template,
"total_rows": len(req.rows),
}
background_tasks.add_task(process_batch_job, job_id, req)
return {"job_id": job_id, "status": "queued", "total_rows": len(req.rows), "poll_url": f"{BASE_URL}/jobs/{job_id}"}
# --- rSwag integration ---
@app.get("/rswag/designs", tags=["rswag"])
async def list_rswag_designs(category: Optional[str] = None):
"""List available rSwag designs that can be exported to print-ready PDFs."""
designs = []
search_dirs = [RSWAG_DESIGNS_PATH]
if category:
search_dirs = [RSWAG_DESIGNS_PATH / category]
for search_dir in search_dirs:
if not search_dir.is_dir():
continue
for cat_dir in sorted(search_dir.iterdir()):
if not cat_dir.is_dir():
continue
# If filtering by category, cat_dir IS the design dir
design_dirs = [cat_dir] if category else sorted(cat_dir.iterdir())
for ddir in design_dirs:
if not ddir.is_dir():
continue
meta_path = ddir / "metadata.yaml"
if not meta_path.exists():
continue
try:
with open(meta_path) as f:
meta = yaml.safe_load(f)
designs.append({
"slug": ddir.name,
"name": meta.get("name", ddir.name),
"category": cat_dir.name if not category else category,
"description": meta.get("description", ""),
"status": meta.get("status", "unknown"),
})
except Exception:
continue
return designs
@app.post("/rswag/export", tags=["rswag"])
async def export_rswag_design(req: RswagExportRequest, background_tasks: BackgroundTasks):
"""Export an rSwag design to a print-ready PDF with bleed and crop marks."""
job_id = str(uuid.uuid4())
jobs[job_id] = {
"job_id": job_id,
"status": "queued",
"progress": 0,
"created_at": datetime.now(timezone.utc).isoformat(),
"design": req.design_slug,
}
background_tasks.add_task(process_rswag_export, job_id, req)
return {"job_id": job_id, "status": "queued", "poll_url": f"{BASE_URL}/jobs/{job_id}"}
# --- Jobs ---
@app.get("/jobs/{job_id}", tags=["jobs"])
async def get_job(job_id: str):
"""Get job status."""
if job_id not in jobs:
raise HTTPException(404, "Job not found")
return jobs[job_id]
@app.get("/jobs", tags=["jobs"])
async def list_jobs(limit: int = Query(20, le=100)):
"""List recent jobs."""
sorted_jobs = sorted(jobs.values(), key=lambda j: j["created_at"], reverse=True)
return sorted_jobs[:limit]
@app.delete("/jobs/{job_id}", tags=["jobs"])
async def delete_job(job_id: str):
"""Delete a job and its output."""
if job_id not in jobs:
raise HTTPException(404, "Job not found")
job = jobs.pop(job_id)
if "result_url" in job:
filename = job["result_url"].split("/")[-1]
output_file = OUTPUT_DIR / filename
if output_file.exists():
output_file.unlink()
return {"message": "Job deleted"}
# --- Output files ---
@app.get("/output/{filename}", tags=["output"])
async def get_output(filename: str):
"""Serve an output file."""
file_path = OUTPUT_DIR / filename
# Path traversal protection
if not file_path.resolve().is_relative_to(OUTPUT_DIR.resolve()):
raise HTTPException(403, "Access denied")
if not file_path.exists():
raise HTTPException(404, "File not found")
return FileResponse(
file_path,
headers={"Cache-Control": "public, max-age=86400"},
)

27
studio/openbox-rc.xml Normal file
View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<openbox_config xmlns="http://openbox.org/3.4/rc">
<resistance>
<strength>10</strength>
<screen_edge_strength>20</screen_edge_strength>
</resistance>
<focus>
<focusNew>yes</focusNew>
<followMouse>no</followMouse>
</focus>
<theme>
<name>Clearlooks</name>
<titleLayout>NLIMC</titleLayout>
<font place="ActiveWindow"><name>DejaVu Sans</name><size>10</size></font>
<font place="InactiveWindow"><name>DejaVu Sans</name><size>10</size></font>
</theme>
<desktops>
<number>1</number>
<names><name>rDesign Studio</name></names>
</desktops>
<applications>
<application name="scribus">
<maximized>yes</maximized>
<decor>yes</decor>
</application>
</applications>
</openbox_config>

28
studio/start.sh Normal file
View File

@ -0,0 +1,28 @@
#!/bin/bash
set -e
VNC_PASSWORD="${VNC_PASSWORD:-changeme}"
DISPLAY_WIDTH="${DISPLAY_WIDTH:-1920}"
DISPLAY_HEIGHT="${DISPLAY_HEIGHT:-1080}"
# Set VNC password
mkdir -p /home/designer/.vnc
echo "$VNC_PASSWORD" | vncpasswd -f > /home/designer/.vnc/passwd
chmod 600 /home/designer/.vnc/passwd
chown -R designer:designer /home/designer/.vnc
# Start VNC server
su - designer -c "vncserver :1 \
-geometry ${DISPLAY_WIDTH}x${DISPLAY_HEIGHT} \
-depth 24 \
-SecurityTypes VncAuth \
-localhost no \
-xstartup /usr/bin/openbox-session" &
sleep 2
# Start noVNC (websockify bridge)
/usr/share/novnc/utils/novnc_proxy \
--vnc localhost:5901 \
--listen 6080 \
--web /usr/share/novnc

View File

@ -0,0 +1,10 @@
name: Example Flyer
description: A simple A4 flyer template with placeholder text and image. Good starting point for event flyers, announcements, or product sheets.
category: flyer
variables:
- title
- subtitle
- body
- date
- location
created: "2026-03-24"