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