summaryrefslogtreecommitdiff
path: root/mathsheet.org
blob: 96e53c7eb368434cb670ada796e2fafa548512ff (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
# -*- 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:<kbd>@@M-x@@html:</kbd>@@
@@html:<kbd>@@mathsheet-open@@html:</kbd>@@
*** 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:<kbd>@@C-c@@html:</kbd>@@
@@html:<kbd>@@C-r@@html:</kbd>@@ 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 <https://www.gnu.org/licenses/>.
#+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 -*-

  <<copyright>>

  ;; Author: Ian Martins <ianxm@jhu.edu>
  ;; Keywords: tools, education, math
  ;; Homepage: https://gitlab.com/ianxm/mathsheet
  ;; Version: 1.0
  ;; Package-Requires: ((peg "1.0")
  ;;                    (emacs "28.1")
  ;;                    calc)

  <<license>>

  ;;; 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
  <<scan-problem>>

  (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 (#<marker in no buffer> . #<marker (moves after insertion) in no buffer>) nil) (_1 (_2) (#<marker in no buffer> . #<marker (moves after insertion) in no buffer>) nil) (_2 nil (#<marker in no buffer> . #<marker (moves after insertion) in no buffer>) nil) (_0 nil (#<marker in no buffer> . #<marker (moves after insertion) in no buffer>) 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
  <<variables>>
  <<reduce-field>>

  (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
  <<variables>>
  <<scan-problem>>
  <<reduce-field>>
  <<replace-field>>
  <<dfs-visit>>

  (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 (~<<label>>~)
to mark where mathsheet will insert content. It's not possible
actually use noweb here since the problems and answers are coming from
elisp and generated at runtime. Instead this template must be tangled
to mathsheet.el as a template so the elisp functions can use it.

#+name: page
#+begin_src latex :exports code :results value silent
  \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}
#+end_src
*** Convert calc to latex
This converts a calc expression to latex format. The problems and
answers are generated in standard emacs calc format. If they are to be
written to a PDF we convert them to latex. emacs calc already knows
how to convert between formats, so we let it do it.

#+name: convert-to-latex
#+begin_src elisp :tangle mathsheet.el
  (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 "\\)")))
#+end_src
*** Write PDF
This inserts instruction line and generated problems into the page
template, writes it to a local file, then runs ~texi2pdf~ to build a
PDF. We save it as ~[template-name].tex~ and the final worksheet is
named ~[template-name].pdf~. Each execution with the same template name
will overwrite the same file.

#+begin_src elisp :results silent :tangle mathsheet.el
  (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"))))
#+end_src
** Convenience functions
*** Add key binding to form
This adds the keybinding to run the mathsheet generator from the
mathsheet form.

#+begin_src elisp :tangle mathsheet.el
  (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)))))
#+end_src
*** Open mathsheet
This is a helper to open mathsheet with the configured data file.

#+begin_src elisp :tangle mathsheet.el
  ;;;###autoload
  (defun mathsheet-open ()
    "Open mathsheet."
    (interactive)
    (forms-find-file (locate-file "mathsheet.el" load-path)))
#+end_src
** Footer
This is the form file footer.

#+begin_src elisp :tangle mathsheet.el
(provide 'mathsheet)

;;; mathsheet.el ends here
#+end_src
** Test with                                                       :noexport:
run this to start

#+begin_src elisp :results silent
(forms-find-file "mathsheet.el")
#+end_src
* Literate Programming
This is written as a [[https://en.wikipedia.org/wiki/Literate_programming][literate program]] using [[https://orgmode.org/][Emacs org-mode]]. [[file:mathsheet.org][The org
file]] contains the code and documentation for the math worksheet
generation script.  When this file is saved, the source code is
generated using =org-babel-tangle= and the readme is generated using
=org-md-export-to-file=.

The first line of [[file:mathsheet.org][the org file]] configures emacs to run those commands
whenever this file is saved, which generates the scripts and readme.