diff --git a/apps/web/src/app/create/worksheets/typstGenerator.ts b/apps/web/src/app/create/worksheets/typstGenerator.ts new file mode 100644 index 00000000..a32e6e55 --- /dev/null +++ b/apps/web/src/app/create/worksheets/typstGenerator.ts @@ -0,0 +1,289 @@ +// Typst document generator for addition worksheets + +import type { WorksheetProblem, WorksheetConfig } from '@/app/create/worksheets/types' +import { + generateTypstHelpers, + generateProblemStackFunction, + generateSubtractionProblemStackFunction, + generatePlaceValueColors, +} from './typstHelpers' +import { analyzeProblem, analyzeSubtractionProblem } from './problemAnalysis' +import { resolveDisplayForProblem } from './displayRules' + +/** + * Chunk array into pages of specified size + */ +function chunkProblems(problems: WorksheetProblem[], pageSize: number): WorksheetProblem[][] { + const pages: WorksheetProblem[][] = [] + for (let i = 0; i < problems.length; i += pageSize) { + pages.push(problems.slice(i, i + pageSize)) + } + return pages +} + +/** + * Calculate maximum number of digits in any problem on this page + * Returns max digits across all operands (handles both addition and subtraction) + */ +function calculateMaxDigits(problems: WorksheetProblem[]): number { + let maxDigits = 1 + for (const problem of problems) { + if (problem.operator === 'add') { + const digitsA = problem.a.toString().length + const digitsB = problem.b.toString().length + const maxProblemDigits = Math.max(digitsA, digitsB) + maxDigits = Math.max(maxDigits, maxProblemDigits) + } else { + // Subtraction + const digitsMinuend = problem.minuend.toString().length + const digitsSubtrahend = problem.subtrahend.toString().length + const maxProblemDigits = Math.max(digitsMinuend, digitsSubtrahend) + maxDigits = Math.max(maxDigits, maxProblemDigits) + } + } + return maxDigits +} + +/** + * Generate Typst source code for a single page + */ +function generatePageTypst( + config: WorksheetConfig, + pageProblems: WorksheetProblem[], + problemOffset: number, + rowsPerPage: number +): string { + // Calculate maximum digits for proper column layout + const maxDigits = calculateMaxDigits(pageProblems) + + // Enrich problems with display options based on mode + const enrichedProblems = pageProblems.map((p, index) => { + if (config.mode === 'smart' || config.mode === 'mastery') { + // Smart & Mastery modes: Per-problem conditional display based on problem complexity + // Both modes use displayRules for conditional scaffolding + const meta = + p.operator === 'add' + ? analyzeProblem(p.a, p.b) + : analyzeSubtractionProblem(p.minuend, p.subtrahend) + + // Choose display rules based on operator (for mastery+mixed mode) + let rulesForProblem = config.displayRules as any + + if (config.mode === 'mastery') { + const masteryConfig = config as any + // If we have operator-specific rules (mastery+mixed), use them + if (p.operator === 'add' && masteryConfig.additionDisplayRules) { + rulesForProblem = masteryConfig.additionDisplayRules + console.log( + `[TYPST PROBLEM ${index}] Using additionDisplayRules for ${p.a} + ${p.b}`, + rulesForProblem + ) + } else if (p.operator === 'sub' && masteryConfig.subtractionDisplayRules) { + rulesForProblem = masteryConfig.subtractionDisplayRules + console.log( + `[TYPST PROBLEM ${index}] Using subtractionDisplayRules for ${p.minuend} - ${p.subtrahend}`, + rulesForProblem + ) + } + } + + const displayOptions = resolveDisplayForProblem(rulesForProblem, meta) + + if (p.operator === 'sub') { + console.log(`[TYPST PROBLEM ${index}] Subtraction resolved display:`, { + problem: `${p.minuend} - ${p.subtrahend}`, + meta, + rulesUsed: rulesForProblem, + resolved: displayOptions, + }) + } + + return { + ...p, + ...displayOptions, // Now includes showBorrowNotation and showBorrowingHints from resolved rules + } + } else { + // Manual mode: Per-problem conditional display using displayRules (same as Smart/Mastery) + const meta = + p.operator === 'add' + ? analyzeProblem(p.a, p.b) + : analyzeSubtractionProblem(p.minuend, p.subtrahend) + + const displayOptions = resolveDisplayForProblem(config.displayRules as any, meta) + + return { + ...p, + ...displayOptions, + } + } + }) + + // Generate Typst problem data with per-problem display flags + const problemsTypst = enrichedProblems + .map((p) => { + if (p.operator === 'add') { + return ` (operator: "+", a: ${p.a}, b: ${p.b}, showCarryBoxes: ${p.showCarryBoxes}, showAnswerBoxes: ${p.showAnswerBoxes}, showPlaceValueColors: ${p.showPlaceValueColors}, showTenFrames: ${p.showTenFrames}, showProblemNumbers: ${p.showProblemNumbers}, showCellBorder: ${p.showCellBorder}, showBorrowNotation: ${p.showBorrowNotation}, showBorrowingHints: ${p.showBorrowingHints}),` + } else { + return ` (operator: "−", minuend: ${p.minuend}, subtrahend: ${p.subtrahend}, showCarryBoxes: ${p.showCarryBoxes}, showAnswerBoxes: ${p.showAnswerBoxes}, showPlaceValueColors: ${p.showPlaceValueColors}, showTenFrames: ${p.showTenFrames}, showProblemNumbers: ${p.showProblemNumbers}, showCellBorder: ${p.showCellBorder}, showBorrowNotation: ${p.showBorrowNotation}, showBorrowingHints: ${p.showBorrowingHints}),` + } + }) + .join('\n') + + // DEBUG: Show Typst problem data for first problem + console.log('[TYPST DEBUG] First problem Typst data:', problemsTypst.split('\n')[0]) + + // Calculate actual number of rows on this page + const actualRows = Math.ceil(pageProblems.length / config.cols) + + // Use smaller margins to maximize space + const margin = 0.4 + const contentWidth = config.page.wIn - margin * 2 + const contentHeight = config.page.hIn - margin * 2 + + // Calculate grid spacing based on ACTUAL rows on this page + const headerHeight = 0.35 // inches for header + const availableHeight = contentHeight - headerHeight + const problemBoxHeight = availableHeight / actualRows + const problemBoxWidth = contentWidth / config.cols + + // Calculate cell size assuming MAXIMUM possible embellishments + // Check if ANY problem on this page might show ten-frames + const anyProblemMayShowTenFrames = enrichedProblems.some((p) => p.showTenFrames) + + // Calculate cell size to fill the entire problem box + // Base vertical stack: carry row + addend1 + addend2 + line + answer = 5 rows + // With ten-frames: add 0.8 * cellSize row + // Total with ten-frames: ~5.8 rows + // + // Horizontal constraint: maxDigits columns + 1 for + sign + // Cell size must fit: (maxDigits + 1) * cellSize <= problemBoxWidth + const maxCellSizeForWidth = problemBoxWidth / (maxDigits + 1) + const maxCellSizeForHeight = anyProblemMayShowTenFrames + ? problemBoxHeight / 6.0 + : problemBoxHeight / 4.5 + + // Use the smaller of width/height constraints + const cellSize = Math.min(maxCellSizeForWidth, maxCellSizeForHeight) + + return String.raw` +// addition-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") + +// Single non-breakable block to ensure one page +#block(breakable: false)[ + +#let heavy-stroke = 0.8pt +#let show-ten-frames-for-all = ${ + config.mode === 'manual' + ? config.showTenFramesForAll + ? 'true' + : 'false' + : config.displayRules.tenFrames === 'always' + ? 'true' + : 'false' + } + +${generatePlaceValueColors()} + +${generateTypstHelpers(cellSize)} + +${generateProblemStackFunction(cellSize, maxDigits)} + +${generateSubtractionProblemStackFunction(cellSize, maxDigits)} + +#let problem-box(problem, index) = { + // Extract per-problem display flags + let grid-stroke = if problem.showCellBorder { (thickness: 1pt, dash: "dashed", paint: gray.darken(20%)) } else { none } + + box( + inset: 0pt, + width: ${problemBoxWidth}in, + height: ${problemBoxHeight}in, + stroke: grid-stroke + )[ + #align(center + horizon)[ + #if problem.operator == "+" { + problem-stack( + problem.a, problem.b, index, + problem.showCarryBoxes, + problem.showAnswerBoxes, + problem.showPlaceValueColors, + problem.showTenFrames, + problem.showProblemNumbers + ) + } else { + subtraction-problem-stack( + problem.minuend, problem.subtrahend, index, + problem.showBorrowNotation, // show-borrows (whether to show borrow boxes) + problem.showAnswerBoxes, + problem.showPlaceValueColors, + problem.showTenFrames, + problem.showProblemNumbers, + problem.showBorrowNotation, // show-borrow-notation (scratch work boxes in minuend) + problem.showBorrowingHints // show-borrowing-hints (hints with arrows) + ) + } + ] + ] +} + +#let problems = ( +${problemsTypst} +) + +// Compact header - name on left, date on right +#grid( + columns: (1fr, 1fr), + align: (left, right), + text(size: 0.75em, weight: "bold")[${config.name}], + text(size: 0.65em)[${config.date}] +) +#v(${headerHeight}in - 0.25in) + +// Problem grid - exactly ${actualRows} rows × ${config.cols} columns +#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() { + (problem-box(problems.at(idx), ${problemOffset} + idx),) + } else { + (box(width: ${problemBoxWidth}in, height: ${problemBoxHeight}in),) + } + } + } +) + +] // End of constrained block +` +} + +/** + * Generate Typst source code for the worksheet (returns array of page sources) + */ +export function generateTypstSource( + config: WorksheetConfig, + problems: WorksheetProblem[] +): string[] { + // Use the problemsPerPage directly from config (primary state) + const problemsPerPage = config.problemsPerPage + const rowsPerPage = problemsPerPage / config.cols + + // 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) + ) +} diff --git a/apps/web/src/app/create/worksheets/typstHelpers/subtraction/borrowBoxes.ts b/apps/web/src/app/create/worksheets/typstHelpers/subtraction/borrowBoxes.ts new file mode 100644 index 00000000..9796dd20 --- /dev/null +++ b/apps/web/src/app/create/worksheets/typstHelpers/subtraction/borrowBoxes.ts @@ -0,0 +1,104 @@ +// Borrow boxes row rendering for subtraction problems +// This row shows where borrows occur (FROM higher place TO lower place) + +import type { CellDimensions } from '../shared/types' +import { TYPST_CONSTANTS } from '../shared/types' + +/** + * Generate Typst code for the borrow boxes row + * + * Borrow boxes indicate where a borrow operation occurs: + * - Source: The place value we're borrowing FROM (giving) + * - Destination: The place value we're borrowing TO (receiving) + * + * Design decision: Borrow boxes NEVER use place value colors (always stroke-only) + * to avoid arrow layering issues where arrows get covered by adjacent cell backgrounds. + * + * @param cellDimensions - Cell sizing information + * @returns Typst code for borrow boxes row + */ +export function generateBorrowBoxesRow(cellDimensions: CellDimensions): string { + const { cellSize, cellSizeIn, cellSizePt } = cellDimensions + + const hintTextSize = (cellSizePt * TYPST_CONSTANTS.HINT_TEXT_SIZE_FACTOR).toFixed(1) + const arrowheadSize = (cellSizePt * TYPST_CONSTANTS.ARROWHEAD_SIZE_FACTOR).toFixed(1) + + const arrowStartDx = (cellSize * TYPST_CONSTANTS.ARROW_START_DX).toFixed(2) + const arrowStartDy = (cellSize * TYPST_CONSTANTS.ARROW_START_DY).toFixed(2) + const arrowEndX = (cellSize * 0.24).toFixed(2) + const arrowEndY = (cellSize * 0.7).toFixed(2) + const arrowControlX = (cellSize * 0.11).toFixed(2) + const arrowControlY = (cellSize * -0.5).toFixed(2) + const arrowheadDx = (cellSize * TYPST_CONSTANTS.ARROWHEAD_DX).toFixed(2) + const arrowheadDy = (cellSize * TYPST_CONSTANTS.ARROWHEAD_DY).toFixed(2) + + return String.raw` + // Borrow boxes row (shows borrows FROM higher place TO lower place) + [], // Empty cell for operator column + ..for i in range(0, grid-digits).rev() { + // Check if we need to borrow FROM this place (to give to i-1) + // We borrow when m-digit < s-digit at position i-1 + let shows-borrow = show-borrows and i > 0 and (m-digits.at(i - 1) < s-digits.at(i - 1)) + + if shows-borrow { + // This place borrowed FROM to give to lower place + let source-color = place-colors.at(i) // This place (giving) + let dest-color = place-colors.at(i - 1) // Lower place (receiving) + + // When showing hints, determine what to display based on cascading + if show-borrowing-hints and i <= m-highest { + // Determine the actual value to show in the hint + // For cascading: if this digit is 0, it received 10 from left and gives 1 to right + // So it shows "10 - 1". Otherwise it shows "original - 1" + let original-digit = m-digits.at(i) + + // Check if this is part of a cascade (is it 0 and needs to borrow?) + let is-cascade = original-digit == 0 + + // The display value is either the original digit or 10 (if cascading) + let display-value = if is-cascade { 10 } else { original-digit } + + // Borrow boxes never use place value colors (always stroke-only) + // to avoid arrow layering issues + (box(width: ${cellSizeIn}, height: ${cellSizeIn}, stroke: ${TYPST_CONSTANTS.CELL_STROKE_WIDTH}pt)[ + #place( + top + center, + dy: 2pt, + text(size: ${hintTextSize}pt, fill: gray.darken(30%), weight: "bold")[ + #display-value#h(0.1em)−#h(0.1em)1 + ] + ) + // Draw curved line using Typst bezier with control point + #place( + top + left, + dx: ${arrowStartDx}in, + dy: ${arrowStartDy}in, + path( + stroke: (paint: gray.darken(30%), thickness: ${TYPST_CONSTANTS.ARROW_STROKE_WIDTH}pt), + // Start vertex (near the "1" in borrow box) + (0pt, 0pt), + // End vertex adjusted up and left to align with arrowhead (vertex, relative-control-point) + ((${arrowEndX}in, ${arrowEndY}in), (${arrowControlX}in, ${arrowControlY}in)), + ) + ) + // Arrowhead pointing down at the top edge of borrowed 10s box + #place( + top + left, + dx: ${arrowheadDx}in, + dy: ${arrowheadDy}in, + text(size: ${arrowheadSize}pt, fill: gray.darken(30%))[▼] + ) + ],) + } else { + // No hints - just show stroke box + (box(width: ${cellSizeIn}, height: ${cellSizeIn}, stroke: ${TYPST_CONSTANTS.CELL_STROKE_WIDTH}pt)[],) + } + } else { + // No borrow from this position + (box(width: ${cellSizeIn}, height: ${cellSizeIn})[ + #v(${cellSizeIn}) + ],) + } + }, +` +}