branch: externals/org-mathsheet
commit 83f43fcc8ab3d80f10891eee0e69992fcf4d59b5
Author: Ian Martins <ia...@jhu.edu>
Commit: Ian Martins <ia...@jhu.edu>

    Add .el file
---
 mathsheet.el | 607 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 607 insertions(+)

diff --git a/mathsheet.el b/mathsheet.el
new file mode 100644
index 0000000000..ef545a2607
--- /dev/null
+++ b/mathsheet.el
@@ -0,0 +1,607 @@
+;;; mathsheet.el --- Generate dynamic math worksheets  -*- lexical-binding:t 
-*-
+
+;; Copyright (C) 2025 Free Software Foundation, Inc.
+
+;; Author: Ian Martins <ia...@jhu.edu>
+;; Keywords: tools, education, math
+;; Homepage: https://gitlab.com/ianxm/mathsheet
+;; Version: 1.0
+;; Package-Requires: ((peg "1.0")
+;;                    (emacs "28.1")
+;;                    calc)
+
+;; 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 <https://www.gnu.org/licenses/>.
+
+;;; 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:
+
+(require 'forms)
+(require 'peg)
+(require 'calc)
+
+(declare-function math-read-expr "calc-ext")
+
+(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)
+
+(let ((page '"\\documentclass[12pt]{exam}
+\\usepackage[top=1in, bottom=0.5in, left=0.8in, right=0.8in]{geometry}
+\\usepackage{multicol}
+\\usepackage{rotating}
+\\usepackage{xcolor}
+
+\\pagestyle{head}
+\\header{Name:\\enspace\\makebox[2.2in]{\\hrulefill}}{}{Date:\\enspace\\makebox[2.2in]{\\hrulefill}}
+
+\\begin{document}
+
+  \\noindent <<instruction>>
+
+  \\begin{questions}
+    <<problems>>
+  \\end{questions}
+
+  \\vspace*{\\fill}
+
+  \\vspace*{0.1cm}
+  \\noindent\\rule{\\linewidth}{0.4pt}
+  \\vspace*{0.1cm}
+
+  \\begin{turn}{180}
+    \\begin{minipage}{\\linewidth}
+      \\color{gray}
+      \\footnotesize
+      \\begin{questions}
+        <<answers>>
+      \\end{questions}
+    \\end{minipage}
+  \\end{turn}
+
+\\end{document}"))
+(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.")
+
+)
+
+(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 "||")
+
+(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)
+
+(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)
+
+(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"))
+
+(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)))
+
+(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")))))
+
+(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)))
+
+(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))))
+
+(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)))))))
+
+(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))))))
+
+(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)))))
+
+(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
+
+(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)))))
+
+(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))
+
+(defun mathsheet--convert-to-latex (expr)
+  "Format the given calc expression EXPR for LaTeX.
+
+EXPR should be in normal calc format.  The result is the same
+expression (not simplified) but in LaTeX format."
+  (let* ((calc-language 'latex)
+         (calc-expr (math-read-expr expr))
+         (latex-expr (math-format-stack-value (list calc-expr 1 nil)))
+         (latex-expr-cleaned (replace-regexp-in-string (rx "1:" (* space)) "" 
latex-expr)))
+    (concat "\\(" latex-expr-cleaned "\\)")))
+
+(defun mathsheet--write-worksheet (fname instruction problems prob-cols)
+  "Write a worksheet to FNAME with INSTRUCTION and PROBLEMS.
+
+Write a file named FNAME.  Include the INSTRUCTION line at the
+top.  The problems will be arranged in PROB-COLS columns.  The
+answers will be in 5 columns."
+  (with-temp-file (concat fname ".tex")
+    (insert mathsheet--worksheet-template)
+
+    (goto-char (point-min))
+    (search-forward "<<instruction>>")
+    (replace-match "")
+    (insert instruction)
+
+    (let ((answ-cols 5))
+      (goto-char (point-min))
+      (search-forward "<<problems>>")
+      (replace-match "")
+      (dolist (group (seq-partition problems prob-cols))
+        (insert (format "\\begin{multicols}{%d}\n" prob-cols))
+        (dolist (row group)
+          (insert (format (if (nth 3 row)
+                              "\\question %s\n"
+                            "\\question %s = 
\\rule[-.2\\baselineskip]{2cm}{0.4pt}\n")
+                          (mathsheet--convert-to-latex (car row)))))
+        (insert "\\end{multicols}\n")
+        (insert "\\vspace{\\stretch{1}}\n"))
+
+      (goto-char (point-min))
+      (search-forward "<<answers>>")
+      (replace-match "")
+      (dolist (group (seq-partition problems answ-cols))
+        (insert (format "\\begin{multicols}{%s}\n" answ-cols))
+        (dolist (row group)
+          (insert (format "\\question %s\n"
+                          (mathsheet--convert-to-latex (cadr row)))))
+        (insert "\\end{multicols}\n"))))
+
+  (let* ((default-directory mathsheet-output-directory)
+         (ret (call-process
+              "texi2pdf" nil (get-buffer-create "*Standard output*") nil
+              (concat fname ".tex"))))
+    (unless (eq ret 0)
+      (error "PDF generation failed"))))
+
+(when (null forms-mode-map)
+  (add-to-list
+   'forms-mode-hook
+   (lambda ()
+     (when (string= "mathsheet.el" (buffer-name))
+       (define-key forms-mode-map "\C-r" #'mathsheet-generate-sheet)))))
+
+;;;###autoload
+(defun mathsheet-open ()
+  "Open mathsheet."
+  (interactive)
+  (forms-find-file (locate-file "mathsheet.el" load-path)))
+
+(provide 'mathsheet)
+
+;;; mathsheet.el ends here

Reply via email to