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