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:
parent
1312eef345
commit
d10b7836b7
|
|
@ -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"
|
||||||
|
|
@ -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()
|
||||||
Loading…
Reference in New Issue