soroban-abacus-flashcards/client/node/soroban-generator.ts

211 lines
6.3 KiB
TypeScript

/**
* 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 });
// }
// });
}