From a686a4a14261a939089d8ee69c16c81ee8a4d739 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Tue, 24 Mar 2026 01:38:36 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20rDesign=20service=20=E2=80=94=20Scrib?= =?UTF-8?q?us-based=20document=20design=20&=20PDF=20generation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- .env.example | 9 + .gitignore | 7 + Dockerfile | 41 ++ Dockerfile.studio | 42 ++ docker-compose.yml | 76 ++++ requirements.txt | 8 + scripts/convert_idml.py | 99 +++++ scripts/export_document.py | 115 +++++ scripts/rswag_export.py | 180 ++++++++ server/__init__.py | 0 server/app.py | 716 +++++++++++++++++++++++++++++++ studio/openbox-rc.xml | 27 ++ studio/start.sh | 28 ++ templates/examples/metadata.yaml | 10 + 14 files changed, 1358 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Dockerfile.studio create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 scripts/convert_idml.py create mode 100644 scripts/export_document.py create mode 100644 scripts/rswag_export.py create mode 100644 server/__init__.py create mode 100644 server/app.py create mode 100644 studio/openbox-rc.xml create mode 100644 studio/start.sh create mode 100644 templates/examples/metadata.yaml diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..7e71bdc2 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..bd02a28f --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +output/ +jobs/ +__pycache__/ +*.pyc +.env +*.egg-info/ +.venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..5654590d --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/Dockerfile.studio b/Dockerfile.studio new file mode 100644 index 00000000..292dd330 --- /dev/null +++ b/Dockerfile.studio @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..6e1804d1 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..a162f285 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/convert_idml.py b/scripts/convert_idml.py new file mode 100644 index 00000000..efa4506d --- /dev/null +++ b/scripts/convert_idml.py @@ -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() diff --git a/scripts/export_document.py b/scripts/export_document.py new file mode 100644 index 00000000..5a92b1a2 --- /dev/null +++ b/scripts/export_document.py @@ -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() diff --git a/scripts/rswag_export.py b/scripts/rswag_export.py new file mode 100644 index 00000000..72759690 --- /dev/null +++ b/scripts/rswag_export.py @@ -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() diff --git a/server/__init__.py b/server/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/app.py b/server/app.py new file mode 100644 index 00000000..603d02f8 --- /dev/null +++ b/server/app.py @@ -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"}, + ) diff --git a/studio/openbox-rc.xml b/studio/openbox-rc.xml new file mode 100644 index 00000000..23aec071 --- /dev/null +++ b/studio/openbox-rc.xml @@ -0,0 +1,27 @@ + + + + 10 + 20 + + + yes + no + + + Clearlooks + NLIMC + DejaVu Sans10 + DejaVu Sans10 + + + 1 + rDesign Studio + + + + yes + yes + + + diff --git a/studio/start.sh b/studio/start.sh new file mode 100644 index 00000000..a4038e9a --- /dev/null +++ b/studio/start.sh @@ -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 diff --git a/templates/examples/metadata.yaml b/templates/examples/metadata.yaml new file mode 100644 index 00000000..9558fe78 --- /dev/null +++ b/templates/examples/metadata.yaml @@ -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"