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:
parent
fb1b0470cf
commit
98263a79a0
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue