Fix fraction answer key QR placement syntax

This commit is contained in:
Thomas Hallock 2025-12-11 04:32:59 -06:00
parent 9159608dcd
commit aca08b1aec
18 changed files with 395 additions and 62 deletions

View File

@ -140,7 +140,7 @@ async function fetchExample(options: {
showTenFrames: boolean showTenFrames: boolean
showTenFramesForAll: boolean showTenFramesForAll: boolean
showBorrowNotation: boolean showBorrowNotation: boolean
operator: 'addition' | 'subtraction' | 'mixed' operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'
addend1?: number addend1?: number
addend2?: number addend2?: number
minuend?: number minuend?: number

View File

@ -25,7 +25,7 @@ interface OrientationPanelProps {
// Config for problem space validation // Config for problem space validation
digitRange?: { min: number; max: number } digitRange?: { min: number; max: number }
pAnyStart?: number pAnyStart?: number
operator?: 'addition' | 'subtraction' | 'mixed' operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
mode?: 'custom' | 'mastery' mode?: 'custom' | 'mastery'
// Layout options // Layout options
problemNumbers?: 'always' | 'never' problemNumbers?: 'always' | 'never'

View File

@ -10,7 +10,7 @@ import type { WorksheetFormState } from '@/app/create/worksheets/types'
export interface WorksheetConfigContextValue { export interface WorksheetConfigContextValue {
formState: WorksheetFormState formState: WorksheetFormState
onChange: (updates: Partial<WorksheetFormState>) => void onChange: (updates: Partial<WorksheetFormState>) => void
operator: 'addition' | 'subtraction' | 'mixed' operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'
isReadOnly?: boolean isReadOnly?: boolean
} }

View File

@ -1,7 +1,7 @@
import { css } from '@styled/css' import { css } from '@styled/css'
export interface OperatorIconProps { export interface OperatorIconProps {
operator: 'addition' | 'subtraction' | 'mixed' operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'
size?: 'sm' | 'md' | 'lg' | 'xl' size?: 'sm' | 'md' | 'lg' | 'xl'
isDark?: boolean isDark?: boolean
color?: 'gray' | 'green' color?: 'gray' | 'green'
@ -15,8 +15,9 @@ const sizeMap = {
xl: 'xl', xl: 'xl',
} as const } as const
function getOperatorSymbol(operator: 'addition' | 'subtraction' | 'mixed'): string { function getOperatorSymbol(operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'): string {
if (operator === 'mixed') return '±' if (operator === 'mixed') return '±'
if (operator === 'fractions') return '¾'
if (operator === 'subtraction') return '' if (operator === 'subtraction') return ''
return '+' return '+'
} }

View File

@ -2,8 +2,8 @@ import { css } from '@styled/css'
import { OperatorIcon } from './OperatorIcon' import { OperatorIcon } from './OperatorIcon'
export interface OperatorSectionProps { export interface OperatorSectionProps {
operator: 'addition' | 'subtraction' | 'mixed' | undefined operator: 'addition' | 'subtraction' | 'mixed' | 'fractions' | undefined
onChange: (operator: 'addition' | 'subtraction' | 'mixed') => void onChange: (operator: 'addition' | 'subtraction' | 'mixed' | 'fractions') => void
isDark?: boolean isDark?: boolean
} }
@ -11,8 +11,13 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
// Derive checkbox states from operator value // Derive checkbox states from operator value
const additionChecked = operator === 'addition' || operator === 'mixed' || !operator const additionChecked = operator === 'addition' || operator === 'mixed' || !operator
const subtractionChecked = operator === 'subtraction' || operator === 'mixed' const subtractionChecked = operator === 'subtraction' || operator === 'mixed'
const fractionsChecked = operator === 'fractions'
const handleAdditionChange = (checked: boolean) => { const handleAdditionChange = (checked: boolean) => {
if (fractionsChecked) {
onChange(checked ? 'addition' : 'subtraction')
return
}
if (!checked && !subtractionChecked) { if (!checked && !subtractionChecked) {
// Can't uncheck if it's the only one checked // Can't uncheck if it's the only one checked
return return
@ -27,6 +32,10 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
} }
const handleSubtractionChange = (checked: boolean) => { const handleSubtractionChange = (checked: boolean) => {
if (fractionsChecked) {
onChange(checked ? 'subtraction' : 'addition')
return
}
if (!checked && !additionChecked) { if (!checked && !additionChecked) {
// Can't uncheck if it's the only one checked // Can't uncheck if it's the only one checked
return return
@ -187,6 +196,69 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
</span> </span>
</div> </div>
</label> </label>
{/* Fractions Selector */}
<label
data-action="toggle-fractions"
className={css({
display: 'flex',
alignItems: 'center',
gap: '3',
cursor: 'pointer',
px: '3',
py: '2.5',
rounded: 'lg',
border: '2px solid',
transition: 'all 0.2s',
bg: fractionsChecked
? isDark
? 'brand.900'
: 'brand.50'
: isDark
? 'gray.700'
: 'white',
borderColor: fractionsChecked ? 'brand.500' : isDark ? 'gray.600' : 'gray.300',
_hover: {
borderColor: 'brand.400',
},
})}
>
<input
type="radio"
checked={fractionsChecked}
onChange={(e) => onChange(e.target.checked ? 'fractions' : 'addition')}
className={css({
width: '5',
height: '5',
cursor: 'pointer',
accentColor: 'brand.600',
flexShrink: 0,
})}
/>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '2',
flex: 1,
minWidth: 0,
})}
>
<OperatorIcon operator="fractions" isDark={isDark} />
<span
className={css({
fontSize: 'sm',
fontWeight: 'semibold',
color: isDark ? 'gray.200' : 'gray.700',
'@media (max-width: 200px)': {
fontSize: 'xs',
},
})}
>
Mixed denominator fractions
</span>
</div>
</label>
</div> </div>
<p <p
@ -196,11 +268,13 @@ export function OperatorSection({ operator, onChange, isDark = false }: Operator
lineHeight: '1.5', lineHeight: '1.5',
})} })}
> >
{additionChecked && subtractionChecked {fractionsChecked
? 'Problems will randomly use addition or subtraction' ? 'All problems will be mixed-denominator fraction addition'
: subtractionChecked : additionChecked && subtractionChecked
? 'All problems will be subtraction' ? 'Problems will randomly use addition or subtraction'
: 'All problems will be addition'} : subtractionChecked
? 'All problems will be subtraction'
: 'All problems will be addition'}
</p> </p>
</div> </div>
) )

