feat(worksheets): add QR codes with share codes for easy worksheet sharing

- Add optional QR code embedding in worksheet PDFs (toggle in Layout settings)
- Display 7-character share code under QR code for manual entry
- Create share record when generating PDF with QR code enabled
- Add "Load Share Code" modal for entering share codes without smartphone
- Update preview to show QR code placement when enabled
- Fix async/await for generateTypstSource across all callers

The QR code appears in the header next to the date, with the share code
printed below it. Users without smartphones can type the code into the
"Load Share Code" option in the worksheet generator menu.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-05 11:57:49 -06:00
parent ed25b323e8
commit a0e73d971b
17 changed files with 854 additions and 28 deletions

View File

@ -11,7 +11,7 @@ export async function POST(request: NextRequest) {
const body: WorksheetFormState = await request.json()
// Generate preview using shared logic
const result = generateWorksheetPreview(body)
const result = await generateWorksheetPreview(body)
if (!result.success) {
return NextResponse.json(

View File

@ -16,7 +16,7 @@ export async function POST(request: NextRequest, { params }: { params: { pageNum
}
// Generate only the requested page
const result = generateSinglePage(body, pageNumber)
const result = await generateSinglePage(body, pageNumber)
if (!result.success) {
return NextResponse.json(

View File

@ -56,7 +56,7 @@ export async function POST(request: NextRequest) {
}
// Generate preview using shared logic with pagination
const result = generateWorksheetPreview(body, startPage, endPage)
const result = await generateWorksheetPreview(body, startPage, endPage)
if (!result.success) {
return NextResponse.json(

View File

@ -2,6 +2,7 @@
import { type NextRequest, NextResponse } from 'next/server'
import { execSync } from 'child_process'
import { eq } from 'drizzle-orm'
import { validateWorksheetConfig } from '@/app/create/worksheets/validation'
import {
generateProblems,
@ -9,7 +10,11 @@ import {
generateMixedProblems,
} from '@/app/create/worksheets/problemGenerator'
import { generateTypstSource } from '@/app/create/worksheets/typstGenerator'
import { serializeAdditionConfig } from '@/app/create/worksheets/config-schemas'
import type { WorksheetFormState, WorksheetProblem } from '@/app/create/worksheets/types'
import { db } from '@/db'
import { worksheetShares } from '@/db/schema'
import { generateShareId } from '@/lib/generateShareId'
export async function POST(request: NextRequest) {
try {
@ -58,8 +63,74 @@ export async function POST(request: NextRequest) {
)
}
// If QR code is enabled, create a share record first
let shareUrl: string | undefined
if (config.includeQRCode) {
try {
// Generate unique share ID
let shareId = generateShareId()
let attempts = 0
const MAX_ATTEMPTS = 5
let isUnique = false
while (!isUnique && attempts < MAX_ATTEMPTS) {
shareId = generateShareId()
const existing = await db.query.worksheetShares.findFirst({
where: eq(worksheetShares.id, shareId),
})
if (!existing) {
isUnique = true
} else {
attempts++
}
}
if (!isUnique) {
console.error('Failed to generate unique share ID for QR code')
// Continue without QR code rather than failing the entire request
} else {
// Get creator IP (hashed for privacy)
const forwardedFor = request.headers.get('x-forwarded-for')
const ip = forwardedFor?.split(',')[0] || request.headers.get('x-real-ip') || 'unknown'
const hashIp = (str: string) => {
let hash = 0
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i)
hash = (hash << 5) - hash + char
hash = hash & hash
}
return hash.toString(36)
}
// Serialize config for sharing
const configJson = serializeAdditionConfig(body)
// Create share record
await db.insert(worksheetShares).values({
id: shareId,
worksheetType: 'addition',
config: configJson,
createdAt: new Date(),
views: 0,
creatorIp: hashIp(ip),
title: config.name || null,
})
// Build full URL
const protocol = request.headers.get('x-forwarded-proto') || 'https'
const host = request.headers.get('host') || 'abaci.one'
shareUrl = `${protocol}://${host}/worksheets/shared/${shareId}`
}
} catch (shareError) {
console.error('Error creating share for QR code:', shareError)
// Continue without QR code rather than failing the entire request
}
}
// Generate Typst sources (one per page)
const typstSources = generateTypstSource(config, problems)
const typstSources = await generateTypstSource(config, problems, shareUrl)
// Join pages with pagebreak for PDF
const typstSource = typstSources.join('\n\n#pagebreak()\n\n')

View File

@ -40,7 +40,7 @@ export async function POST(request: Request) {
}
// Generate preview
const result = generateWorksheetPreview(fullConfig)
const result = await generateWorksheetPreview(fullConfig)
if (!result.success) {
return NextResponse.json(

View File

@ -0,0 +1,299 @@
'use client'
import { css } from '@styled/css'
import { stack } from '@styled/patterns'
import { useRouter } from 'next/navigation'
import { useCallback, useState } from 'react'
import { isValidShareId } from '@/lib/generateShareId'
interface LoadShareCodeModalProps {
isOpen: boolean
onClose: () => void
isDark: boolean
}
/**
* Modal for entering a share code to load a shared worksheet
*
* Users can type in a 7-character share code (printed under QR codes on worksheets)
* to load the shared worksheet configuration.
*/
export function LoadShareCodeModal({ isOpen, onClose, isDark }: LoadShareCodeModalProps) {
const router = useRouter()
const [shareCode, setShareCode] = useState('')
const [error, setError] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
// Remove any non-alphanumeric characters and convert to string
const value = e.target.value.replace(/[^a-zA-Z0-9]/g, '').slice(0, 7)
setShareCode(value)
setError(null)
}, [])
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault()
// Validate share code format
if (!isValidShareId(shareCode)) {
setError('Invalid share code. Share codes are 7 characters (letters and numbers).')
return
}
setIsLoading(true)
setError(null)
try {
// Check if the share exists
const response = await fetch(`/api/worksheets/share/${shareCode}`)
if (!response.ok) {
if (response.status === 404) {
setError('Share code not found. Please check the code and try again.')
} else {
setError('Failed to load shared worksheet. Please try again.')
}
return
}
// Navigate to the shared worksheet page
router.push(`/worksheets/shared/${shareCode}`)
onClose()
} catch (err) {
console.error('Error loading share code:', err)
setError('Failed to load shared worksheet. Please check your connection.')
} finally {
setIsLoading(false)
}
},
[shareCode, router, onClose]
)
if (!isOpen) return null
return (
<div
data-component="load-share-code-modal"
className={css({
position: 'fixed',
inset: 0,
bg: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 50,
p: '4',
})}
onClick={onClose}
>
<div
className={css({
bg: isDark ? 'gray.800' : 'white',
rounded: 'xl',
shadow: 'xl',
maxW: 'md',
w: 'full',
p: '6',
})}
onClick={(e) => e.stopPropagation()}
>
<form onSubmit={handleSubmit}>
<div className={stack({ gap: '4' })}>
{/* Header */}
<div>
<h2
className={css({
fontSize: '2xl',
fontWeight: 'bold',
color: isDark ? 'gray.100' : 'gray.900',
mb: '2',
})}
>
Load Shared Worksheet
</h2>
<p
className={css({
fontSize: 'md',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Enter the 7-character share code printed under the QR code on a worksheet to load
it.
</p>
</div>
{/* Share Code Input */}
<div>
<label
htmlFor="share-code-input"
className={css({
display: 'block',
fontSize: 'sm',
fontWeight: 'medium',
color: isDark ? 'gray.300' : 'gray.700',
mb: '2',
})}
>
Share Code
</label>
<input
id="share-code-input"
type="text"
value={shareCode}
onChange={handleInputChange}
placeholder="e.g., k7mP2qR"
autoComplete="off"
autoFocus
className={css({
w: 'full',
px: '4',
py: '3',
fontSize: 'xl',
fontFamily: 'mono',
fontWeight: 'bold',
letterSpacing: '0.1em',
textAlign: 'center',
textTransform: 'none',
bg: isDark ? 'gray.900' : 'gray.50',
border: '2px solid',
borderColor: error
? 'red.500'
: shareCode.length === 7
? 'green.500'
: isDark
? 'gray.600'
: 'gray.300',
rounded: 'lg',
color: isDark ? 'gray.100' : 'gray.900',
outline: 'none',
transition: 'all 0.2s',
_focus: {
borderColor: isDark ? 'brand.400' : 'brand.500',
boxShadow: `0 0 0 3px ${isDark ? 'rgba(99, 102, 241, 0.3)' : 'rgba(99, 102, 241, 0.2)'}`,
},
_placeholder: {
color: isDark ? 'gray.500' : 'gray.400',
letterSpacing: '0.1em',
},
})}
/>
<div
className={css({
mt: '2',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<span
className={css({
fontSize: 'sm',
color: isDark ? 'gray.500' : 'gray.500',
})}
>
{shareCode.length}/7 characters
</span>
{shareCode.length === 7 && !error && (
<span
className={css({
fontSize: 'sm',
color: 'green.500',
})}
>
Valid format
</span>
)}
</div>
</div>
{/* Error Message */}
{error && (
<div
className={css({
p: '3',
bg: isDark ? 'red.900/30' : 'red.50',
border: '1px solid',
borderColor: isDark ? 'red.700' : 'red.200',
rounded: 'lg',
color: isDark ? 'red.300' : 'red.600',
fontSize: 'sm',
})}
>
{error}
</div>
)}
{/* Help Text */}
<div
className={css({
p: '4',
bg: isDark ? 'gray.700/50' : 'blue.50',
rounded: 'lg',
fontSize: 'sm',
color: isDark ? 'gray.300' : 'gray.700',
})}
>
<strong>Where to find the share code:</strong>
<ul className={css({ mt: '2', ml: '4', listStyle: 'disc' })}>
<li>Look at the top-right corner of a printed worksheet</li>
<li>The share code is printed below the QR code</li>
<li>It's 7 characters: letters and numbers (e.g., k7mP2qR)</li>
</ul>
</div>
{/* Actions */}
<div
className={css({
display: 'flex',
gap: '3',
flexDirection: 'row-reverse',
pt: '2',
})}
>
<button
type="submit"
disabled={shareCode.length !== 7 || isLoading}
className={css({
px: '6',
py: '3',
bg: shareCode.length === 7 ? 'brand.600' : isDark ? 'gray.600' : 'gray.300',
color: shareCode.length === 7 ? 'white' : isDark ? 'gray.400' : 'gray.500',
fontSize: 'md',
fontWeight: 'bold',
rounded: 'lg',
cursor: shareCode.length === 7 ? 'pointer' : 'not-allowed',
transition: 'all 0.2s',
opacity: isLoading ? 0.7 : 1,
_hover: shareCode.length === 7 ? { bg: 'brand.700' } : {},
})}
>
{isLoading ? 'Loading...' : 'Load Worksheet'}
</button>
<button
type="button"
onClick={onClose}
className={css({
px: '6',
py: '3',
bg: isDark ? 'gray.700' : 'gray.200',
color: isDark ? 'gray.300' : 'gray.700',
fontSize: 'md',
fontWeight: 'medium',
rounded: 'lg',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
bg: isDark ? 'gray.600' : 'gray.300',
},
})}
>
Cancel
</button>
</div>
</div>
</form>
</div>
</div>
)
}

View File

@ -30,8 +30,12 @@ interface OrientationPanelProps {
// Layout options
problemNumbers?: 'always' | 'never'
cellBorders?: 'always' | 'never'
includeAnswerKey?: boolean
includeQRCode?: boolean
onProblemNumbersChange?: (value: 'always' | 'never') => void
onCellBordersChange?: (value: 'always' | 'never') => void
onIncludeAnswerKeyChange?: (value: boolean) => void
onIncludeQRCodeChange?: (value: boolean) => void
}
/**
@ -53,8 +57,12 @@ export function OrientationPanel({
mode = 'custom',
problemNumbers = 'always',
cellBorders = 'always',
includeAnswerKey = false,
includeQRCode = false,
onProblemNumbersChange,
onCellBordersChange,
onIncludeAnswerKeyChange,
onIncludeQRCodeChange,
}: OrientationPanelProps) {
// Calculate best problems per page for an orientation to minimize total change
const getBestProblemsPerPage = (targetOrientation: 'portrait' | 'landscape') => {
@ -1272,6 +1280,126 @@ export function OrientationPanel({
/>
</button>
</label>
{/* Answer Key Toggle */}
<label
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
})}
>
<div>
<div
className={css({
fontSize: 'sm',
fontWeight: 'medium',
color: isDark ? 'gray.200' : 'gray.800',
})}
>
Answer Key
</div>
<div
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Include answer key at end of PDF
</div>
</div>
<button
type="button"
data-action="toggle-answer-key"
onClick={() => {
onIncludeAnswerKeyChange?.(!includeAnswerKey)
}}
className={css({
position: 'relative',
w: '12',
h: '6',
bg: includeAnswerKey ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
rounded: 'full',
cursor: 'pointer',
transition: 'background 0.2s',
flexShrink: 0,
})}
>
<div
className={css({
position: 'absolute',
top: '1',
left: includeAnswerKey ? '7' : '1',
w: '4',
h: '4',
bg: 'white',
rounded: 'full',
transition: 'left 0.2s',
})}
/>
</button>
</label>
{/* QR Code Toggle */}
<label
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
cursor: 'pointer',
})}
>
<div>
<div
className={css({
fontSize: 'sm',
fontWeight: 'medium',
color: isDark ? 'gray.200' : 'gray.800',
})}
>
QR Code
</div>
<div
className={css({
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
Link to shareable worksheet
</div>
</div>
<button
type="button"
data-action="toggle-qr-code"
onClick={() => {
onIncludeQRCodeChange?.(!includeQRCode)
}}
className={css({
position: 'relative',
w: '12',
h: '6',
bg: includeQRCode ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
rounded: 'full',
cursor: 'pointer',
transition: 'background 0.2s',
flexShrink: 0,
})}
>
<div
className={css({
position: 'absolute',
top: '1',
left: includeQRCode ? '7' : '1',
w: '4',
h: '4',
bg: 'white',
rounded: 'full',
transition: 'left 0.2s',
})}
/>
</button>
</label>
</div>
</div>
</div>

View File

@ -10,6 +10,7 @@ import { UploadWorksheetModal } from '@/components/worksheets/UploadWorksheetMod
import { useTheme } from '@/contexts/ThemeContext'
import { extractConfigFields } from '../utils/extractConfigFields'
import { FloatingPageIndicator } from './FloatingPageIndicator'
import { LoadShareCodeModal } from './LoadShareCodeModal'
import { ShareModal } from './ShareModal'
import { useWorksheetConfig } from './WorksheetConfigContext'
import { WorksheetPreview } from './WorksheetPreview'
@ -211,6 +212,7 @@ export function PreviewCenter({
const scrollTimeoutRef = useRef<NodeJS.Timeout>()
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false)
const [isShareModalOpen, setIsShareModalOpen] = useState(false)
const [isLoadShareModalOpen, setIsLoadShareModalOpen] = useState(false)
const [isGeneratingShare, setIsGeneratingShare] = useState(false)
const [justCopied, setJustCopied] = useState(false)
// Dice rotation state for react-spring animation
@ -698,6 +700,34 @@ export function PreviewCenter({
<span className={css({ fontSize: 'lg' })}></span>
<span>Upload</span>
</DropdownMenu.Item>
<DropdownMenu.Item
data-action="load-share-code"
onClick={() => setIsLoadShareModalOpen(true)}
className={css({
px: '4',
py: '2.5',
fontSize: 'sm',
fontWeight: 'medium',
color: 'gray.700',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '2',
outline: 'none',
_hover: {
bg: 'amber.50',
color: 'amber.700',
},
_focus: {
bg: 'amber.50',
color: 'amber.700',
},
})}
>
<span className={css({ fontSize: 'lg' })}>🔗</span>
<span>Load Share Code</span>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Content>
@ -721,6 +751,12 @@ export function PreviewCenter({
onClose={() => setIsUploadModalOpen(false)}
onUploadComplete={handleUploadComplete}
/>
<LoadShareCodeModal
isOpen={isLoadShareModalOpen}
onClose={() => setIsLoadShareModalOpen(false)}
isDark={isDark}
/>
</>
)}

View File

@ -163,6 +163,8 @@ function PreviewContent({
formState.showTenFrames,
formState.showTenFramesForAll,
formState.seed, // Include seed to bust cache when problem set regenerates
formState.includeQRCode, // Include QR code setting to regenerate preview when toggled
formState.includeAnswerKey, // Include answer key setting to regenerate preview when toggled
// Note: fontSize, date, rows, total intentionally excluded
// (rows and total are derived from primary state)
],

View File

@ -160,6 +160,22 @@ export function LayoutTab() {
onChange(updates)
}}
includeAnswerKey={formState.includeAnswerKey ?? false}
onIncludeAnswerKeyChange={(value) => {
console.log('[LayoutTab] Changing includeAnswerKey:', {
from: formState.includeAnswerKey,
to: value,
})
onChange({ includeAnswerKey: value })
}}
includeQRCode={formState.includeQRCode ?? false}
onIncludeQRCodeChange={(value) => {
console.log('[LayoutTab] Changing includeQRCode:', {
from: formState.includeQRCode,
to: value,
})
onChange({ includeQRCode: value })
}}
/>
)
}

View File

@ -350,6 +350,12 @@ const additionConfigV4BaseSchema = z.object({
pAllStart: z.number().min(0).max(1),
interpolate: z.boolean(),
// V4: Include answer key pages at end of PDF
includeAnswerKey: z.boolean().optional().default(false),
// V4: Include QR code linking to shared worksheet on each page
includeQRCode: z.boolean().optional().default(false),
// Problem reproducibility (CRITICAL for sharing worksheets)
seed: z.number().int().min(0).optional(),
prngAlgorithm: z.string().optional(),

View File

@ -39,11 +39,11 @@ export interface SinglePageResult {
* @param startPage - Optional start page (0-indexed, inclusive). Default: 0
* @param endPage - Optional end page (0-indexed, inclusive). Default: last page
*/
export function generateWorksheetPreview(
export async function generateWorksheetPreview(
config: WorksheetFormState,
startPage?: number,
endPage?: number
): PreviewResult {
): Promise<PreviewResult> {
const totalProblems = (config.problemsPerPage ?? 20) * (config.pages ?? 1)
console.log(`[PREVIEW] Starting generation: ${totalProblems} problems, pages ${config.pages}`)
@ -159,9 +159,13 @@ export function generateWorksheetPreview(
console.log(`[PREVIEW] Step 2: ✓ Generated ${problems.length} problems`)
// Generate Typst sources (one per page)
// Use placeholder URL for QR code in preview (actual URL will be generated when PDF is created)
const previewShareUrl = validatedConfig.includeQRCode
? 'https://abaci.one/worksheets/shared/preview'
: undefined
console.log(`[PREVIEW] Step 3: Generating Typst source for ${validatedConfig.pages} pages...`)
const startTypst = Date.now()
const typstSources = generateTypstSource(validatedConfig, problems)
const typstSources = await generateTypstSource(validatedConfig, problems, previewShareUrl)
const typstTime = Date.now() - startTypst
const totalPages = typstSources.length
console.log(`[PREVIEW] Step 3: ✓ Generated ${totalPages} Typst sources in ${typstTime}ms`)
@ -246,10 +250,10 @@ export function generateWorksheetPreview(
* Generate a single worksheet page SVG
* Much faster than generating all pages when you only need one
*/
export function generateSinglePage(
export async function generateSinglePage(
config: WorksheetFormState,
pageNumber: number
): SinglePageResult {
): Promise<SinglePageResult> {
try {
// First, validate and get total page count
const validation = validateWorksheetConfig(config)
@ -346,7 +350,11 @@ export function generateSinglePage(
}
// Generate Typst source for ALL pages (lightweight operation)
const typstSources = generateTypstSource(problems, validatedConfig)
// Use placeholder URL for QR code in preview
const previewShareUrl = validatedConfig.includeQRCode
? 'https://abaci.one/worksheets/shared/preview'
: undefined
const typstSources = await generateTypstSource(validatedConfig, problems, previewShareUrl)
// Only compile the requested page
const typstSource = typstSources[pageNumber]

View File

@ -105,7 +105,7 @@ export default async function AdditionWorksheetPage() {
const INITIAL_PAGES = 3
const pagesToGenerate = Math.min(INITIAL_PAGES, pages)
console.log(`[SSR] Generating initial ${pagesToGenerate} pages on server (total: ${pages})...`)
const previewResult = generateWorksheetPreview(fullConfig, 0, pagesToGenerate - 1)
const previewResult = await generateWorksheetPreview(fullConfig, 0, pagesToGenerate - 1)
console.log('[SSR] Preview generation complete:', previewResult.success ? 'success' : 'failed')
// Pass settings and preview to client, wrapped in error boundary

View File

@ -0,0 +1,61 @@
/**
* QR Code generation utilities for worksheet PDF embedding
*
* Generates QR codes as SVG strings that can be embedded in Typst documents
* using image.decode() with raw SVG strings.
*/
import QRCode from 'qrcode'
/**
* Generate a QR code as an SVG string
*
* @param url - The URL to encode in the QR code
* @param size - The size of the QR code in pixels (default 100)
* @returns SVG string representing the QR code
*/
export async function generateQRCodeSVG(url: string, size = 100): Promise<string> {
const svg = await QRCode.toString(url, {
type: 'svg',
width: size,
margin: 1,
errorCorrectionLevel: 'M', // Medium error correction - good balance
color: {
dark: '#000000',
light: '#ffffff',
},
})
return svg
}
/**
* Generate a QR code as a base64-encoded SVG data URI
* This format can be embedded directly in Typst using #image()
*
* @param url - The URL to encode in the QR code
* @param size - The size of the QR code in pixels (default 100)
* @returns Base64-encoded data URI string (e.g., "data:image/svg+xml;base64,...")
*/
export async function generateQRCodeBase64(url: string, size = 100): Promise<string> {
const svg = await generateQRCodeSVG(url, size)
const base64 = Buffer.from(svg).toString('base64')
return `data:image/svg+xml;base64,${base64}`
}
/**
* Generate the Typst code snippet for embedding a QR code
* Places the QR code at the bottom-left corner of the page
*
* @param shareUrl - The share URL to encode
* @param sizeInches - Size of the QR code in inches (default 0.5)
* @returns Typst code string for placing the QR code
*/
export async function generateQRCodeTypst(shareUrl: string, sizeInches = 0.5): Promise<string> {
const base64DataUri = await generateQRCodeBase64(shareUrl, 200) // Higher res for print
// Position at bottom-left corner with small margin
return `#place(bottom + left, dx: 0.25in, dy: -0.25in)[
#image("${base64DataUri}", width: ${sizeInches}in, height: ${sizeInches}in)
]`
}

View File

@ -29,6 +29,12 @@ export type WorksheetConfig = AdditionConfigV4 & {
seed: number
prngAlgorithm: string
// Answer key generation
includeAnswerKey: boolean
// QR code linking to shared worksheet
includeQRCode: boolean
// Layout
page: {
wIn: number

View File

@ -1,14 +1,15 @@
// Typst document generator for addition worksheets
import type { WorksheetProblem, WorksheetConfig } from '@/app/create/worksheets/types'
import type { WorksheetConfig, WorksheetProblem } from '@/app/create/worksheets/types'
import { resolveDisplayForProblem } from './displayRules'
import { analyzeProblem, analyzeSubtractionProblem } from './problemAnalysis'
import { generateQRCodeSVG } from './qrCodeGenerator'
import {
generateTypstHelpers,
generatePlaceValueColors,
generateProblemStackFunction,
generateSubtractionProblemStackFunction,
generatePlaceValueColors,
generateTypstHelpers,
} from './typstHelpers'
import { analyzeProblem, analyzeSubtractionProblem } from './problemAnalysis'
import { resolveDisplayForProblem } from './displayRules'
/**
* Chunk array into pages of specified size
@ -46,12 +47,16 @@ function calculateMaxDigits(problems: WorksheetProblem[]): number {
/**
* Generate Typst source code for a single page
* @param qrCodeSvg - Optional raw SVG string for QR code to embed
* @param shareCode - Optional share code to display under QR code (e.g., "k7mP2qR")
*/
function generatePageTypst(
config: WorksheetConfig,
pageProblems: WorksheetProblem[],
problemOffset: number,
rowsPerPage: number
rowsPerPage: number,
qrCodeSvg?: string,
shareCode?: string
): string {
// Calculate maximum digits for proper column layout
const maxDigits = calculateMaxDigits(pageProblems)
@ -235,12 +240,18 @@ ${generateSubtractionProblemStackFunction(cellSize, maxDigits)}
${problemsTypst}
)
// Compact header - name on left, date on right
// Compact header - name on left, date and QR code with share code on right
#grid(
columns: (1fr, 1fr),
align: (left, right),
columns: (1fr, auto, auto),
column-gutter: 0.15in,
align: (left, right, right),
text(size: 0.75em, weight: "bold")[${config.name}],
text(size: 0.65em)[${config.date}]
text(size: 0.65em)[${config.date}],
${
qrCodeSvg
? `stack(dir: ttb, spacing: 2pt, align(center)[#image.decode("${qrCodeSvg.replace(/"/g, '\\"').replace(/\n/g, '')}", width: 0.35in, height: 0.35in)], align(center)[#text(size: 6pt, font: "Courier New")[${shareCode || 'PREVIEW'}]])`
: `[]`
}
)
#v(${headerHeight}in - 0.25in)
@ -266,21 +277,197 @@ ${problemsTypst}
}
/**
* Generate Typst source code for the worksheet (returns array of page sources)
* Calculate the answer for a problem
*/
export function generateTypstSource(
function calculateAnswer(problem: WorksheetProblem): number {
if (problem.operator === 'add') {
return problem.a + problem.b
} else {
return problem.minuend - problem.subtrahend
}
}
/**
* Format a problem as a string for the answer key
* Example: "45 + 27 = 72" or "89 34 = 55"
*/
function formatProblemWithAnswer(
problem: WorksheetProblem,
index: number,
showNumber: boolean
): string {
const answer = calculateAnswer(problem)
if (problem.operator === 'add') {
const prefix = showNumber ? `*${index + 1}.* ` : ''
return `${prefix}${problem.a} + ${problem.b} = *${answer}*`
} else {
const prefix = showNumber ? `*${index + 1}.* ` : ''
return `${prefix}${problem.minuend} ${problem.subtrahend} = *${answer}*`
}
}
/**
* Generate Typst source code for answer key page(s)
* Displays problems with answers grouped by worksheet page
* @param qrCodeSvg - Optional raw SVG string for QR code to embed
* @param shareCode - Optional share code to display under QR code
*/
function generateAnswerKeyTypst(
config: WorksheetConfig,
problems: WorksheetProblem[]
problems: WorksheetProblem[],
showProblemNumbers: boolean,
qrCodeSvg?: string,
shareCode?: string
): string[] {
const { problemsPerPage } = config
const worksheetPageCount = Math.ceil(problems.length / problemsPerPage)
// Group problems by worksheet page
const worksheetPages: WorksheetProblem[][] = []
for (let i = 0; i < problems.length; i += problemsPerPage) {
worksheetPages.push(problems.slice(i, i + problemsPerPage))
}
// Generate answer sections for each worksheet page
// Each section is wrapped in a non-breakable block to keep page answers together
const generatePageSection = (
pageProblems: WorksheetProblem[],
worksheetPageNum: number,
globalOffset: number
): string => {
const answers = pageProblems
.map((problem, i) => {
const globalIndex = globalOffset + i
return formatProblemWithAnswer(problem, globalIndex, showProblemNumbers)
})
.join(' \\\n')
// Only show page header if there are multiple worksheet pages
// Wrap in block(breakable: false) to prevent splitting across columns/pages
if (worksheetPageCount > 1) {
return `#block(breakable: false)[
#text(size: 10pt, weight: "bold")[Page ${worksheetPageNum}] \\
${answers}
]`
}
return answers
}
// Generate all page sections
const allSections = worksheetPages.map((pageProblems, idx) =>
generatePageSection(pageProblems, idx + 1, idx * problemsPerPage)
)
// Combine sections with spacing between page groups
const combinedAnswers =
worksheetPageCount > 1 ? allSections.join('\n\n#v(0.5em)\n\n') : allSections[0]
// For now, generate a single answer key page
// TODO: If content exceeds page height, could split into multiple pages
const pageTypst = String.raw`
// answer-key-page.typ (auto-generated)
#set page(
width: ${config.page.wIn}in,
height: ${config.page.hIn}in,
margin: 0.5in,
fill: white
)
#set text(size: 11pt, font: "New Computer Modern Math")
// Header - matches worksheet header format
#grid(
columns: (1fr, 1fr),
align: (left, right),
text(size: 0.75em, weight: "bold")[${config.name}],
text(size: 0.65em)[${config.date}]
)
#v(0.15in)
#align(center)[
#text(size: 14pt, weight: "bold")[Answer Key]
]
#v(0.25in)
// Answers in 3 columns, grouped by worksheet page
#columns(3, gutter: 1.5em)[
#set par(leading: 0.8em)
${combinedAnswers}
]
${
qrCodeSvg
? `// QR code linking to shared worksheet with share code below
#place(bottom + left, dx: 0.1in, dy: -0.1in)[
#stack(dir: ttb, spacing: 2pt, align(center)[#image.decode("${qrCodeSvg.replace(/"/g, '\\"').replace(/\n/g, '')}", width: 0.5in, height: 0.5in)], align(center)[#text(size: 7pt, font: "Courier New")[${shareCode || 'PREVIEW'}]])
]`
: ''
}
`
return [pageTypst]
}
/**
* Extract share code from a share URL
* @param shareUrl - Full URL like "https://abaci.one/worksheets/shared/k7mP2qR"
* @returns The share code (e.g., "k7mP2qR") or undefined
*/
function extractShareCode(shareUrl?: string): string | undefined {
if (!shareUrl) return undefined
// URL format: https://abaci.one/worksheets/shared/{shareCode}
const match = shareUrl.match(/\/worksheets\/shared\/([a-zA-Z0-9]+)$/)
return match ? match[1] : undefined
}
/**
* Generate Typst source code for the worksheet (returns array of page sources)
* @param shareUrl - Optional share URL for QR code embedding (required if config.includeQRCode is true)
*/
export async function generateTypstSource(
config: WorksheetConfig,
problems: WorksheetProblem[],
shareUrl?: string
): Promise<string[]> {
// Use the problemsPerPage directly from config (primary state)
const problemsPerPage = config.problemsPerPage
const rowsPerPage = problemsPerPage / config.cols
// Generate QR code if enabled and shareUrl is provided
let qrCodeSvg: string | undefined
let shareCode: string | undefined
if (config.includeQRCode && shareUrl) {
qrCodeSvg = await generateQRCodeSVG(shareUrl, 200) // Higher res for print quality
shareCode = extractShareCode(shareUrl)
}
// Chunk problems into discrete pages
const pages = chunkProblems(problems, problemsPerPage)
// Generate separate Typst source for each page
return pages.map((pageProblems, pageIndex) =>
generatePageTypst(config, pageProblems, pageIndex * problemsPerPage, rowsPerPage)
// Generate separate Typst source for each worksheet page
const worksheetPages = pages.map((pageProblems, pageIndex) =>
generatePageTypst(
config,
pageProblems,
pageIndex * problemsPerPage,
rowsPerPage,
qrCodeSvg,
shareCode
)
)
// If answer key is requested, append answer key page(s)
if (config.includeAnswerKey) {
// Check if problem numbers are shown (from displayRules)
const showProblemNumbers = config.displayRules?.problemNumbers !== 'never'
const answerKeyPages = generateAnswerKeyTypst(
config,
problems,
showProblemNumbers,
qrCodeSvg,
shareCode
)
return [...worksheetPages, ...answerKeyPages]
}
return worksheetPages
}

View File

@ -233,6 +233,12 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
fontSize,
seed,
prngAlgorithm: formState.prngAlgorithm ?? 'mulberry32',
// V4: Include answer key pages at end of PDF
includeAnswerKey: formState.includeAnswerKey ?? false,
// V4: Include QR code linking to shared worksheet on each page
includeQRCode: formState.includeQRCode ?? false,
}
// Build mode-specific config