feat: add Python bridge and optional FastAPI server

- Add bridge.py for clean JSON-based Python-Node communication
- Add optional FastAPI server for REST API access
- Include requirements for API server setup

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-09-09 15:45:52 -05:00
parent fb1b0470cf
commit 98263a79a0
3 changed files with 305 additions and 0 deletions

8
requirements-api.txt Normal file
View File

@ -0,0 +1,8 @@
# API server requirements
fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
python-multipart==0.0.6
# Include base requirements
-r requirements.txt

160
src/api.py Normal file
View File

@ -0,0 +1,160 @@
#!/usr/bin/env python3
from fastapi import FastAPI, HTTPException, Response
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from typing import Optional, List, Literal
import tempfile
import subprocess
from pathlib import Path
import base64
import io
from generate import parse_range, generate_typst_file
app = FastAPI(title="Soroban Flashcard Generator API")
# Enable CORS for web apps
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Configure this for your domain in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
class FlashcardRequest(BaseModel):
"""Request model for generating flashcards"""
range: str = Field("0-9", description="Number range (e.g., '0-99') or list (e.g., '1,2,5,10')")
step: int = Field(1, description="Step/increment for ranges")
cards_per_page: int = Field(6, description="Cards per page")
paper_size: str = Field("us-letter", description="Paper size")
orientation: Literal["portrait", "landscape"] = Field("portrait")
margins: dict = Field(default_factory=lambda: {
"top": "0.5in",
"bottom": "0.5in",
"left": "0.5in",
"right": "0.5in"
})
gutter: str = Field("5mm", description="Space between cards")
shuffle: bool = False
seed: Optional[int] = None
show_cut_marks: bool = False
show_registration: bool = False
font_family: str = Field("DejaVu Sans")
font_size: str = Field("48pt")
columns: str = Field("auto", description="Number of soroban columns")
show_empty_columns: bool = False
hide_inactive_beads: bool = False
bead_shape: Literal["diamond", "circle", "square"] = Field("diamond")
color_scheme: Literal["monochrome", "place-value", "heaven-earth", "alternating"] = Field("monochrome")
colored_numerals: bool = False
scale_factor: float = Field(0.9, ge=0.1, le=1.0)
format: Literal["pdf", "base64"] = Field("base64", description="Return format")
@app.post("/generate")
async def generate_flashcards(request: FlashcardRequest):
"""Generate flashcards and return as PDF bytes or base64"""
try:
# Parse numbers
numbers = parse_range(request.range, request.step)
if request.shuffle:
import random
if request.seed is not None:
random.seed(request.seed)
random.shuffle(numbers)
# Build config
config = {
'cards_per_page': request.cards_per_page,
'paper_size': request.paper_size,
'orientation': request.orientation,
'margins': request.margins,
'gutter': request.gutter,
'show_cut_marks': request.show_cut_marks,
'show_registration': request.show_registration,
'font_family': request.font_family,
'font_size': request.font_size,
'columns': request.columns,
'show_empty_columns': request.show_empty_columns,
'hide_inactive_beads': request.hide_inactive_beads,
'bead_shape': request.bead_shape,
'color_scheme': request.color_scheme,
'colored_numerals': request.colored_numerals,
'scale_factor': request.scale_factor,
}
# Create temp files
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
temp_typst = tmpdir_path / "flashcards.typ"
temp_pdf = tmpdir_path / "flashcards.pdf"
# Generate Typst file
generate_typst_file(numbers, config, temp_typst)
# Find project root and copy template
project_root = Path(__file__).parent.parent
templates_dir = project_root / "templates"
# Copy templates to temp dir for imports to work
import shutil
temp_templates = tmpdir_path / "templates"
shutil.copytree(templates_dir, temp_templates)
# Compile with Typst
result = subprocess.run(
["typst", "compile", str(temp_typst), str(temp_pdf)],
capture_output=True,
text=True,
cwd=str(tmpdir_path)
)
if result.returncode != 0:
raise HTTPException(status_code=500, detail=f"Typst compilation failed: {result.stderr}")
# Read PDF
with open(temp_pdf, "rb") as f:
pdf_bytes = f.read()
# Return based on format
if request.format == "pdf":
return Response(
content=pdf_bytes,
media_type="application/pdf",
headers={
"Content-Disposition": "attachment; filename=flashcards.pdf"
}
)
else: # base64
return {
"pdf": base64.b64encode(pdf_bytes).decode('utf-8'),
"count": len(numbers),
"numbers": numbers[:100] # Limit preview
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.get("/health")
async def health_check():
"""Health check endpoint"""
return {"status": "healthy"}
@app.get("/")
async def root():
"""API documentation"""
return {
"name": "Soroban Flashcard Generator API",
"endpoints": {
"/generate": "POST - Generate flashcards",
"/health": "GET - Health check",
"/docs": "GET - Interactive API documentation"
}
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

137
src/bridge.py Normal file
View File

@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""
Python bridge for Node.js integration
Provides a clean function interface instead of CLI
"""
import json
import sys
import base64
import tempfile
from pathlib import Path
import subprocess
# Import our existing functions
from generate import parse_range, generate_typst_file
def generate_flashcards_json(config_json):
"""
Generate flashcards from JSON config
Returns base64 encoded PDF
"""
config = json.loads(config_json)
# Parse numbers
numbers = parse_range(
config.get('range', '0-9'),
config.get('step', 1)
)
# Handle shuffle
if config.get('shuffle', False):
import random
if 'seed' in config:
random.seed(config['seed'])
random.shuffle(numbers)
# Build Typst config
typst_config = {
'cards_per_page': config.get('cardsPerPage', 6),
'paper_size': config.get('paperSize', 'us-letter'),
'orientation': config.get('orientation', 'portrait'),
'margins': config.get('margins', {
'top': '0.5in',
'bottom': '0.5in',
'left': '0.5in',
'right': '0.5in'
}),
'gutter': config.get('gutter', '5mm'),
'show_cut_marks': config.get('showCutMarks', False),
'show_registration': config.get('showRegistration', False),
'font_family': config.get('fontFamily', 'DejaVu Sans'),
'font_size': config.get('fontSize', '48pt'),
'columns': config.get('columns', 'auto'),
'show_empty_columns': config.get('showEmptyColumns', False),
'hide_inactive_beads': config.get('hideInactiveBeads', False),
'bead_shape': config.get('beadShape', 'diamond'),
'color_scheme': config.get('colorScheme', 'monochrome'),
'colored_numerals': config.get('coloredNumerals', False),
'scale_factor': config.get('scaleFactor', 0.9),
}
# Generate PDF in temp directory
with tempfile.TemporaryDirectory() as tmpdir:
tmpdir_path = Path(tmpdir)
temp_typst = tmpdir_path / 'flashcards.typ'
temp_pdf = tmpdir_path / 'flashcards.pdf'
# Generate Typst file
project_root = Path(__file__).parent.parent
# Create temp Typst with correct imports
typst_content = f'''
#import "{project_root}/templates/flashcards.typ": generate-flashcards
#generate-flashcards(
{numbers},
cards-per-page: {typst_config['cards_per_page']},
paper-size: "{typst_config['paper_size']}",
orientation: "{typst_config['orientation']}",
margins: (
top: {typst_config['margins'].get('top', '0.5in')},
bottom: {typst_config['margins'].get('bottom', '0.5in')},
left: {typst_config['margins'].get('left', '0.5in')},
right: {typst_config['margins'].get('right', '0.5in')}
),
gutter: {typst_config['gutter']},
show-cut-marks: {str(typst_config['show_cut_marks']).lower()},
show-registration: {str(typst_config['show_registration']).lower()},
font-family: "{typst_config['font_family']}",
font-size: {typst_config['font_size']},
columns: {typst_config['columns']},
show-empty-columns: {str(typst_config['show_empty_columns']).lower()},
hide-inactive-beads: {str(typst_config['hide_inactive_beads']).lower()},
bead-shape: "{typst_config['bead_shape']}",
color-scheme: "{typst_config['color_scheme']}",
colored-numerals: {str(typst_config['colored_numerals']).lower()},
scale-factor: {typst_config['scale_factor']}
)
'''
with open(temp_typst, 'w') as f:
f.write(typst_content)
# Compile with Typst
result = subprocess.run(
['typst', 'compile', str(temp_typst), str(temp_pdf)],
capture_output=True,
text=True,
cwd=str(project_root)
)
if result.returncode != 0:
return json.dumps({
'error': f'Typst compilation failed: {result.stderr}'
})
# Read and encode PDF
with open(temp_pdf, 'rb') as f:
pdf_bytes = f.read()
return json.dumps({
'pdf': base64.b64encode(pdf_bytes).decode('utf-8'),
'count': len(numbers),
'numbers': numbers[:100] # Limit preview
})
if __name__ == '__main__':
# Read JSON from stdin, write JSON to stdout
# This allows clean function-like communication
for line in sys.stdin:
try:
result = generate_flashcards_json(line.strip())
print(result)
sys.stdout.flush()
except Exception as e:
print(json.dumps({'error': str(e)}))
sys.stdout.flush()