diff --git a/client/node/API.md b/client/node/API.md new file mode 100644 index 00000000..3708e869 --- /dev/null +++ b/client/node/API.md @@ -0,0 +1,259 @@ +# Soroban Flashcard Generator - Node.js API Documentation + +## Installation + +```bash +npm install python-shell +``` + +## Basic Usage + +```typescript +import { SorobanGenerator } from './soroban-generator-bridge'; + +const generator = new SorobanGenerator(); +const result = await generator.generate({ + range: '0-99', + cardsPerPage: 6 +}); +``` + +## API Reference + +### `SorobanGenerator` + +The main class for generating flashcards from Node.js/TypeScript. + +#### Constructor + +```typescript +new SorobanGenerator(projectRoot?: string) +``` + +- `projectRoot` (optional): Path to the soroban-abacus-flashcards directory. Defaults to `../../` from the module location. + +#### Methods + +##### `generate(config: FlashcardConfig): Promise` + +Generate flashcards with the specified configuration. + +**Parameters:** +- `config`: Configuration object (see FlashcardConfig below) + +**Returns:** Promise resolving to: +```typescript +{ + pdf: string; // Base64 encoded PDF + count: number; // Number of flashcards generated + numbers: number[]; // Array of numbers (limited to first 100) +} +``` + +##### `generateBuffer(config: FlashcardConfig): Promise` + +Generate flashcards and return as Node.js Buffer. + +**Parameters:** +- `config`: Configuration object + +**Returns:** Promise resolving to Buffer containing PDF data + +##### `initialize(): Promise` + +Initialize a persistent Python process for better performance when generating multiple PDFs. + +##### `close(): Promise` + +Clean up the persistent Python process. + +### Configuration Interface + +```typescript +interface FlashcardConfig { + // Required + range: string; // e.g., "0-99" or "1,2,5,10" + + // Optional + step?: number; // Increment (default: 1) + cardsPerPage?: number; // 1-30+ (default: 6) + paperSize?: 'us-letter' | 'a4' | 'a3' | 'a5'; + orientation?: 'portrait' | 'landscape'; + margins?: { + top?: string; // e.g., "0.5in" + bottom?: string; + left?: string; + right?: string; + }; + gutter?: string; // Space between cards (default: "5mm") + shuffle?: boolean; // Randomize order + seed?: number; // Random seed for deterministic shuffle + showCutMarks?: boolean; // Show cutting guides + showRegistration?: boolean; // Show alignment marks + fontFamily?: string; // Font name (default: "DejaVu Sans") + fontSize?: string; // Font size (default: "48pt") + columns?: string | number; // "auto" or specific number + showEmptyColumns?: boolean; + hideInactiveBeads?: boolean; + beadShape?: 'diamond' | 'circle' | 'square'; + colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'; + coloredNumerals?: boolean; // Color numerals to match beads + scaleFactor?: number; // 0.1 to 1.0 (default: 0.9) +} +``` + +## Examples + +### Basic Generation + +```typescript +const generator = new SorobanGenerator(); + +// Simple 0-9 flashcards +const result = await generator.generate({ + range: '0-9' +}); +``` + +### Skip Counting + +```typescript +// Count by 5s from 0 to 100 +const result = await generator.generate({ + range: '0-100', + step: 5, + cardsPerPage: 12 +}); +``` + +### Educational Colors + +```typescript +// Place-value coloring for learning +const result = await generator.generate({ + range: '0-999', + colorScheme: 'place-value', + coloredNumerals: true, + showCutMarks: true +}); +``` + +### Express.js Route + +```typescript +app.post('/api/flashcards', async (req, res) => { + try { + const generator = new SorobanGenerator(); + const config = { + range: req.body.range || '0-9', + cardsPerPage: req.body.cardsPerPage || 6, + colorScheme: req.body.colorScheme || 'monochrome', + ...req.body + }; + + const result = await generator.generate(config); + + if (req.query.format === 'json') { + // Return metadata + res.json({ + count: result.count, + numbers: result.numbers + }); + } else { + // Return PDF + const pdfBuffer = Buffer.from(result.pdf, 'base64'); + res.contentType('application/pdf'); + res.setHeader('Content-Disposition', 'attachment; filename=flashcards.pdf'); + res.send(pdfBuffer); + } + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); +``` + +### Next.js API Route + +```typescript +// pages/api/flashcards.ts +import { NextApiRequest, NextApiResponse } from 'next'; +import { SorobanGenerator } from '@/lib/soroban-generator-bridge'; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + return res.status(405).json({ error: 'Method not allowed' }); + } + + try { + const generator = new SorobanGenerator(); + const result = await generator.generate(req.body); + const pdfBuffer = Buffer.from(result.pdf, 'base64'); + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', 'attachment; filename=flashcards.pdf'); + res.send(pdfBuffer); + } catch (error) { + res.status(500).json({ error: error.message }); + } +} +``` + +### Performance Optimization + +For generating multiple PDFs, use persistent mode: + +```typescript +const generator = new SorobanGenerator(); + +// Initialize once +await generator.initialize(); + +// Generate multiple PDFs quickly +for (const config of configs) { + const result = await generator.generate(config); + // Process result... +} + +// Clean up when done +await generator.close(); +``` + +## Requirements + +- Node.js 14+ +- Python 3.8+ +- Typst (installed via `brew install typst`) +- qpdf (optional, for PDF optimization) + +## Error Handling + +The generator will throw errors for: +- Missing Python installation +- Missing Typst installation +- Invalid configuration +- Typst compilation errors + +Always wrap calls in try/catch blocks: + +```typescript +try { + const result = await generator.generate(config); +} catch (error) { + console.error('Generation failed:', error.message); +} +``` + +## TypeScript Types + +All interfaces and types are included in the module. Import them as needed: + +```typescript +import { + SorobanGenerator, + FlashcardConfig, + FlashcardResult +} from './soroban-generator-bridge'; +``` \ No newline at end of file diff --git a/client/node/example-server.ts b/client/node/example-server.ts new file mode 100644 index 00000000..490b4a1e --- /dev/null +++ b/client/node/example-server.ts @@ -0,0 +1,98 @@ +/** + * Example TypeScript server using the Soroban generator + * This shows how to call the PDF generation directly from TypeScript + */ + +import express from 'express'; +import { SorobanGenerator } from './soroban-generator'; + +const app = express(); +app.use(express.json()); + +// Initialize the generator once +const generator = new SorobanGenerator(); + +// Direct usage - just call the function from TypeScript +app.post('/api/generate-pdf', async (req, res) => { + try { + // Call the generator directly from TypeScript + const pdfBuffer = await generator.generate({ + range: req.body.range || '0-9', + cardsPerPage: req.body.cardsPerPage || 6, + colorScheme: req.body.colorScheme || 'monochrome', + showCutMarks: req.body.showCutMarks || false, + // ... other config options + }); + + // Send PDF directly to client + res.contentType('application/pdf'); + res.setHeader('Content-Disposition', 'attachment; filename=flashcards.pdf'); + res.send(pdfBuffer); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Example: Generate specific sets programmatically +app.get('/api/presets/:preset', async (req, res) => { + try { + let config; + + switch (req.params.preset) { + case 'basic': + config = { range: '0-9' }; + break; + case 'counting-by-5': + config = { range: '0-100', step: 5 }; + break; + case 'place-value': + config = { + range: '0-999', + colorScheme: 'place-value' as const, + coloredNumerals: true + }; + break; + default: + return res.status(404).json({ error: 'Unknown preset' }); + } + + // Direct function call from TypeScript + const pdfBuffer = await generator.generate(config); + + res.contentType('application/pdf'); + res.send(pdfBuffer); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +// Simple usage in any async function +async function generateFlashcardsDirectly() { + const generator = new SorobanGenerator(); + + // Just call it like any TypeScript function + const pdfBuffer = await generator.generate({ + range: '0-99', + cardsPerPage: 6, + colorScheme: 'place-value', + coloredNumerals: true + }); + + // Now you have the PDF as a Buffer, use it however you want + return pdfBuffer; +} + +// Next.js API route example +export async function nextJsApiRoute(req: any, res: any) { + const generator = new SorobanGenerator(); + + // Direct call from TypeScript + const pdf = await generator.generate(req.body); + + res.setHeader('Content-Type', 'application/pdf'); + res.send(pdf); +} + +app.listen(3000, () => { + console.log('Server running on http://localhost:3000'); +}); \ No newline at end of file diff --git a/client/node/package.json b/client/node/package.json new file mode 100644 index 00000000..2fb61674 --- /dev/null +++ b/client/node/package.json @@ -0,0 +1,21 @@ +{ + "name": "soroban-flashcards-node", + "version": "1.0.0", + "description": "Node.js TypeScript interface for Soroban Flashcard Generator", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc", + "dev": "ts-node-dev --respawn example-server.ts" + }, + "dependencies": { + "python-shell": "^5.0.0", + "express": "^4.18.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/express": "^4.17.0", + "typescript": "^5.0.0", + "ts-node-dev": "^2.0.0" + } +} \ No newline at end of file diff --git a/client/node/soroban-generator-bridge.ts b/client/node/soroban-generator-bridge.ts new file mode 100644 index 00000000..e46d9736 --- /dev/null +++ b/client/node/soroban-generator-bridge.ts @@ -0,0 +1,207 @@ +/** + * TypeScript wrapper using python-shell for clean function interface + * No CLI arguments - just function calls with objects + */ + +import { PythonShell } from 'python-shell'; +import * as path from 'path'; + +export interface FlashcardConfig { + range: string; + step?: number; + cardsPerPage?: number; + paperSize?: 'us-letter' | 'a4' | 'a3' | 'a5'; + orientation?: 'portrait' | 'landscape'; + margins?: { + top?: string; + bottom?: string; + left?: string; + right?: string; + }; + gutter?: string; + shuffle?: boolean; + seed?: number; + showCutMarks?: boolean; + showRegistration?: boolean; + fontFamily?: string; + fontSize?: string; + columns?: string | number; + showEmptyColumns?: boolean; + hideInactiveBeads?: boolean; + beadShape?: 'diamond' | 'circle' | 'square'; + colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'; + coloredNumerals?: boolean; + scaleFactor?: number; +} + +export interface FlashcardResult { + pdf: string; // base64 encoded + count: number; + numbers: number[]; +} + +export class SorobanGenerator { + private pythonShell: PythonShell | null = null; + private projectRoot: string; + + constructor(projectRoot?: string) { + this.projectRoot = projectRoot || path.join(__dirname, '../../'); + } + + /** + * Initialize persistent Python process for better performance + */ + async initialize(): Promise { + if (this.pythonShell) return; + + this.pythonShell = new PythonShell( + path.join(this.projectRoot, 'src', 'bridge.py'), + { + mode: 'json', + pythonPath: 'python3', + pythonOptions: ['-u'], // Unbuffered + scriptPath: this.projectRoot, + } + ); + } + + /** + * Generate flashcards - clean function interface + */ + async generate(config: FlashcardConfig): Promise { + // One-shot mode if not initialized + if (!this.pythonShell) { + return new Promise((resolve, reject) => { + PythonShell.run( + path.join(this.projectRoot, 'src', 'bridge.py'), + { + mode: 'json', + pythonPath: 'python3', + scriptPath: this.projectRoot, + args: [], + }, + (err, results) => { + if (err) { + reject(err); + } else if (results && results[0]) { + const result = results[0] as any; + if (result.error) { + reject(new Error(result.error)); + } else { + resolve(result as FlashcardResult); + } + } else { + reject(new Error('No result from Python')); + } + } + ); + + // Send config as JSON + PythonShell.defaultOptions = {}; + const shell = new PythonShell( + path.join(this.projectRoot, 'src', 'bridge.py'), + { + mode: 'json', + pythonPath: 'python3', + scriptPath: this.projectRoot, + } + ); + + shell.send(config); + shell.end((err, code, signal) => { + if (err) console.error(err); + }); + }); + } + + // Persistent mode + return new Promise((resolve, reject) => { + if (!this.pythonShell) { + reject(new Error('Not initialized')); + return; + } + + const handler = (message: any) => { + if (message.error) { + reject(new Error(message.error)); + } else { + resolve(message as FlashcardResult); + } + this.pythonShell?.removeListener('message', handler); + }; + + this.pythonShell.on('message', handler); + this.pythonShell.send(config); + }); + } + + /** + * Generate and return as Buffer + */ + async generateBuffer(config: FlashcardConfig): Promise { + const result = await this.generate(config); + return Buffer.from(result.pdf, 'base64'); + } + + /** + * Clean up Python process + */ + async close(): Promise { + if (this.pythonShell) { + this.pythonShell.end(() => {}); + this.pythonShell = null; + } + } +} + +// Example usage - just like calling a regular TypeScript function +async function example() { + const generator = new SorobanGenerator(); + + // Just call it like a function! + const result = await generator.generate({ + range: '0-99', + cardsPerPage: 6, + colorScheme: 'place-value', + coloredNumerals: true, + showCutMarks: true + }); + + // You get back a clean result object + console.log(`Generated ${result.count} flashcards`); + + // Convert to Buffer if needed + const pdfBuffer = Buffer.from(result.pdf, 'base64'); + + // Or use persistent mode for better performance + await generator.initialize(); + + // Now calls are faster + const result2 = await generator.generate({ range: '0-9' }); + const result3 = await generator.generate({ range: '10-19' }); + + await generator.close(); +} + +// Express example - clean function calls +export function expressRoute(app: any) { + const generator = new SorobanGenerator(); + + app.post('/api/flashcards', async (req, res) => { + try { + // Just pass the config object directly! + const result = await generator.generate(req.body); + + // Send back JSON or PDF + if (req.query.format === 'json') { + res.json(result); + } else { + const pdfBuffer = Buffer.from(result.pdf, 'base64'); + res.contentType('application/pdf'); + res.send(pdfBuffer); + } + } catch (error) { + res.status(500).json({ error: error.message }); + } + }); +} \ No newline at end of file diff --git a/client/node/soroban-generator.ts b/client/node/soroban-generator.ts new file mode 100644 index 00000000..f16b0fe0 --- /dev/null +++ b/client/node/soroban-generator.ts @@ -0,0 +1,211 @@ +/** + * Node.js TypeScript wrapper for Soroban Flashcard Generator + * Calls Python functions via child_process + */ + +import { spawn, execSync } from 'child_process'; +import * as path from 'path'; +import * as fs from 'fs/promises'; +import * as os from 'os'; + +export interface FlashcardConfig { + range: string; + step?: number; + cardsPerPage?: number; + paperSize?: 'us-letter' | 'a4' | 'a3' | 'a5'; + orientation?: 'portrait' | 'landscape'; + margins?: { + top?: string; + bottom?: string; + left?: string; + right?: string; + }; + gutter?: string; + shuffle?: boolean; + seed?: number; + showCutMarks?: boolean; + showRegistration?: boolean; + fontFamily?: string; + fontSize?: string; + columns?: string | number; + showEmptyColumns?: boolean; + hideInactiveBeads?: boolean; + beadShape?: 'diamond' | 'circle' | 'square'; + colorScheme?: 'monochrome' | 'place-value' | 'heaven-earth' | 'alternating'; + coloredNumerals?: boolean; + scaleFactor?: number; +} + +export class SorobanGenerator { + private pythonPath: string; + private generatorPath: string; + private projectRoot: string; + + constructor(projectRoot?: string) { + // Find Python executable + this.pythonPath = this.findPython(); + + // Set project root (where the Python scripts are) + this.projectRoot = projectRoot || path.join(__dirname, '../../'); + this.generatorPath = path.join(this.projectRoot, 'src', 'generate.py'); + } + + private findPython(): string { + // Try common Python commands + const pythonCommands = ['python3', 'python']; + + for (const cmd of pythonCommands) { + try { + const version = execSync(`${cmd} --version`, { encoding: 'utf8' }); + if (version.includes('Python 3')) { + return cmd; + } + } catch { + // Continue to next command + } + } + + throw new Error('Python 3 not found. Please install Python 3.'); + } + + /** + * Generate flashcards and return PDF as Buffer + */ + async generate(config: FlashcardConfig): Promise { + // Create temp output file + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'soroban-')); + const outputPath = path.join(tempDir, 'flashcards.pdf'); + + try { + // Build command line arguments + const args = [ + this.generatorPath, + '--output', outputPath, + '--range', config.range, + ]; + + if (config.step) args.push('--step', config.step.toString()); + if (config.cardsPerPage) args.push('--cards-per-page', config.cardsPerPage.toString()); + if (config.paperSize) args.push('--paper-size', config.paperSize); + if (config.orientation) args.push('--orientation', config.orientation); + if (config.gutter) args.push('--gutter', config.gutter); + if (config.shuffle) args.push('--shuffle'); + if (config.seed !== undefined) args.push('--seed', config.seed.toString()); + if (config.showCutMarks) args.push('--cut-marks'); + if (config.showRegistration) args.push('--registration'); + if (config.fontFamily) args.push('--font-family', config.fontFamily); + if (config.fontSize) args.push('--font-size', config.fontSize); + if (config.columns !== undefined) args.push('--columns', config.columns.toString()); + if (config.showEmptyColumns) args.push('--show-empty-columns'); + if (config.hideInactiveBeads) args.push('--hide-inactive-beads'); + if (config.beadShape) args.push('--bead-shape', config.beadShape); + if (config.colorScheme) args.push('--color-scheme', config.colorScheme); + if (config.coloredNumerals) args.push('--colored-numerals'); + if (config.scaleFactor !== undefined) args.push('--scale-factor', config.scaleFactor.toString()); + + if (config.margins) { + const m = config.margins; + const marginStr = `${m.top || '0.5in'},${m.right || '0.5in'},${m.bottom || '0.5in'},${m.left || '0.5in'}`; + args.push('--margins', marginStr); + } + + // Execute Python script + await this.executePython(args); + + // Read generated PDF + const pdfBuffer = await fs.readFile(outputPath); + return pdfBuffer; + + } finally { + // Clean up temp directory + await fs.rm(tempDir, { recursive: true, force: true }); + } + } + + /** + * Generate flashcards and save to file + */ + async generateToFile(config: FlashcardConfig, outputPath: string): Promise { + const pdfBuffer = await this.generate(config); + await fs.writeFile(outputPath, pdfBuffer); + } + + /** + * Generate flashcards and return as base64 string + */ + async generateBase64(config: FlashcardConfig): Promise { + const pdfBuffer = await this.generate(config); + return pdfBuffer.toString('base64'); + } + + private executePython(args: string[]): Promise { + return new Promise((resolve, reject) => { + const process = spawn(this.pythonPath, args, { + cwd: this.projectRoot, + env: { ...process.env, PYTHONPATH: this.projectRoot } + }); + + let stderr = ''; + + process.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + process.on('close', (code) => { + if (code !== 0) { + reject(new Error(`Python process failed with code ${code}: ${stderr}`)); + } else { + resolve(); + } + }); + + process.on('error', (err) => { + reject(new Error(`Failed to start Python process: ${err.message}`)); + }); + }); + } + + /** + * Check if all dependencies are installed + */ + async checkDependencies(): Promise<{ python: boolean; typst: boolean; qpdf: boolean }> { + const checks = { + python: false, + typst: false, + qpdf: false + }; + + try { + execSync(`${this.pythonPath} --version`); + checks.python = true; + } catch {} + + try { + execSync('typst --version'); + checks.typst = true; + } catch {} + + try { + execSync('qpdf --version'); + checks.qpdf = true; + } catch {} + + return checks; + } +} + +// Example usage for Express/Next.js/etc +export async function expressExample() { + const generator = new SorobanGenerator(); + + // In your Express route handler: + // app.post('/api/generate', async (req, res) => { + // try { + // const pdfBuffer = await generator.generate(req.body); + // res.contentType('application/pdf'); + // res.send(pdfBuffer); + // } catch (error) { + // res.status(500).json({ error: error.message }); + // } + // }); +} \ No newline at end of file