feat: add Node.js/TypeScript integration with clean function interface

- Add SorobanGenerator class with python-shell bridge
- No CLI arguments - clean TypeScript function calls
- Support for Express.js and Next.js integration
- Include comprehensive API documentation

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-09-09 15:45:47 -05:00
parent a8a01a8db3
commit fb1b0470cf
5 changed files with 796 additions and 0 deletions

259
client/node/API.md Normal file
View File

@ -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<FlashcardResult>`
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<Buffer>`
Generate flashcards and return as Node.js Buffer.
**Parameters:**
- `config`: Configuration object
**Returns:** Promise resolving to Buffer containing PDF data
##### `initialize(): Promise<void>`
Initialize a persistent Python process for better performance when generating multiple PDFs.
##### `close(): Promise<void>`
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';
```

View File

@ -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');
});

21
client/node/package.json Normal file
View File

@ -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"
}
}

View File

@ -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<void> {
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<FlashcardResult> {
// 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<Buffer> {
const result = await this.generate(config);
return Buffer.from(result.pdf, 'base64');
}
/**
* Clean up Python process
*/
async close(): Promise<void> {
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 });
}
});
}

View File

@ -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<Buffer> {
// 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<void> {
const pdfBuffer = await this.generate(config);
await fs.writeFile(outputPath, pdfBuffer);
}
/**
* Generate flashcards and return as base64 string
*/
async generateBase64(config: FlashcardConfig): Promise<string> {
const pdfBuffer = await this.generate(config);
return pdfBuffer.toString('base64');
}
private executePython(args: string[]): Promise<void> {
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 });
// }
// });
}