From d10b7836b7e7fda29d5f9c12eb7cd142343cff20 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Tue, 9 Sep 2025 12:14:05 -0500 Subject: [PATCH] Add Python CLI tool for flashcard generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create generate.py with full CLI argument support - Support YAML/JSON configuration files - Implement number range parsing and shuffling - Add font path configuration for bundled fonts - Include PDF linearization with qpdf - Add sample generation script for testing 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- generate_samples.sh | 68 +++++++++++++ src/generate.py | 243 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 311 insertions(+) create mode 100755 generate_samples.sh create mode 100755 src/generate.py diff --git a/generate_samples.sh b/generate_samples.sh new file mode 100755 index 00000000..9d01f6a4 --- /dev/null +++ b/generate_samples.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +# Generate sample PDFs for demonstration +# This script creates sample outputs once Typst is installed + +set -e + +echo "Checking dependencies..." +if ! command -v typst &> /dev/null; then + echo "Error: Typst is not installed. Please run 'make install' first." + exit 1 +fi + +if ! command -v python3 &> /dev/null; then + echo "Error: Python3 is not installed." + exit 1 +fi + +echo "Creating output directories..." +mkdir -p out/samples + +echo "Generating sample PDFs..." + +# 1. Default 0-9 set +echo " 1/5: Default (0-9)..." +python3 src/generate.py \ + --config config/default.yaml \ + --output out/samples/default_0-9.pdf + +# 2. 0-99 with cut marks +echo " 2/5: 0-99 with cut marks..." +python3 src/generate.py \ + --config config/0-99.yaml \ + --output out/samples/0-99_with_cuts.pdf + +# 3. 3-column fixed width (0-999) +echo " 3/5: 3-column fixed (0-999)..." +python3 src/generate.py \ + --config config/3-column-fixed.yaml \ + --range "0-20" \ + --output out/samples/3-column_sample.pdf + +# 4. Custom list with 8 cards per page +echo " 4/5: Custom list (8 per page)..." +python3 src/generate.py \ + --range "1,2,5,10,20,50,100,500" \ + --cards-per-page 8 \ + --font-size "36pt" \ + --output out/samples/custom_list_8up.pdf + +# 5. Shuffled deck with seed +echo " 5/5: Shuffled 0-20..." +python3 src/generate.py \ + --range "0-20" \ + --shuffle \ + --seed 42 \ + --show-empty-columns \ + --columns 2 \ + --output out/samples/shuffled_0-20.pdf + +echo "" +echo "Sample generation complete!" +echo "Generated files:" +ls -lh out/samples/*.pdf 2>/dev/null || echo " (PDFs will appear here once generated)" +echo "" +echo "Note: If Typst is not installed, run:" +echo " brew install typst" +echo " ./generate_samples.sh" \ No newline at end of file diff --git a/src/generate.py b/src/generate.py new file mode 100755 index 00000000..d9212257 --- /dev/null +++ b/src/generate.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 + +import argparse +import json +import yaml +import random +import subprocess +import sys +import os +from pathlib import Path + +def load_config(config_path): + """Load configuration from JSON or YAML file.""" + with open(config_path, 'r') as f: + if config_path.endswith('.yaml') or config_path.endswith('.yml'): + return yaml.safe_load(f) + else: + return json.load(f) + +def parse_range(range_str): + """Parse a range string like '0-99' or a comma-separated list.""" + numbers = [] + if ',' in range_str: + # Comma-separated list + for part in range_str.split(','): + part = part.strip() + if '-' in part: + start, end = map(int, part.split('-')) + numbers.extend(range(start, end + 1)) + else: + numbers.append(int(part)) + elif '-' in range_str: + # Range + start, end = map(int, range_str.split('-')) + numbers = list(range(start, end + 1)) + else: + # Single number + numbers = [int(range_str)] + return numbers + +def generate_typst_file(numbers, config, output_path): + """Generate a Typst file with the specified configuration.""" + + # Convert Python list to Typst array syntax + numbers_str = '(' + ', '.join(str(n) for n in numbers) + ',)' + + # Build the Typst document + # Use relative path from project root where temp file is created + typst_content = f''' +#import "templates/flashcards.typ": generate-flashcards + +#generate-flashcards( + {numbers_str}, + cards-per-page: {config.get('cards_per_page', 6)}, + paper-size: "{config.get('paper_size', 'us-letter')}", + orientation: "{config.get('orientation', 'portrait')}", + margins: ( + top: {config.get('margins', {}).get('top', '0.5in')}, + bottom: {config.get('margins', {}).get('bottom', '0.5in')}, + left: {config.get('margins', {}).get('left', '0.5in')}, + right: {config.get('margins', {}).get('right', '0.5in')} + ), + gutter: {config.get('gutter', '5mm')}, + show-cut-marks: {str(config.get('show_cut_marks', False)).lower()}, + show-registration: {str(config.get('show_registration', False)).lower()}, + font-family: "{config.get('font_family', 'DejaVu Sans')}", + font-size: {config.get('font_size', '48pt')}, + columns: {config.get('columns', 'auto')}, + show-empty-columns: {str(config.get('show_empty_columns', False)).lower()} +) +''' + + with open(output_path, 'w') as f: + f.write(typst_content) + +def main(): + parser = argparse.ArgumentParser(description='Generate Soroban flashcards PDF') + parser.add_argument('--config', '-c', type=str, help='Configuration file (JSON or YAML)') + parser.add_argument('--range', '-r', type=str, help='Number range (e.g., "0-99") or list (e.g., "1,2,5,10")') + parser.add_argument('--cards-per-page', type=int, default=6, help='Cards per page (default: 6)') + parser.add_argument('--paper-size', type=str, default='us-letter', help='Paper size (default: us-letter)') + parser.add_argument('--orientation', type=str, choices=['portrait', 'landscape'], default='portrait', help='Page orientation') + parser.add_argument('--margins', type=str, help='Margins in format "top,right,bottom,left" (e.g., "0.5in,0.5in,0.5in,0.5in")') + parser.add_argument('--gutter', type=str, default='5mm', help='Space between cards (default: 5mm)') + parser.add_argument('--shuffle', action='store_true', help='Shuffle the numbers') + parser.add_argument('--seed', type=int, help='Random seed for shuffle (for deterministic builds)') + parser.add_argument('--cut-marks', action='store_true', help='Show cut marks') + parser.add_argument('--registration', action='store_true', help='Show registration marks') + parser.add_argument('--font-family', type=str, default='DejaVu Sans', help='Font family') + parser.add_argument('--font-size', type=str, default='48pt', help='Font size') + parser.add_argument('--columns', type=str, default='auto', help='Number of soroban columns (auto or integer)') + parser.add_argument('--show-empty-columns', action='store_true', help='Show leading empty columns') + parser.add_argument('--output', '-o', type=str, default='out/flashcards.pdf', help='Output PDF path') + parser.add_argument('--linearize', action='store_true', default=True, help='Create linearized PDF (default: True)') + parser.add_argument('--font-path', type=str, help='Path to fonts directory') + + args = parser.parse_args() + + # Load config file if provided + config = {} + if args.config: + config = load_config(args.config) + + # Override config with command-line arguments + if args.range: + numbers = parse_range(args.range) + elif 'range' in config: + numbers = parse_range(config['range']) + elif 'numbers' in config: + numbers = config['numbers'] + else: + # Default to 0-9 + numbers = list(range(10)) + + # Apply shuffle if requested + if args.shuffle or config.get('shuffle', False): + seed = args.seed or config.get('seed') + if seed is not None: + random.seed(seed) + random.shuffle(numbers) + + # Build final configuration + final_config = { + 'cards_per_page': args.cards_per_page or config.get('cards_per_page', 6), + 'paper_size': args.paper_size or config.get('paper_size', 'us-letter'), + 'orientation': args.orientation or config.get('orientation', 'portrait'), + 'gutter': args.gutter or config.get('gutter', '5mm'), + 'show_cut_marks': args.cut_marks or config.get('show_cut_marks', False), + 'show_registration': args.registration or config.get('show_registration', False), + 'font_family': args.font_family or config.get('font_family', 'DejaVu Sans'), + 'font_size': args.font_size or config.get('font_size', '48pt'), + 'show_empty_columns': args.show_empty_columns or config.get('show_empty_columns', False), + } + + # Handle margins + if args.margins: + parts = args.margins.split(',') + if len(parts) == 4: + final_config['margins'] = { + 'top': parts[0], + 'right': parts[1], + 'bottom': parts[2], + 'left': parts[3] + } + elif 'margins' in config: + final_config['margins'] = config['margins'] + else: + final_config['margins'] = { + 'top': '0.5in', + 'right': '0.5in', + 'bottom': '0.5in', + 'left': '0.5in' + } + + # Handle columns + if args.columns != 'auto': + try: + final_config['columns'] = int(args.columns) + except ValueError: + final_config['columns'] = 'auto' + else: + final_config['columns'] = config.get('columns', 'auto') + + # Create output directory + output_path = Path(args.output) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Generate temporary Typst file in project root + project_root = Path(__file__).parent.parent + temp_typst = project_root / 'temp_flashcards.typ' + generate_typst_file(numbers, final_config, temp_typst) + + # Set up font path if provided + font_args = [] + if args.font_path: + font_args = ['--font-path', args.font_path] + elif os.path.exists('fonts'): + font_args = ['--font-path', 'fonts'] + + # Compile with Typst + print(f"Generating flashcards for {len(numbers)} numbers...") + try: + # Run typst from project root directory + project_root = Path(__file__).parent.parent + result = subprocess.run( + ['typst', 'compile'] + font_args + [str(temp_typst), str(output_path)], + capture_output=True, + text=True, + cwd=str(project_root) + ) + + if result.returncode != 0: + print(f"Error compiling Typst document:", file=sys.stderr) + print(result.stderr, file=sys.stderr) + sys.exit(1) + + print(f"Generated: {output_path}") + + # Clean up temp file + temp_typst.unlink() + + # Linearize PDF if requested + if args.linearize: + linearized_path = output_path.parent / f"{output_path.stem}_linear{output_path.suffix}" + print(f"Linearizing PDF...") + + result = subprocess.run( + ['qpdf', '--linearize', str(output_path), str(linearized_path)], + capture_output=True, + text=True + ) + + if result.returncode == 0: + print(f"Linearized: {linearized_path}") + else: + print(f"Warning: Failed to linearize PDF: {result.stderr}", file=sys.stderr) + + # Run basic PDF validation + print("Validating PDF...") + result = subprocess.run( + ['qpdf', '--check', str(output_path)], + capture_output=True, + text=True + ) + + if result.returncode == 0: + print("PDF validation passed") + else: + print(f"Warning: PDF validation issues: {result.stderr}", file=sys.stderr) + + except FileNotFoundError as e: + if 'typst' in str(e): + print("Error: typst command not found. Please install Typst first.", file=sys.stderr) + print("Visit: https://github.com/typst/typst", file=sys.stderr) + elif 'qpdf' in str(e): + print("Warning: qpdf command not found. Skipping linearization and validation.", file=sys.stderr) + print("Install with: brew install qpdf", file=sys.stderr) + else: + raise + sys.exit(1) + +if __name__ == '__main__': + main() \ No newline at end of file