feat: add PDF print integration with modal interface

Replace web print support with professional PDF printing workflow:
- Generate companion PDF automatically with each web flashcard page
- Intercept print attempts (Ctrl+P/Cmd+P, browser print menu)
- Show informative modal explaining PDF advantages
- Open high-quality PDF in new tab for printing
- Include cut marks, registration marks, and proper card layout
- Remove misleading web print CSS and documentation claims

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-09-10 18:54:39 -05:00
parent 6cf8f90c79
commit 09b0fad633
2 changed files with 257 additions and 21 deletions

View File

@ -167,7 +167,7 @@ python3 src/generate.py --format web --range 0-50 --shuffle
- Turn-based timers and player indicators for multiplayer games
- Efficiency-based medal system (Gold/Silver/Bronze achievements)
- **📱 Responsive Design**: Works on desktop, tablet, and mobile
- **🖨️ Print Support**: CSS optimized for printing physical cards
- **🖨️ PDF Integration**: Automatically suggests high-quality PDF format when printing is attempted
- **♿ Accessible**: Keyboard navigation, semantic HTML, ARIA labels
- **🎨 Full Customization**: All color schemes, bead shapes, and display options supported

View File

@ -6,8 +6,20 @@ Generates static HTML with inline SVG abacus representations using existing Typs
import tempfile
import json
import subprocess
import os
import sys
from pathlib import Path
# Import PDF generation functionality
try:
from generate import generate_typst_file
except ImportError:
# If running as a standalone script, try relative import
import sys
sys.path.append(str(Path(__file__).parent))
from generate import generate_typst_file
def get_colored_numeral_html(number, config):
"""Generate HTML for numeral with appropriate coloring based on configuration."""
@ -195,6 +207,62 @@ def generate_card_svgs(numbers, config):
return card_data
def generate_companion_pdf(numbers, config, output_path):
"""Generate a PDF version alongside the web flashcards for print functionality."""
# Ensure output_path is a Path object
output_path = Path(output_path)
# Use PDF-optimized config
pdf_config = config.copy()
pdf_config.update({
'cards_per_page': 6, # Standard 6 cards per page for printing
'show_cut_marks': True, # Always show cut marks for printing
'show_registration': True, # Always show registration marks
'paper_size': 'us-letter', # Standard paper size
'orientation': 'portrait', # Standard orientation
'margins': {'top': '0.5in', 'bottom': '0.5in', 'left': '0.5in', 'right': '0.5in'},
'gutter': '5mm'
})
# Generate Typst file
project_root = Path(__file__).parent.parent
temp_typst = project_root / 'temp_web_companion.typ'
generate_typst_file(numbers, pdf_config, temp_typst)
# Set up font path
font_args = []
if os.path.exists(str(project_root / 'fonts')):
font_args = ['--font-path', str(project_root / 'fonts')]
# Compile with Typst - use absolute paths to ensure correct location
result = subprocess.run(
['typst', 'compile'] + font_args + [str(temp_typst), str(output_path.resolve())],
capture_output=True,
text=True,
cwd=str(project_root)
)
if result.returncode != 0:
raise RuntimeError(f"Typst compilation failed: {result.stderr}")
# Clean up temp file
temp_typst.unlink(missing_ok=True)
# Linearize PDF for web delivery
try:
linearized_path = output_path.with_name(f"{output_path.stem}_linearized.pdf")
result = subprocess.run(
['qpdf', '--linearize', str(output_path), str(linearized_path)],
capture_output=True,
text=True
)
if result.returncode == 0:
linearized_path.replace(output_path) # Replace original with linearized
except FileNotFoundError:
pass # qpdf not available, skip linearization
def generate_web_flashcards(numbers, config, output_path):
"""Generate HTML file with flashcard layout."""
@ -2812,25 +2880,6 @@ def generate_web_flashcards(numbers, config, output_path):
to {{ transform: scale(1) translateY(0); opacity: 1; }}
}}
@media print {{
body {{
background-color: white;
}}
.flashcard {{
box-shadow: none;
border: 1px solid #ddd;
break-inside: avoid;
}}
.numeral {{
opacity: 0.5;
background: transparent;
border: none;
box-shadow: none;
}}
.quiz-section, .quiz-game, .quiz-input, .quiz-results, .sorting-section {{
display: none !important;
}}
}}
</style>
</head>
<body>
@ -3260,7 +3309,7 @@ def generate_web_flashcards(numbers, config, output_path):
</div>
<div class="instructions">
<p><em>Tip: You can print these cards for offline practice. Numbers will be faintly visible in print mode.</em></p>
<p><em>Interactive flashcards for digital learning. Use the left/right arrow keys or click the cards to flip them.</em></p>
</div>
</div>
@ -5797,12 +5846,188 @@ def generate_web_flashcards(numbers, config, output_path):
}}
}}
// Function to handle print requests by opening PDF in print dialog
function handlePrintRequest() {{
console.log('Print request intercepted!');
// Get the companion PDF URL
const pdfUrl = window.location.href.replace(/\.html$/, '.pdf');
// Create a modal overlay explaining the situation
const modal = document.createElement('div');
modal.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.8);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
`;
const content = document.createElement('div');
content.style.cssText = `
background: white;
border-radius: 8px;
padding: 30px;
max-width: 500px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
`;
const title = document.createElement('h3');
title.textContent = 'Print High-Quality Flashcards';
title.style.margin = '0 0 20px 0';
title.style.color = '#333';
const explanation = document.createElement('p');
explanation.innerHTML = 'The web version is not optimized for printing.<br>For best results, download the PDF version which includes:<br><br>• Proper card sizing and layout<br>• Cut marks for easy trimming<br>• Registration marks for alignment';
explanation.style.textAlign = 'left';
explanation.style.margin = '0 0 25px 0';
explanation.style.lineHeight = '1.6';
explanation.style.color = '#555';
const buttonContainer = document.createElement('div');
buttonContainer.style.cssText = 'display: flex; gap: 15px;';
const downloadButton = document.createElement('button');
downloadButton.textContent = '📄 Open PDF';
downloadButton.style.cssText = `
padding: 12px 24px;
background: #007bff;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
font-weight: 500;
`;
const cancelButton = document.createElement('button');
cancelButton.textContent = 'Cancel';
cancelButton.style.cssText = `
padding: 12px 24px;
background: #6c757d;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
`;
// Download button handler
downloadButton.onclick = function() {{
showNotification('📄 Opening PDF in new tab...');
window.open(pdfUrl, '_blank');
modal.remove();
}};
// Cancel button handler
cancelButton.onclick = function() {{
modal.remove();
}};
// Close on backdrop click
modal.onclick = function(e) {{
if (e.target === modal) {{
modal.remove();
}}
}};
// Prevent clicks inside content from closing modal
content.onclick = function(e) {{
e.stopPropagation();
}};
// Assemble the modal
buttonContainer.appendChild(downloadButton);
buttonContainer.appendChild(cancelButton);
content.appendChild(title);
content.appendChild(explanation);
content.appendChild(buttonContainer);
modal.appendChild(content);
document.body.appendChild(modal);
}}
// Fallback function to download PDF
function downloadPDF(pdfUrl) {{
const link = document.createElement('a');
link.href = pdfUrl;
link.download = pdfUrl.split('/').pop();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
// Function to show brief notifications
function showNotification(message) {{
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: #28a745;
color: white;
padding: 15px 20px;
border-radius: 8px;
font-size: 16px;
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10000;
animation: slideInFromRight 0.3s ease-out;
`;
notification.textContent = message;
// Add animation styles
const style = document.createElement('style');
style.textContent = `
@keyframes slideInFromRight {{
from {{ transform: translateX(100%); opacity: 0; }}
to {{ transform: translateX(0); opacity: 1; }}
}}
`;
document.head.appendChild(style);
document.body.appendChild(notification);
// Auto-remove after 3 seconds
setTimeout(() => {{
notification.style.animation = 'slideInFromRight 0.3s ease-out reverse';
setTimeout(() => {{
if (notification.parentNode) {{
document.body.removeChild(notification);
}}
if (style.parentNode) {{
document.head.removeChild(style);
}}
}}, 300);
}}, 3000);
}}
// Initialize quiz and sorting when DOM is loaded
document.addEventListener('DOMContentLoaded', () => {{
new ModalManager();
new SorobanQuiz();
new SortingChallenge();
new MatchingChallenge();
// Intercept print attempts and download PDF instead
window.addEventListener('beforeprint', (e) => {{
e.preventDefault();
handlePrintRequest();
}});
// Also intercept Ctrl+P / Cmd+P
document.addEventListener('keydown', (e) => {{
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {{
e.preventDefault();
handlePrintRequest();
}}
}});
}});
</script>
</body>
@ -5825,4 +6050,15 @@ def generate_web_flashcards(numbers, config, output_path):
f.write(html_content)
print(f"Generated web flashcards: {output_path}")
# Also generate a PDF version for print functionality
pdf_path = Path(output_path).with_suffix('.pdf')
try:
print(f"Generating companion PDF for print functionality...")
generate_companion_pdf(numbers, config, pdf_path)
print(f"Generated companion PDF: {pdf_path}")
except Exception as e:
print(f"Warning: Could not generate companion PDF: {e}")
print("Print functionality will show instructions instead of downloading PDF")
return output_path