# -*- eval: (add-hook 'after-save-hook (lambda () (save-excursion (org-babel-tangle) (org-export-to-file 'md "README.md"))) nil 'local); -*- #+title: Math Worksheet Generator #+author: Ian Martins #+email: ianxm@jhu.edu * Overview ** Description This is a math worksheet generator. The worksheets are randomly generated based on templates that define what kinds of problems to include along with the order and relative frequency that each type of problem should appear on the worksheet. ** Audience This could be useful for anyone that wants to provide math practice to someone else. It could be useful for a teacher, tutor, homeschooling parent, or any parent. ** Examples Here are some example worksheets generated by this tool: 1. [[file:examples/add-sub-1.pdf][arithmetic]] 2. [[file:examples/algebra-1.pdf][algebra]] They were generated using [[file:examples/example.org][this configuration]]. ** Requirements [[https://www.gnu.org/software/texinfo/manual/texinfo/html_node/Format-with-texi2dvi-or-texi2pdf.html][texi2pdf]] is required to generate the PDF worksheet. Without it you can still generate the table of problems and solutions. ** Usage *** Starting Mathsheet Open mathsheet using @@html:@@M-x@@html:@@ @@html:@@mathsheet-open@@html:@@ *** Defining a Worksheet Worksheets are defined using a form. Forms-mode provides a way to add, save, load records based on a form. See [[https://www.gnu.org/software/emacs/manual/html_mono/forms.html#Forms-Commands][forms-mode doc]] for details. The mathsheet form specifies the following fields: - ~name~ :: The base name of the file to write. Spaces will be converted to dashes and a ~pdf~ extension will be added. - ~count~ :: the total number of problems to put on the worksheet - ~columns~ :: the number of columns the worksheet should have. - ~instruction~ :: a brief, one sentence instruction that will be included at the top of the sheet to guide the student. - ~problems~ :: A multi-line, pipe (~|~) delimited string describing the problems to include on the worksheet. Consider this example value for ~problems~: #+begin_example 3 | 1 | [1..10] + [1..20] 1 | 2 | [a=1..10] - [0..$a] #+end_example Each ~problems~ line contains the following fields: - ~weight (w)~ :: The relative number of this type of problem to include on the worksheet. A weight of zero means the template will not be used. In the example above, three fourths of the worksheet problems will be addition. - ~order (o)~ :: Problems are ordered on the sheet in ascending order. Two problems with the same order will be intermingled. In the example above, all of the addition problems will come before the subtraction problems. - ~template~ :: this is the template used to generate problems of this type. Generate a worksheet by running @@html:@@C-c@@html:@@ @@html:@@C-r@@html:@@ from the mathsheet form. *** Customization Mathsheet allows for the following customizations: - ~mathsheet-data-file~ :: This is where mathsheet data is stored. It defaults to a file in your emacs user directory. You can probably leave it there. - ~mathsheet-output-directory~ :: This is where worksheets should be written. It defaults to your home directory. You'll probably want to move it somewhere else. *** Problem Templates The worksheet is made of a set of math problems. Each problem is defined by a template that lays out an equation or expression and shows where variables or numbers should be. **** Expression Templates Expression templates define an expression which must be evaluated. For example, consider this template: #+begin_example [0..15] + [1..10] #+end_example The parts within the brackets are fields. When a template is made into a problem and added to a worksheet, each field is replaced by a number based on a set of rules. The supported rules are described in more detail below, but ~[0..15]~ means pick a random number between 0 and 15, inclusive, so the above template could result in problems like these: #+begin_example 1 + 2 15 + 10 5 + 1 #+end_example **** Equation Templates In addition to expressions where the answer is a number, templates can be equations where the solution is found by solving for the variable. For example, consider this template: #+begin_example [1..5] x + 3 = [-10..10] #+end_example This can produce the following problems: #+begin_example 3 x + 6 = -1 4 x + 2 = 2 1 x + 8 = -3 #+end_example **** Field Rules These are the different ways fields can be defined: - ~[-2..8]~ :: choose a random number from -2 to 8, inclusive - ~[1,3,5]~ :: choose randomly from 1, 3 or 5 - ~[-3..-1,1..3]~ :: choose a random number from -3 to -1 or 1 to 3 - ~[10/(2-1)]~ :: evaluate the expression - ~[round(sin(0.3))]~ :: expressions can use math functions - ~[a=...]~ :: assign the variable ~a~ to the number chosen for this field - ~[-2..$a]~ :: any number from -2 to the value another field assigned to ~a~ - ~[0..[$a/2]]~ :: any number from 0 to half the value assigned to ~a~. The ability to keep track of the random number chosen in one field and use it to influence another allows the template to be written to avoid answers that are negative or don't divide evenly. These math functions are allowed: ~sqrt~, ~sin~, ~cos~, ~tan~, ~asin~, ~acos~, ~atan~, ~floor~, ~ceil~, ~round~. Find more details about each of these functions in the [[https://www.gnu.org/software/emacs/manual/html_mono/calc.html#Arithmetic][Emacs Calc manual]]. **** Template Examples Here are a few more examples: Division problem that divides evenly #+begin_example [$a*[1..5]] / [a=1..10] #+end_example Addition and subtraction, but ensure a positive result #+begin_example [a=1..10] + [b=0..10] - [0..($a+$b)] #+end_example Division but ensure we don't divide by zero #+begin_example [-10..10] / [-5..-1,1..5] #+end_example * Code walkthrough ** Front matter *** GNU header components This lays out some standard header content that is repeated for each file. #+name: copyright #+begin_src elisp :exports none ;; Copyright (C) 2025 Free Software Foundation, Inc. #+end_src #+name: license #+begin_src elisp :exports none ;; This file is not part of GNU Emacs. ;; GNU Emacs is free software: you can redistribute it and/or modify ;; it under the terms of the GNU General Public License as published by ;; the Free Software Foundation, either version 3 of the License, or ;; (at your option) any later version. ;; GNU Emacs is distributed in the hope that it will be useful, ;; but WITHOUT ANY WARRANTY; without even the implied warranty of ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ;; GNU General Public License for more details. ;; You should have received a copy of the GNU General Public License ;; along with GNU Emacs. If not, see . #+end_src *** Full header This is the standard Emacs package header. ~emacs 26~ is needed for ~seq-random-elt~. ~calc~ is used to solve the problems as well as converting them to mathematical notation in LaTeX format. #+begin_src elisp :noweb yes :tangle mathsheet.el ;;; mathsheet.el --- Generate dynamic math worksheets -*- lexical-binding:t -*- <> ;; Author: Ian Martins ;; Keywords: tools, education, math ;; Homepage: https://gitlab.com/ianxm/mathsheet ;; Version: 1.0 ;; Package-Requires: ((peg "1.0") ;; (emacs "28.1") ;; calc) <> ;;; Commentary: ;; This package generates dynamic math worksheets. The types and ;; distribution of problems is highly customizable. Problem sets are ;; defined using templates and exported to PDF for printing. ;;; Code: #+end_src *** Dependencies This package needs [[https://www.gnu.org/software/emacs/manual/html_mono/forms.html][forms-mode]], [[https://elpa.gnu.org/packages/peg.html][peg]], [[https://www.gnu.org/software/emacs/manual/html_mono/calc.html][calc]]. Forms mode and Calc are included in Emacs but we need to make sure they have been loaded. #+begin_src elisp :tangle mathsheet.el (require 'forms) (require 'peg) (require 'calc) (declare-function math-read-expr "calc-ext") #+end_src *** Variables Here we define a customize group, some customize variables that provide for configuring where form records are stored and where output is written, and some non-customize variables used internally. #+begin_src elisp :tangle mathsheet.el (defgroup mathsheet nil "Options for customizing Mathsheet." :prefix "mathsheet-" :group 'applications :tag "mathsheet") (defcustom mathsheet-data-file (expand-file-name "mathsheet.dat" user-emacs-directory) "Where to store saved mathsheet configurations. The default is to save them to a file in the private emacs configuration directory." :type 'file :group 'mathsheet) (defcustom mathsheet-output-directory (expand-file-name "~") "Where to write generated worksheets. The default is to write the to the home directory." :type 'directory :group 'mathsheet) #+end_src We need ~mathsheet--var-list~ to keep track of the variables between fields since we need to access the list from multiple top level functions. ~mathsheet--worksheet-template~ is the LaTeX template for the worksheet, which is defined in a LaTeX source block below. This assigns the constant directly to that named block. ~mathsheet--num-pat~ is defined here since it is referenced in a macro that is used in multiple places. If it was in the macro it would be redefined by expansion, and since the macro is called from different scopes we'd have to define it in multiple places to define it in the scope where the macro is called. #+name: variables #+begin_src elisp :tangle mathsheet.el :var page=page (defvar mathsheet--var-list '() "List of variables used within a problem.") (defconst mathsheet--worksheet-template page "LaTeX template for the worksheet.") (defconst mathsheet--num-pat (rx string-start (+ num) string-end) "Pattern for integers.") (defvar mathsheet--field-sheet-name nil "The form record name field.") (defvar mathsheet--field-count nil "The form record count field.") (defvar mathsheet--field-cols nil "The form record cols field.") (defvar mathsheet--field-instruction nil "The form record instruction field.") (defvar mathsheet--field-problems nil "The form record problems field.") #+end_src ** UI Form *** Form configuration See details [[https://www.gnu.org/software/emacs/manual/html_mono/forms.html][here]]. #+begin_src elisp :tangle mathsheet.el (setq forms-file mathsheet-data-file) (setq forms-number-of-fields (forms-enumerate '(mathsheet--field-sheet-name mathsheet--field-count mathsheet--field-cols mathsheet--field-instruction mathsheet--field-problems))) (setq forms-field-sep "||") #+end_src *** New record defaults When new records are created using the form, initialize them with default values. #+begin_src elisp :tangle mathsheet.el (defun mathsheet--new-record-filter (record) "Set defaults in new RECORD." (aset record 2 "20") ; default (aset record 3 "2") ; default (aset record 4 "Find the answer.") ; default (aset record 5 "1 | 1 | ") ; lay out structure record) (setq forms-new-record-filter 'mathsheet--new-record-filter) #+end_src *** Clean up template rows When the form is saved, clean up the template field by lining up the columns. #+begin_src elisp :tangle mathsheet.el (defun mathsheet--format-templates (record) "Format the template rows in RECORD to line up with the header." (let ((rows (string-split (aref record 5) "\n")) (pat (rx (* space) (group (+ alnum)) (* space) "|" (* space) (group (+ alnum)) (* space) "|" (* space) (group (+ nonl))))) (setq rows (mapconcat (lambda (row) (string-match pat row) (format "%s | %s | %s" (match-string 1 row) (match-string 2 row) (match-string 3 row))) rows "\n")) (aset record 5 rows)) record) (setq forms-modified-record-filter 'mathsheet--format-templates) #+end_src *** Layout the actual form This defines the form itself and the locations of the fields. #+begin_src elisp :tangle mathsheet.el (setq forms-format-list (list "====== Math Sheet Generator ======" "\nSee https://gitlab.com/ianxm/mathsheet for details." "\n\nThe base-name of the mathsheet file to write, not including extension." "\nName: " mathsheet--field-sheet-name "\n\nThe total number of problems to put on the sheet." "\nCount: " mathsheet--field-count "\n\nThe number of columns the sheet should have." "\nColumns: " mathsheet--field-cols "\n\nThe instruction to give at the top of the sheet." "\nInstruction: " mathsheet--field-instruction "\n\nThe problem templates from which to generate problems for the sheet." "\nOne per line, formatted as \"(w)eight | (o)rder | template\".\n\n" "w | o | template\n" "--+---+------------------------------------\n" mathsheet--field-problems "\n")) #+end_src ** Extract configuration from form *** Validate form fields This adds validation checks as needed for each field. #+begin_src elisp :tangle mathsheet.el (defmacro mathsheet--validate (field-name field-str checks) "Add specified checks to validate field input. FIELD-NAME is the name of the field. FIELD-STR is the string value in the record. CHECKS is a list of symbols specifying which validation checks to perform." (let (ret) (dolist (check checks) (pcase check ('not-null-p (push `(when (null ,field-str) (error (format "`%s' cannot be empty" ,field-name))) ret)) ('is-num-p (when (not (null field-str)) (push `(when (not (string-match-p mathsheet--num-pat ,field-str)) (error (format "`%s' must be a number" ,field-name))) ret))) (`(in-range-p ,min ,max) (push `(when (or (< (string-to-number ,field-str) ,min) (> (string-to-number ,field-str) ,max)) (error (format "`%s' must be between %s and %s, inclusive" ,field-name ,min ,max))) ret)) (_ (push `(error (format "Unknown check: %s" ,check)) ret)) )) (append '(progn) ret))) #+end_src *** Extract and parse ~emacs-forms~ treats everything like strings so we have to validate and convert the numbers. Also the problem field contains multi-line delimited data so we have to parse it. #+begin_src elisp :tangle mathsheet.el (defun mathsheet--parse (record) "Parse all of the fields of the current RECORD into an alist." (let (count cols problems) (pcase record (`(,name ,count-str ,cols-str ,instruction ,problems-str) ;; validate the form fields (mathsheet--validate "name" name (not-null-p)) (mathsheet--validate "count" count-str (not-null-p is-num-p (in-range-p 1 30))) (mathsheet--validate "cols" cols-str (not-null-p is-num-p (in-range-p 1 6))) (mathsheet--validate "problems" problems-str (not-null-p)) ;; convert the numbers and parse the problems field (setq count (string-to-number count-str) cols (string-to-number cols-str) problems (mapcar ; parse rows #'mathsheet--parse-problem-row (seq-filter ; remove possible trailing empty line (lambda (x) (not (string-empty-p x))) (string-split ; split lines problems-str "\n")))) `((:name . ,name) (:count . ,count) (:cols . ,cols) (:instr . ,instruction) (:probs . ,problems))) (_ (error "Invalid form data"))))) #+end_src This function is used to parse each problem row. #+begin_src elisp :tangle mathsheet.el (defun mathsheet--parse-problem-row (row) "Parse one ROW of the problem field into a list." (let* ((fields (mapcar ; trim whitespace #'string-trim (split-string ; split fields row "|"))) (weight-str (nth 0 fields)) (order-str (nth 1 fields)) (template (nth 2 fields)) weight order) (mathsheet--validate "weight" weight-str (not-null-p is-num-p)) (mathsheet--validate "order" order-str (not-null-p is-num-p)) (mathsheet--validate "template" template (not-null-p)) (setq weight (string-to-number weight-str) order (string-to-number order-str)) (list weight order template))) #+end_src *** Initiate sheet generation #+begin_src elisp :tangle mathsheet.el (defun mathsheet-generate-sheet () "Generate sheet for current form data." (interactive) (when (not (string= major-mode "forms-mode")) (error "Mathsheet must be open to generate a sheet")) (let ((config (mathsheet--parse forms--the-record-list))) (let ((problems (mathsheet--generate-problems (alist-get :probs config) (alist-get :count config))) ;; absolute path without extension (fname (concat (file-name-as-directory mathsheet-output-directory) (string-replace " " "-" (alist-get :name config))))) (mathsheet--write-worksheet fname (alist-get :instr config) problems (alist-get :cols config)) (message "Wrote %s problems to %s.pdf" (alist-get :count config) fname)))) #+end_src ** Problem generation *** Scan problem This scans a problem to find the locations of fields and dependencies between them. It must be called with point at the start of the problem. It moves the point to the end of the problem unless there's an error, in which case it stops at the place where the error occurred. This returns a list of fields, with each field formatted as: #+begin_example '(asn-var (deps) (start-marker . end-marker) nil) #+end_example ~asn-var~ is a variable name if this field is being assigned to a variable, otherwise it is a placeholder like ~_0~, ~_1~, etc. ~asn-var~ must be interned and must be the first index since we use this list as an alist later. ~deps~ is a list of are dependencies if this field has any, otherwise ~nil~. Dependencies could be variables or placeholders. ~start-marker~ and ~end-marker~ are markers in the (temp) buffer. The ~end-marker~ is configured to insert text before the marker. The last entry is ~nil~ for "not visited." It is used by ~dfs-visit~. for example: #+begin_example [$a + 2 + [a=1..5]] => '((nil (a) m1 m19 nil) (a nil m11 m18 nil)) '((:fields (_0 (a a) (marker . marker) nil) (a nil (marker . marker) nil)) (:alg-vars)) #+end_example This uses peg to parse the problem. Instead of using the peg return value we build the list of fields outside of the peg stack. ~open-fields~ is a stack of fields with the current field on top. We push a new field to the stack when we start a new field. ~closed-fields~ is a list of fields that have been completed. We push a new field to the list when we close the current field, taking it off of ~open-fields~. #+name: scan-problem #+begin_src elisp :tangle mathsheet.el (defun mathsheet--scan-problem () "Scan a problem. This parses the problem and produces a list containing info about its fields. For each field it returns a list containing: 1. a symbol for the assigned variable or a unique placeholder 2. a list of variables this field depends on 3. a cons containing start and end markers for the field in the current buffer 4. nil which is used by `dfs-visit' later" (let ((field-index 0) open-fields ; stack closed-fields ; list alg-vars) (with-peg-rules ((stuff (* (or asn-var math-func alg-var digit symbol field space))) (field open (opt assignment) stuff close) (space (* [space])) (open (region "[") `(l _ -- (progn (push (list (intern (concat "_" (number-to-string field-index))) ; asn-var nil ; deps (cons (copy-marker l) nil) ; start and end markers nil) ; not visited open-fields) (setq field-index (1+ field-index)) "."))) (assignment (substring letter) "=" `(v -- (progn (setcar (car open-fields) (intern v)) "."))) (asn-var "$" (substring letter) `(v -- (progn (push (intern v) (cadar open-fields)) "."))) (alg-var (substring letter) `(v -- (progn (push v alg-vars) "."))) (close (region "]") `(l _ -- (progn (setcdr (caddar open-fields) (copy-marker l t)) (when (> (length open-fields) 1) ; add parent to child dependency (push (caar open-fields) (cadadr open-fields))) (push (pop open-fields) closed-fields) "."))) (math-func (or "sqrt" "sin" "cos" "tan" "asin" "acos" "atan" "floor" "ceil" "round")) (letter [a-z]) (digit [0-9]) (symbol (or "." "," "+" "-" "*" "/" "^" "(" ")" "="))) (peg-run (peg stuff) (lambda (x) (message "Failed %s" x)) (lambda (x) (funcall x) `((:fields . ,closed-fields) (:alg-vars . ,alg-vars))))))) #+end_src **** test scan :noexport: Test ~mathsheet--scan-problem~ here: #+begin_src elisp :results verbatim :noweb yes <> (with-temp-buffer (insert "[0..4,6-9,11] * x + [floor([-10..10]/3)] = [-10..10]") (goto-char (point-min)) (mathsheet--scan-problem)) #+end_src #+RESULTS: : ((:fields (_3 nil (# . #) nil) (_1 (_2) (# . #) nil) (_2 nil (# . #) nil) (_0 nil (# . #) nil)) (:alg-vars "x")) *** Reduce field This must be called with point at the start of a field. This moves the point to the end of the field. This returns the value to which the field reduces. ~peg-run~ returns its stack and the value is the last thing remaining on the stack when peg completes so peg returns a list with one value. We take the value out of the list and return it. This uses the peg package to parse the field. This time there shouldn't be any fields embedded within the field. We should have already evaluated and replaced them. We use ~..~ instead of ~-~ for range because if we used ~-~ then this would be ambiguous: #+begin_example [1-5] #+end_example The list of supported operators and math functions are listed both here and in ~mathsheet--scan-problem~, so changes must be made in both places to keep them synced. #+name: reduce-field #+begin_src elisp :tangle mathsheet.el (defun mathsheet--reduce-field () "Reduce the field to a number. Parse the field again, replacing spans with random numbers and evaluating arithmetic operations. The field shouldn't have any internal fields so this should result in a single number. Return that number." (with-peg-rules ((field "[" space (or math-func expression sequence assignment value) space "]") (expression (list value space operation space value (* space operation space value)) `(vals -- (string-to-number (calc-eval (list (mapconcat (lambda (x) (if (numberp x) (number-to-string x) x)) vals " ")) calc-prefer-frac nil)))) (operation (substring (or "+" "-" "*" "/"))) (assignment var-lhs space "=" space (or range sequence) `(v r -- (progn (push (cons (intern v) r) mathsheet--var-list) r))) (sequence (list (or range value) (* "," space (or range value))) `(vals -- (seq-random-elt vals))) (range value ".." value `(min max -- (if (>= min max) (error "Range bounds must be increasing") (+ (random (- max min)) min)))) (value (or (substring (opt "-") (+ digit)) var-rhs parenthetical) `(v -- (if (stringp v) (string-to-number v) v))) (parenthetical "(" (or expression value) ")") (var-lhs (substring letter)) ; var for assignment (var-rhs "$" (substring letter) ; var for use `(v -- (let ((val (alist-get (intern v) mathsheet--var-list))) (or val (error "Var %s not set" v))))) (math-func (substring (or "sqrt" "sin" "cos" "tan" "asin" "acos" "atan" "floor" "ceil" "round")) parenthetical `(f v -- (string-to-number (calc-eval (format "%s(%s)" f v))))) (space (* [space])) (letter [a-z]) (digit [0-9])) (peg-run (peg field) (lambda (x) (message "Failed %s" x)) (lambda (x) (car (funcall x)))))) #+end_src **** test reduce :noexport: test ~mathsheet--reduce-field~ here: #+begin_src elisp :results verbatim :noweb yes :var page=page <> <> (with-temp-buffer ;(insert "[1..10,15..20,50]") (insert "[1..10]") (goto-char (point-min)) (mathsheet--reduce-field)) #+end_src #+RESULTS: : 3 *** Replace field Replace a field with the value returned from reducing it. This uses ~mathsheet--reduce-field~ to determine the value to use in place of the field. #+name: replace-field #+begin_src elisp :tangle mathsheet.el (defun mathsheet--replace-field (node) "Replace a field in NODE with the number to which it reduces. Update the current buffer by replacing the field at point in the current buffer with the number it reduces to. NODE contains the info for the current field." (let ((start (caaddr node)) (end (1+ (cdaddr node))) val) (goto-char start) (when (looking-at "\\[") (setq val (mathsheet--reduce-field)) (goto-char start) (delete-char (- end start) t) (insert (number-to-string val))))) #+end_src *** DFS visit This uses a depth first search to ensure that we visit (reduce and replace) the fields in dependency order. We check dependencies then visit the node. We use the last field in the field structure to keep track of which fields have been visited. #+name: dfs-visit #+begin_src elisp :tangle mathsheet.el (defun mathsheet--dfs-visit (node fields) "Visit NODE as part of a DFS of the problem. Traverse the fields of a problem using depth first search to ensure that field replacement happens in dependency order. FIELDS is a list of all fields in the problem." (pcase (cadddr node) (1 (error "Cycle detected")) ; cycle (2) ; skip (_ ; process (setcar (cdddr node) 1) ; started (dolist (dep (cadr node)) (mathsheet--dfs-visit (assq dep fields) fields)) (mathsheet--replace-field node) ; visit (setcar (cdddr node) 2)))) ; mark done #+end_src *** Fill fields in problem processes all fields in a problem. #+begin_example (full-problem (buffer-substring (point-at-bol) (point-at-eol))) #+end_example #+begin_src elisp :tangle mathsheet.el (defun mathsheet--fill-problem (full-problem) "Replace all fields in FULL-PROBLEM. Goes through all fields in the given problem in dependency order and replaces fields with numbers. When this completes the problem will be ready to solve." (with-temp-buffer ;; stage problem in temp buffer (insert full-problem) (goto-char (point-min)) ;; find fields, assignment variables, algebraic variables, dependencies (let* ((scan-ret (mathsheet--scan-problem)) (fields (alist-get :fields scan-ret)) (alg-vars (alist-get :alg-vars scan-ret))) ;; visit fields ordered according to dependencies (dolist (node fields) (mathsheet--dfs-visit node fields)) (setq mathsheet--var-list '()) ;; return filled problem `((:problem . ,(buffer-string)) (:alg-vars . ,alg-vars))))) #+end_src **** test fill :noexport: test ~mathsheet--fill-problem~ here: #+begin_src elisp :results verbatim :noweb yes :var page=page <> <> <> <> <> (mathsheet--fill-problem "[1..12] + [1,4,6,10]") ;;(mathsheet--fill-problem "[1..[2..[10..100]]]") ;;(mathsheet--fill-problem "[$a*[1..10]] / [a=1..10]") ;;(mathsheet--fill-problem "[$a]/(3+[a=1..5])") ;; (mathsheet--fill-problem "1/x + 2 = [-10..[10..20]]") #+end_src #+RESULTS: : ((:problem . "6 + [1,4,6,10]") (:alg-vars)) other examples #+begin_example simple range [10..11] complex range [-10..[10..20]] complex with assignment [a=1..[2..8]] complex with inner assignment [-10..[b=10..20]] simple with variable [0..[$a..$b]] #+end_example *** Generate problem set from templates This reads in the templates, figures out how many of each based on weights and the number of problems needed, generates the problem set, figures out the answers, then reorders. The reordering is done because if multiple templates are assigned the same ~order~, they should be intermingled, but we add all problems for each template sequentially. In order to mix them up we shuffle the whole set and then reorder by ~order~. #+name: generate-problems #+begin_src elisp :tangle mathsheet.el (defun mathsheet--generate-problems (templates count) "Use TEMPLATES to generate COUNT problems. Generate problems and answers based on what is defined in the given template table. The template table defines problem templates as well as relative weights and how they should be ordered." (let (total-weight problems) ;; sort by weight (low to high) (setq templates (sort templates #'car-less-than-car) ;; calc total weight total-weight (seq-reduce (lambda (total item) (+ total (car item))) templates 0.0)) ;; calculate number for each row (dotimes (ii (length templates)) (let* ((item (nth ii templates)) (weight (car item)) (needed (cond ; number of problems to add for this template ((= weight 0) 0) ((= ii (1- (length templates))) (- count (length problems))) (t (max (round (* (/ weight total-weight) count) ) 1)))) (added 0) (dup-count 0) problem-set) (while (< added needed) ; add until "needed" are kept (let* ((fill-ret (mathsheet--fill-problem (caddr item))) (problem (alist-get :problem fill-ret)) (alg-vars (alist-get :alg-vars fill-ret)) (calc-string (if (not alg-vars) problem (format "solve(%s,[%s])" problem (string-join (seq-uniq alg-vars) ",")))) (solution (replace-regexp-in-string (rx (or "[" ".]" "]")) "" (calc-eval `(,calc-string calc-prefer-frac t calc-frac-format ("/" nil)))))) (cond ((member problem problem-set) ; dedup problems (setq dup-count (1+ dup-count)) (when (> dup-count 100) ;; high number of dups indicates a narrow problem space relative to problem count (error "Giving up, too many dups"))) (t (push problem problem-set) (push (list problem ; problem solution ; solution (cadr item) ; order (not (null alg-vars))) ; true if algebraic variables exist problems) (setq added (1+ added)))))))) ;; shuffle (dotimes (ii (- (length problems) 1)) (let ((jj (+ (random (- (length problems) ii)) ii))) (cl-psetf (elt problems ii) (elt problems jj) (elt problems jj) (elt problems ii)))) ;; sort by order (setq problems (sort problems (lambda (a b) (< (caddr a) (caddr b))))) ;; return problems and answers, drop header problems)) #+end_src ** Generate PDF *** Lay out page This wraps the problems with a LaTeX header and footer. This template doesn't use noweb but it uses noweb syntax (~<