View File

@ -102,7 +102,7 @@ export function RuleThermometer({
const isAutoResolved = !isMixedMode && isAutoSelected && resolvedValue === option.value const isAutoResolved = !isMixedMode && isAutoSelected && resolvedValue === option.value
// Determine which operator to show // Determine which operator to show
let operatorToShow: 'addition' | 'subtraction' | 'mixed' | null = null let operatorToShow: 'addition' | 'subtraction' | 'mixed' | 'fractions' | null = null
if (isAutoResolvedMixed) { if (isAutoResolvedMixed) {
if (additionDefersHere && subtractionDefersHere) { if (additionDefersHere && subtractionDefersHere) {
operatorToShow = 'mixed' // Both defer here operatorToShow = 'mixed' // Both defer here

View File

@ -9,7 +9,7 @@ import { css } from '@styled/css'
*/ */
export function getScaffoldingSummary( export function getScaffoldingSummary(
displayRules: any, displayRules: any,
operator?: 'addition' | 'subtraction' | 'mixed' operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
): React.ReactNode { ): React.ReactNode {
console.log('[getScaffoldingSummary] displayRules:', displayRules, 'operator:', operator) console.log('[getScaffoldingSummary] displayRules:', displayRules, 'operator:', operator)

View File

@ -8,7 +8,7 @@ import type { DisplayRules } from '../../displayRules'
interface ProblemPreviewProps { interface ProblemPreviewProps {
displayRules: DisplayRules displayRules: DisplayRules
resolvedDisplayRules?: DisplayRules resolvedDisplayRules?: DisplayRules
operator?: 'addition' | 'subtraction' | 'mixed' operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
digitRange?: { min: number; max: number } digitRange?: { min: number; max: number }
className?: string className?: string
} }

View File

@ -9,7 +9,7 @@ import { ProblemPreview } from './ProblemPreview'
export interface Tab { export interface Tab {
id: string id: string
label: string label: string
icon: string | ((operator?: 'addition' | 'subtraction' | 'mixed') => string) | 'preview' icon: string | ((operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions') => string) | 'preview'
subtitle?: (props: { subtitle?: (props: {
mode?: 'custom' | 'manual' | 'mastery' mode?: 'custom' | 'manual' | 'mastery'
difficultyProfile?: string difficultyProfile?: string
@ -20,7 +20,7 @@ export interface Tab {
pages?: number pages?: number
displayRules?: DisplayRules displayRules?: DisplayRules
resolvedDisplayRules?: DisplayRules resolvedDisplayRules?: DisplayRules
operator?: 'addition' | 'subtraction' | 'mixed' operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
}) => string | null }) => string | null
} }
@ -30,6 +30,7 @@ export const TABS: Tab[] = [
label: 'Operator', label: 'Operator',
icon: (operator) => { icon: (operator) => {
if (operator === 'mixed') return '±' if (operator === 'mixed') return '±'
if (operator === 'fractions') return '¾'
if (operator === 'subtraction') return '' if (operator === 'subtraction') return ''
return '+' return '+'
}, },
@ -115,7 +116,7 @@ export const TABS: Tab[] = [
interface TabNavigationProps { interface TabNavigationProps {
activeTab: string activeTab: string
onChange: (tabId: string) => void onChange: (tabId: string) => void
operator?: 'addition' | 'subtraction' | 'mixed' operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
mode?: 'custom' | 'manual' | 'mastery' mode?: 'custom' | 'manual' | 'mastery'
difficultyProfile?: string difficultyProfile?: string
interpolate?: boolean interpolate?: boolean

View File

@ -343,7 +343,7 @@ const additionConfigV4BaseSchema = z.object({
}), }),
// V4: Operator selection (addition, subtraction, or mixed) // V4: Operator selection (addition, subtraction, or mixed)
operator: z.enum(['addition', 'subtraction', 'mixed']).default('addition'), operator: z.enum(['addition', 'subtraction', 'mixed', 'fractions']).default('addition'),
// Regrouping probabilities (shared between modes) // Regrouping probabilities (shared between modes)
pAnyStart: z.number().min(0).max(1), pAnyStart: z.number().min(0).max(1),

View File

@ -243,7 +243,7 @@ function describeScaffoldingChange(
fromRules: DisplayRules, fromRules: DisplayRules,
toRules: DisplayRules, toRules: DisplayRules,
direction: 'added' | 'reduced', direction: 'added' | 'reduced',
operator?: 'addition' | 'subtraction' | 'mixed' operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
): string { ): string {
const changes: string[] = [] const changes: string[] = []
@ -786,7 +786,7 @@ export function makeHarder(
displayRules: DisplayRules displayRules: DisplayRules
}, },
mode: DifficultyMode = 'both', mode: DifficultyMode = 'both',
operator?: 'addition' | 'subtraction' | 'mixed' operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
): { ): {
pAnyStart: number pAnyStart: number
pAllStart: number pAllStart: number
@ -989,7 +989,7 @@ export function makeEasier(
displayRules: DisplayRules displayRules: DisplayRules
}, },
mode: DifficultyMode = 'both', mode: DifficultyMode = 'both',
operator?: 'addition' | 'subtraction' | 'mixed' operator?: 'addition' | 'subtraction' | 'mixed' | 'fractions'
): { ): {
pAnyStart: number pAnyStart: number
pAllStart: number pAllStart: number

View File

@ -3,6 +3,7 @@
import { execSync } from 'child_process' import { execSync } from 'child_process'
import type { WorksheetFormState } from '@/app/create/worksheets/types' import type { WorksheetFormState } from '@/app/create/worksheets/types'
import { import {
generateFractionProblems,
generateMasteryMixedProblems, generateMasteryMixedProblems,
generateMixedProblems, generateMixedProblems,
generateProblems, generateProblems,
@ -127,33 +128,36 @@ export async function generateWorksheetPreview(
) )
} else { } else {
// Standard problem generation // Standard problem generation
problems = if (operator === 'addition') {
operator === 'addition' problems = generateProblems(
? generateProblems( validatedConfig.total,
validatedConfig.total, validatedConfig.pAnyStart,
validatedConfig.pAnyStart, validatedConfig.pAllStart,
validatedConfig.pAllStart, validatedConfig.interpolate,
validatedConfig.interpolate, validatedConfig.seed,
validatedConfig.seed, validatedConfig.digitRange
validatedConfig.digitRange )
) } else if (operator === 'subtraction') {
: operator === 'subtraction' problems = generateSubtractionProblems(
? generateSubtractionProblems( validatedConfig.total,
validatedConfig.total, validatedConfig.digitRange,
validatedConfig.digitRange, validatedConfig.pAnyStart,
validatedConfig.pAnyStart, validatedConfig.pAllStart,
validatedConfig.pAllStart, validatedConfig.interpolate,
validatedConfig.interpolate, validatedConfig.seed
validatedConfig.seed )
) } else if (operator === 'fractions') {
: generateMixedProblems( problems = generateFractionProblems(validatedConfig.total, validatedConfig.seed)
validatedConfig.total, } else {
validatedConfig.digitRange, problems = generateMixedProblems(
validatedConfig.pAnyStart, validatedConfig.total,
validatedConfig.pAllStart, validatedConfig.digitRange,
validatedConfig.interpolate, validatedConfig.pAnyStart,
validatedConfig.seed validatedConfig.pAllStart,
) validatedConfig.interpolate,
validatedConfig.seed
)
}
} }
console.log(`[PREVIEW] Step 2: ✓ Generated ${problems.length} problems`) console.log(`[PREVIEW] Step 2: ✓ Generated ${problems.length} problems`)
@ -328,6 +332,8 @@ export async function generateSinglePage(
validatedConfig.interpolate, validatedConfig.interpolate,
validatedConfig.seed validatedConfig.seed
) )
} else if (operator === 'fractions') {
problems = generateFractionProblems(validatedConfig.total, validatedConfig.seed)
} else if (operator === 'subtraction') { } else if (operator === 'subtraction') {
problems = generateSubtractionProblems( problems = generateSubtractionProblems(
validatedConfig.total, validatedConfig.total,

View File

@ -1269,3 +1269,45 @@ export function generateMixedProblems(
return problems return problems
} }
function gcd(a: number, b: number): number {
return b === 0 ? Math.abs(a) : gcd(b, a % b)
}
function simplifyFraction(numerator: number, denominator: number): { numerator: number; denominator: number } {
const divisor = gcd(numerator, denominator)
return { numerator: numerator / divisor, denominator: denominator / divisor }
}
/**
* Generate addition problems with mixed denominators (fractions)
*/
export function generateFractionProblems(total: number, seed: number): FractionProblem[] {
const rand = createPRNG(seed)
const problems: FractionProblem[] = []
for (let i = 0; i < total; i++) {
let leftDen = randint(2, 12, rand)
let rightDen = randint(2, 12, rand)
// Ensure mixed denominators
if (rightDen === leftDen) {
rightDen = ((rightDen % 12) + 1) + 1 // rotate to a different denominator between 2-13
if (rightDen > 12) rightDen = 2
}
const leftNum = randint(1, leftDen - 1, rand)
const rightNum = randint(1, rightDen - 1, rand)
const left = simplifyFraction(leftNum, leftDen)
const right = simplifyFraction(rightNum, rightDen)
problems.push({
left,
right,
operator: 'fraction',
})
}
return problems
}

View File

@ -116,7 +116,7 @@ export type WorksheetFormState = Partial<Omit<AdditionConfigV4Custom, 'version'>
/** /**
* Worksheet operator type * Worksheet operator type
*/ */
export type WorksheetOperator = 'addition' | 'subtraction' | 'mixed' export type WorksheetOperator = 'addition' | 'subtraction' | 'mixed' | 'fractions'
/** /**
* A single addition problem * A single addition problem
@ -136,10 +136,19 @@ export interface SubtractionProblem {
operator: 'sub' operator: 'sub'
} }
/**
* A single fraction addition problem with mixed denominators
*/
export interface FractionProblem {
left: { numerator: number; denominator: number }
right: { numerator: number; denominator: number }
operator: 'fraction'
}
/** /**
* Unified problem type (addition or subtraction) * Unified problem type (addition or subtraction)
*/ */
export type WorksheetProblem = AdditionProblem | SubtractionProblem export type WorksheetProblem = AdditionProblem | SubtractionProblem | FractionProblem
/** /**
* Validation result * Validation result

View File

@ -1,6 +1,6 @@
// Typst document generator for addition worksheets // Typst document generator for addition worksheets
import type { WorksheetConfig, WorksheetProblem } from '@/app/create/worksheets/types' import type { FractionProblem, WorksheetConfig, WorksheetProblem } from '@/app/create/worksheets/types'
import { resolveDisplayForProblem } from './displayRules' import { resolveDisplayForProblem } from './displayRules'
import { analyzeProblem, analyzeSubtractionProblem } from './problemAnalysis' import { analyzeProblem, analyzeSubtractionProblem } from './problemAnalysis'
import { generateQRCodeSVG } from './qrCodeGenerator' import { generateQRCodeSVG } from './qrCodeGenerator'
@ -116,6 +116,21 @@ function chunkProblems(problems: WorksheetProblem[], pageSize: number): Workshee
return pages return pages
} }
function gcdInt(a: number, b: number): number {
return b === 0 ? Math.abs(a) : gcdInt(b, a % b)
}
function addFractions(problem: FractionProblem): { numerator: number; denominator: number } {
const lcmDenominator =
(problem.left.denominator * problem.right.denominator) /
gcdInt(problem.left.denominator, problem.right.denominator)
const numerator =
problem.left.numerator * (lcmDenominator / problem.left.denominator) +
problem.right.numerator * (lcmDenominator / problem.right.denominator)
const divisor = gcdInt(numerator, lcmDenominator)
return { numerator: numerator / divisor, denominator: lcmDenominator / divisor }
}
/** /**
* Calculate maximum number of digits in any problem on this page * Calculate maximum number of digits in any problem on this page
* Returns max digits across all operands (handles both addition and subtraction) * Returns max digits across all operands (handles both addition and subtraction)
@ -448,9 +463,11 @@ ${(() => {
function calculateAnswer(problem: WorksheetProblem): number { function calculateAnswer(problem: WorksheetProblem): number {
if (problem.operator === 'add') { if (problem.operator === 'add') {
return problem.a + problem.b return problem.a + problem.b
} else { } else if (problem.operator === 'sub') {
return problem.minuend - problem.subtrahend return problem.minuend - problem.subtrahend
} }
const fractionAnswer = addFractions(problem as FractionProblem)
return fractionAnswer.numerator / fractionAnswer.denominator
} }
/** /**
@ -462,14 +479,164 @@ function formatProblemWithAnswer(
index: number, index: number,
showNumber: boolean showNumber: boolean
): string { ): string {
const answer = calculateAnswer(problem) const prefix = showNumber ? `*${index + 1}.* ` : ''
if (problem.operator === 'add') { if (problem.operator === 'add') {
const prefix = showNumber ? `*${index + 1}.* ` : '' return `${prefix}${problem.a} + ${problem.b} = *${calculateAnswer(problem)}*`
return `${prefix}${problem.a} + ${problem.b} = *${answer}*`
} else {
const prefix = showNumber ? `*${index + 1}.* ` : ''
return `${prefix}${problem.minuend} ${problem.subtrahend} = *${answer}*`
} }
if (problem.operator === 'sub') {
return `${prefix}${problem.minuend} ${problem.subtrahend} = *${calculateAnswer(problem)}*`
}
const answer = addFractions(problem as FractionProblem)
const left = (problem as FractionProblem).left
const right = (problem as FractionProblem).right
return `${prefix}${left.numerator}/${left.denominator} + ${right.numerator}/${right.denominator} = *${answer.numerator}/${answer.denominator}*`
}
function generateFractionPageTypst(
config: WorksheetConfig,
pageProblems: WorksheetProblem[],
problemOffset: number,
rowsPerPage: number,
qrCodeSvg?: string,
shareCode?: string,
domain?: string
): string {
const actualRows = Math.ceil(pageProblems.length / config.cols)
const margin = 0.4
const contentWidth = config.page.wIn - margin * 2
const contentHeight = config.page.hIn - margin * 2
const headerHeight = 0.35
const availableHeight = contentHeight - headerHeight
const problemBoxHeight = availableHeight / actualRows
const problemBoxWidth = contentWidth / config.cols
const problemsTypst = pageProblems
.map((p) => {
const fraction = p as FractionProblem
return ` (left_num: ${fraction.left.numerator}, left_den: ${fraction.left.denominator}, right_num: ${fraction.right.numerator}, right_den: ${fraction.right.denominator}),`
})
.join('\n')
const description = generateWorksheetDescription(config)
const brandDomain = domain || 'abaci.one'
const breadcrumb = 'Create Worksheets'
return String.raw`
// fraction-worksheet-page.typ (auto-generated)
#set page(
width: ${config.page.wIn}in,
height: ${config.page.hIn}in,
margin: ${margin}in,
fill: white
)
#set text(size: ${config.fontSize}pt, font: "New Computer Modern Math")
#let problems = (
${problemsTypst}
)
#block(breakable: false)[
#box(
width: 100%,
stroke: (bottom: 1pt + gray),
inset: (bottom: 2pt),
)[
#grid(
columns: (1fr, auto),
column-gutter: 0.1in,
align: (left + top, right + top),
[
#text(size: 0.85em, weight: "bold")[${description.title}] \\
#text(size: 0.6em, fill: gray.darken(20%))[${description.scaffolding}]
],
[
#stack(dir: ttb, spacing: 1pt, align(right)[
#text(size: 0.6em)[*Date:* ${config.date}]
], align(right)[
#text(size: 0.5em, fill: gray.darken(10%), weight: "medium")[${brandDomain}]
], align(right)[
#text(size: 0.4em, fill: gray)[${breadcrumb}]
])
]
)
]
#v(-0.25in)
#let fraction-box(problem, index) = {
box(
inset: (top: 0pt, bottom: -${(problemBoxHeight / 3).toFixed(3)}in, left: 0pt, right: 0pt),
width: ${problemBoxWidth}in,
height: ${problemBoxHeight}in,
stroke: (thickness: 1pt, dash: "dashed", paint: gray.darken(20%))
)[
#if index != none {
place(top + left, dx: 0.02in, dy: 0.02in)[
#text(size: ${(problemBoxHeight * 72 * 0.35).toFixed(1)}pt, weight: "bold", font: "New Computer Modern Math")[\##(index + 1).]
]
}
#align(center + horizon)[
#text(size: ${config.fontSize}pt)[#frac(problem.left_num, problem.left_den) + #frac(problem.right_num, problem.right_den) = ]
]
]
}
#grid(
columns: ${config.cols},
column-gutter: 0pt,
row-gutter: 0pt,
..for r in range(0, ${actualRows}) {
for c in range(0, ${config.cols}) {
let idx = r * ${config.cols} + c
if idx < problems.len() {
(fraction-box(problems.at(idx), ${problemOffset} + idx),)
} else {
(box(width: ${problemBoxWidth}in, height: ${problemBoxHeight}in),)
}
}
}
)
]
`
}
function generateFractionAnswerKeyTypst(
config: WorksheetConfig,
problems: WorksheetProblem[],
showProblemNumbers: boolean,
qrCodeSvg?: string,
shareCode?: string
): string[] {
const problemsPerPage = config.problemsPerPage
const worksheetPageCount = Math.ceil(problems.length / problemsPerPage)
const pages: string[] = []
for (let i = 0; i < worksheetPageCount; i++) {
const start = i * problemsPerPage
const pageProblems = problems.slice(start, start + problemsPerPage)
const answers = pageProblems
.map((problem, idx) => formatProblemWithAnswer(problem, start + idx, showProblemNumbers))
.join(' \\\n')
pages.push(String.raw`
#set page(width: ${config.page.wIn}in, height: ${config.page.hIn}in, margin: 0.6in)
#set text(size: ${config.fontSize}pt, font: "New Computer Modern Math")
#block(breakable: false)[
#text(size: 12pt, weight: "bold")[Answer Key - Page ${i + 1}] \\
${answers}
${
qrCodeSvg
? `\\
#place(bottom + left, dx: 0.1in, dy: -0.1in)[#stack(dir: ttb, spacing: 2pt, align(center)[#image(bytes("${qrCodeSvg.replace(/"/g, '\\"').replace(/\n/g, '')}"), format: "svg", width: 0.63in, height: 0.63in)], align(center)[#text(size: 7pt, font: "Courier New")[${shareCode || 'PREVIEW'}]])]`
: ''
}
]
`)
}
return pages
} }
/** /**
@ -622,6 +789,35 @@ export async function generateTypstSource(
// Chunk problems into discrete pages // Chunk problems into discrete pages
const pages = chunkProblems(problems, problemsPerPage) const pages = chunkProblems(problems, problemsPerPage)
const hasFractionProblems = problems.some((p) => p.operator === 'fraction')
if (hasFractionProblems) {
const worksheetPages = pages.map((pageProblems, pageIndex) =>
generateFractionPageTypst(
config,
pageProblems,
pageIndex * problemsPerPage,
rowsPerPage,
qrCodeSvg,
shareCode,
brandDomain
)
)
if (config.includeAnswerKey) {
const showProblemNumbers = true
const answerPages = generateFractionAnswerKeyTypst(
config,
problems,
showProblemNumbers,
qrCodeSvg,
shareCode
)
return [...worksheetPages, ...answerPages]
}
return worksheetPages
}
// Generate separate Typst source for each worksheet page // Generate separate Typst source for each worksheet page
const worksheetPages = pages.map((pageProblems, pageIndex) => const worksheetPages = pages.map((pageProblems, pageIndex) =>
generatePageTypst( generatePageTypst(

View File

@ -10,6 +10,7 @@ export const SETTING_ICONS = {
multiplication: '×', multiplication: '×',
division: '÷', division: '÷',
mixed: '±', mixed: '±',
fractions: '¾',
}, },
difficulty: { difficulty: {
smart: '🎯', smart: '🎯',

View File

@ -112,7 +112,7 @@ export function validateProblemSpace(
pages: number, pages: number,
digitRange: { min: number; max: number }, digitRange: { min: number; max: number },
pAnyStart: number, pAnyStart: number,
operator: 'addition' | 'subtraction' | 'mixed' operator: 'addition' | 'subtraction' | 'mixed' | 'fractions'
): ProblemSpaceValidation { ): ProblemSpaceValidation {
const requestedProblems = problemsPerPage * pages const requestedProblems = problemsPerPage * pages
const warnings: string[] = [] const warnings: string[] = []
@ -123,6 +123,9 @@ export function validateProblemSpace(
const addSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'addition') const addSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'addition')
const subSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'subtraction') const subSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, 'subtraction')
estimatedSpace = addSpace + subSpace estimatedSpace = addSpace + subSpace
} else if (operator === 'fractions') {
// Fractions have a very large combinatorial space when denominators are unconstrained
estimatedSpace = 1000000
} else { } else {
estimatedSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, operator) estimatedSpace = estimateUniqueProblemSpace(digitRange, pAnyStart, operator)
} }

View File

@ -167,8 +167,8 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
// V4: Validate operator (addition, subtraction, or mixed) // V4: Validate operator (addition, subtraction, or mixed)
const operator = formState.operator ?? 'addition' const operator = formState.operator ?? 'addition'
if (!['addition', 'subtraction', 'mixed'].includes(operator)) { if (!['addition', 'subtraction', 'mixed', 'fractions'].includes(operator)) {
errors.push('Operator must be "addition", "subtraction", or "mixed"') errors.push('Operator must be "addition", "subtraction", "mixed", or "fractions"')
} }
// Validate seed (must be positive integer) // Validate seed (must be positive integer)