Add Python CLI tool for flashcard generation

- 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 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-09-09 12:14:05 -05:00
parent 1312eef345
commit d10b7836b7
2 changed files with 311 additions and 0 deletions

68
generate_samples.sh Executable file
View File

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

243
src/generate.py Executable file
View File

@ -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()