branch: externals/mathsheet commit ae795556271f3633518ebe948f3573e5d40090ec Author: Ian Martins <ia...@jhu.edu> Commit: Ian Martins <ia...@jhu.edu>
Write PDFs using groff instead of LaTeX --- README.md | 298 ++++++++++++++++++++++++++++++----------------- examples/add-sub-1.pdf | Bin 34623 -> 29296 bytes examples/algebra-1.pdf | Bin 75836 -> 28888 bytes mathsheet.el | 163 +++++++++++++------------- mathsheet.org | 305 ++++++++++++++++++++++++++++++++----------------- 5 files changed, 476 insertions(+), 290 deletions(-) diff --git a/README.md b/README.md index d046663ee2..a98311d6d1 100644 --- a/README.md +++ b/README.md @@ -20,18 +20,37 @@ parent, or any parent. ## Examples -Here are some example worksheets generated by this tool: +Here are some example worksheets generated by this tool, along with +the templates from which they were built. Template syntax is described +in greater in the [Problem Templates](#org3b64385) section below. 1. [arithmetic](examples/add-sub-1.pdf) + made from these templates: + + w | o | template + --+---+------------------------------------ + 3 | 2 | [1..10] + [8..15] + 2 | 2 | [a=3..10] - [0..$a] + 1 | 3 | [1..10] + [1..7] + [1..5] + 1 | 4 | [a=1..10] + [0..10] - [0..$a] + 1 | 5 | [a=1..10] + [b=0..10] - [0..($a+$b)] 2. [algebra](examples/algebra-1.pdf) - -They were generated using [this configuration](examples/example.md). + made from these templates: + + w | o | template + --+---+------------------------------------ + 3 | 1 | x / ([2..4] + [a=0..5]) = [$a..10] + 2 | 2 | [$a*[2..10]] / x = [a=1,2,4] + 2 | 3 | x/[-5..-1,1..5] + [1..10] = [-10..10] + 1 | 3 | x = x/[a=2..6] + [round([1..20]/$a)] + 1 | 3 | x^2 = sqrt([16 - $a] + [a=1..5]) ## Requirements -[texi2pdf](https://www.gnu.org/software/texinfo/manual/texinfo/html_node/Format-with-texi2dvi-or-texi2pdf.html) is required to generate the PDF worksheet. Without it you can -still generate the table of problems and solutions. +[Groff](https://www.gnu.org/software/groff/#introduction) is required to generate mathsheets. If `groff` is not installed on +the system PDF generation will fail and there will be a `command not +found` message in the mini-buffer. ## Usage @@ -184,8 +203,8 @@ file. 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 +`peg` is used to parse the problem templates. `calc` is used to solve the +problems as well as converting them to mathematical notation in EQN format. ;;; mathsheet.el --- Generate dynamic math worksheets -*- lexical-binding:t -*- @@ -269,8 +288,8 @@ 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 +`mathsheet--worksheet-template` is the Groff template for the +worksheet, which is defined in an example block below. This assigns the constant directly to that named block. `mathsheet--num-pat` is defined here since it is referenced in a macro @@ -283,7 +302,7 @@ scope where the macro is called. "List of variables used within a problem.") (defconst mathsheet--worksheet-template page - "LaTeX template for the worksheet.") + "Groff template for the worksheet.") (defconst mathsheet--num-pat (rx string-start (+ num) string-end) "Pattern for integers.") @@ -445,6 +464,9 @@ This adds validation checks as needed for each field. convert the numbers. Also the problem field contains multi-line delimited data so we have to parse it. +This is also where limits are set. The max problems on a sheet +is `50`. The max columns allowed is `4`. + (defun mathsheet--parse (record) "Parse all of the fields of the current RECORD into an alist." (let (count cols problems) @@ -454,8 +476,8 @@ data so we have to parse it. ;; 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 "count" count-str (not-null-p is-num-p (in-range-p 1 50))) + (mathsheet--validate "cols" cols-str (not-null-p is-num-p (in-range-p 1 4))) (mathsheet--validate "problems" problems-str (not-null-p)) ;; convert the numbers and parse the problems field @@ -863,121 +885,185 @@ whole set and then reorder by `order`. ### Lay out page -This wraps the problems with a LaTeX header and footer. +This lays out the page in Groff, with placeholders for where details +must be filled in. -This template doesn't use noweb but it uses noweb syntax (`<<label>>`) +This template doesn't use [noweb](https://orgmode.org/manual/Noweb-Reference-Syntax.html) 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. - \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}} + .VM 0 -0.5i \" reduce bottom margin + .PH "'Name: \l'20\_'''Date:\l'10\_''" \" header + <<instruction>> + .SP 1 + .fam C \" set font + <<layout>> + .MC \n[clen]p 10p \" start columns mode + <<problems>> + .1C 1 \" end of columns mode + .BS \" floating bottom box + \l'\n(.lu' \" horizontal rule + .S 8 10 \" reduce font and vertical space + .ss 6 \" reduce horizontal space + .gcolor grey \" answers color + <<answers>> + .BE + + +### Convert calc to EQN + +This converts a calc expression to EQN format for use with Groff. The +problems and answers are generated in Emacs Calc normal format. Emacs +Calc already knows how to convert between formats, so we let it do it. + + (defun mathsheet--convert-to-eqn (expr) + "Format the given calc expression EXPR for groff. + + EXPR should be in normal calc format. The result is the same + expression (not simplified) but in eqn format for groff." + (let ((current-language calc-language)) + (calc-set-language 'eqn) + (let* ((calc-expr (math-read-expr expr)) + (eqn-expr (math-format-stack-value (list calc-expr 1 nil))) + (eqn-expr-cleaned (replace-regexp-in-string (rx "1:" (* space)) "" eqn-expr))) + (calc-set-language current-language) + eqn-expr-cleaned))) + + (defun mathsheet--convert-to-eqn (expr) + "Format the given calc expression EXPR for groff. + + EXPR should be in normal calc format. The result is the same + expression (not simplified) but in eqn format for groff." + (let ((current-language calc-language)) + (calc-set-language 'eqn) + (let* ((calc-expr (math-read-expr expr)) + (eqn-expr (math-format-stack-value (list calc-expr 1 nil))) + (eqn-expr-cleaned (replace-regexp-in-string (rx "1:" (* space)) "" eqn-expr))) + (calc-set-language current-language) + eqn-expr-cleaned))) + (mathsheet--convert-to-eqn "x/(1+2)=3") + + +### Write PDF + +This inserts instruction line and generated problems into the page +template, writes it to a local file, then runs `groff` to build a PDF +named `[template-name].pdf`. Each execution with the same template name +will overwrite that file. + +Sub-sections are identified by [noweb](https://orgmode.org/manual/Noweb-Reference-Syntax.html) syntax (`<<section>>`). The details +of how each section is filled in is described below. + + (defun mathsheet--write-worksheet (fname instruction problems prob-cols) + "Write a worksheet to FNAME with INSTRUCTION and PROBLEMS. - \begin{document} + 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-buffer + (insert mathsheet--worksheet-template) - \noindent <<instruction>> + (let ((probs-per-col (ceiling (/ (float (length problems)) prob-cols)))) + <<fill-instruction>> - \begin{questions} - <<problems>> - \end{questions} + <<fill-layout>> - \vspace*{\fill} + <<fill-problems>> - \vspace*{0.1cm} - \noindent\rule{\linewidth}{0.4pt} - \vspace*{0.1cm} + <<fill-answers>>) - \begin{turn}{180} - \begin{minipage}{\linewidth} - \color{gray} - \footnotesize - \begin{questions} - <<answers>> - \end{questions} - \end{minipage} - \end{turn} + ;; write the groff file for debugging + ;; (write-region (point-min) (point-max) (concat fname ".mm")) - \end{document} + ;; run groff to generate the pdf + (let* ((default-directory mathsheet-output-directory) + (ret (shell-command-on-region + (point-min) (point-max) + (format "groff -mm -e -Tpdf - > %s" (concat fname ".pdf"))))) + (unless (eq ret 0) + (error "PDF generation failed"))))) +This fills in the instruction line. It's just a single string taken +from the config and added to the top of the sheet. -### Convert calc to latex +fill-instruction: -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. + (goto-char (point-min)) + (search-forward "<<instruction>>") + (replace-match + (if (null instruction) + "" + (concat ".B \"" instruction "\""))) - (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 "\\)"))) +This figures out the column with and spacing between rows and sets +them in registers. +Column width is computed based on line length. Line length (`\n[.l]`) is +reported in basic units, which are 1/72000 of an inch. Line length is +6.5 inches for letter paper. -### Write PDF +To find the space between rows we take page height, subtract header +and footer, a little more for the instruction, some more for each of +the problems, then divide the remainder by the number of problems per +column plus one. -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. +Groff has no rules for order of operations but calculates left to +right and numbers are integers, so we need to include parenthesis to +ensure order of operations and use large units (like points) to reduce +loss of precision due to integer division. - (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")))) +fill-layout: + + (goto-char (point-min)) + (search-forward "<<layout>>") + (replace-match "") + (insert (format ".nr ncols %d\n" prob-cols)) + (insert ".nr clen ((\\n[.l]/1000)-((\\n[ncols]-1)*10)/\\n[ncols])\n") + (insert (format ".nr cl %d\n" probs-per-col)) + (insert ".nr vs (\\n[.p]/1000-(2*72)-20-(12*\\n[cl]))/(\\n[cl]+1)") + +Here we fill the problems into the sheet. First we group them into +columns. + +fill-problems: + + (goto-char (point-min)) + (search-forward "<<problems>>") + (replace-match "") + (insert ".AL\n") + (let ((colsize probs-per-col)) + (seq-do-indexed + (lambda (group index) + (unless (= index 0) + (insert ".NCOL\n")) + (dolist (row group) + (message "convert to eqn %s -> %s" (car row) (mathsheet--convert-to-eqn (car row))) + (insert (format (if (nth 3 row) + ".LI\n.EQ\n%s\n.EN\n.SP \\n[vs]p\n" + ".LI\n.EQ\n%s =\n.EN\n\\l'5\\_'\n.SP \\n[vs]p\n") + (mathsheet--convert-to-eqn (car row)))))) + (seq-partition problems colsize))) + (insert ".LE") + +Here we fill in the answers. They are written as a comma delimited +list at the bottom of the sheet. + +fill-answers: + + (goto-char (point-min)) + (search-forward "<<answers>>") + (replace-match "") + (let ((index 0)) + (dolist (row problems) + (setq index (1+ index)) + (insert + (format ".EQ\n%d. %s%s\n.EN%s" + index + (mathsheet--convert-to-eqn (cadr row)) + (if (< index (length problems)) "\",\"~" "") + (if (< index (length problems)) "\n" ""))))) ## Convenience functions diff --git a/examples/add-sub-1.pdf b/examples/add-sub-1.pdf index 0264fb0423..61510b5e7e 100644 Binary files a/examples/add-sub-1.pdf and b/examples/add-sub-1.pdf differ diff --git a/examples/algebra-1.pdf b/examples/algebra-1.pdf index e96f5acb7e..de1a0a5c5e 100644 Binary files a/examples/algebra-1.pdf and b/examples/algebra-1.pdf differ diff --git a/mathsheet.el b/mathsheet.el index ef545a2607..ad04d0ea7f 100644 --- a/mathsheet.el +++ b/mathsheet.el @@ -5,7 +5,7 @@ ;; Author: Ian Martins <ia...@jhu.edu> ;; Keywords: tools, education, math ;; Homepage: https://gitlab.com/ianxm/mathsheet -;; Version: 1.0 +;; Version: 1.1 ;; Package-Requires: ((peg "1.0") ;; (emacs "28.1") ;; calc) @@ -62,45 +62,28 @@ 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}")) +(let ((page '".VM 0 -0.5i \\\" reduce bottom margin +.PH \"'Name: \\l'20\\_'''Date:\\l'10\\_''\" \\\" header +<<instruction>> +.SP 1 +.fam C \\\" set font +<<layout>> +.MC \\n[clen]p 10p \\\" start columns mode +<<problems>> +.1C 1 \\\" end of columns mode +.BS \\\" floating bottom box +\\l'\\n(.lu' \\\" horizontal rule +.S 8 10 \\\" reduce font and vertical space +.ss 6 \\\" reduce horizontal space +.gcolor grey \\\" answers color +<<answers>> +.BE +")) (defvar mathsheet--var-list '() "List of variables used within a problem.") (defconst mathsheet--worksheet-template page - "LaTeX template for the worksheet.") + "Groff template for the worksheet.") (defconst mathsheet--num-pat (rx string-start (+ num) string-end) "Pattern for integers.") @@ -233,8 +216,8 @@ which validation checks to perform." ;; 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 "count" count-str (not-null-p is-num-p (in-range-p 1 50))) + (mathsheet--validate "cols" cols-str (not-null-p is-num-p (in-range-p 1 4))) (mathsheet--validate "problems" problems-str (not-null-p)) ;; convert the numbers and parse the problems field @@ -533,61 +516,85 @@ ordered." ;; return problems and answers, drop header problems)) -(defun mathsheet--convert-to-latex (expr) - "Format the given calc expression EXPR for LaTeX. +(defun mathsheet--convert-to-eqn (expr) + "Format the given calc expression EXPR for groff. -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 "\\)"))) + EXPR should be in normal calc format. The result is the same + expression (not simplified) but in eqn format for groff." + (let ((current-language calc-language)) + (calc-set-language 'eqn) + (let* ((calc-expr (math-read-expr expr)) + (eqn-expr (math-format-stack-value (list calc-expr 1 nil))) + (eqn-expr-cleaned (replace-regexp-in-string (rx "1:" (* space)) "" eqn-expr))) + (calc-set-language current-language) + eqn-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") + 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-buffer (insert mathsheet--worksheet-template) - (goto-char (point-min)) - (search-forward "<<instruction>>") - (replace-match "") - (insert instruction) + (let ((probs-per-col (ceiling (/ (float (length problems)) prob-cols)))) + (goto-char (point-min)) + (search-forward "<<instruction>>") + (replace-match + (if (null instruction) + "" + (concat ".B \"" instruction "\""))) + + (goto-char (point-min)) + (search-forward "<<layout>>") + (replace-match "") + (insert (format ".nr ncols %d\n" prob-cols)) + (insert ".nr clen ((\\n[.l]/1000)-((\\n[ncols]-1)*10)/\\n[ncols])\n") + (insert (format ".nr cl %d\n" probs-per-col)) + (insert ".nr vs (\\n[.p]/1000-(2*72)-20-(12*\\n[cl]))/(\\n[cl]+1)") - (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")) + (insert ".AL\n") + (let ((colsize probs-per-col)) + (seq-do-indexed + (lambda (group index) + (unless (= index 0) + (insert ".NCOL\n")) + (dolist (row group) + (message "convert to eqn %s -> %s" (car row) (mathsheet--convert-to-eqn (car row))) + (insert (format (if (nth 3 row) + ".LI\n.EQ\n%s\n.EN\n.SP \\n[vs]p\n" + ".LI\n.EQ\n%s =\n.EN\n\\l'5\\_'\n.SP \\n[vs]p\n") + (mathsheet--convert-to-eqn (car row)))))) + (seq-partition problems colsize))) + (insert ".LE") (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")))) + (let ((index 0)) + (dolist (row problems) + (setq index (1+ index)) + (insert + (format ".EQ\n%d. %s%s\n.EN%s" + index + (mathsheet--convert-to-eqn (cadr row)) + (if (< index (length problems)) "\",\"~" "") + (if (< index (length problems)) "\n" "")))))) + + ;; write the groff file for debugging + ;; (write-region (point-min) (point-max) (concat fname ".mm")) + + ;; run groff to generate the pdf + (let* ((default-directory mathsheet-output-directory) + (ret (shell-command-on-region + (point-min) (point-max) + (format "groff -mm -e -Tpdf - > %s" (concat fname ".pdf"))))) + (unless (eq ret 0) + (error "PDF generation failed"))))) (when (null forms-mode-map) (add-to-list diff --git a/mathsheet.org b/mathsheet.org index 96e53c7eb3..39c7e031a3 100644 --- a/mathsheet.org +++ b/mathsheet.org @@ -13,14 +13,36 @@ 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: +Here are some example worksheets generated by this tool, along with +the templates from which they were built. Template syntax is described +in greater in the [[*Problem Templates][Problem Templates]] section below. + 1. [[file:examples/add-sub-1.pdf][arithmetic]] + made from these templates: + #+begin_example + w | o | template + --+---+------------------------------------ + 3 | 2 | [1..10] + [8..15] + 2 | 2 | [a=3..10] - [0..$a] + 1 | 3 | [1..10] + [1..7] + [1..5] + 1 | 4 | [a=1..10] + [0..10] - [0..$a] + 1 | 5 | [a=1..10] + [b=0..10] - [0..($a+$b)] + #+end_example 2. [[file:examples/algebra-1.pdf][algebra]] - -They were generated using [[file:examples/example.org][this configuration]]. + made from these templates: + #+begin_example + w | o | template + --+---+------------------------------------ + 3 | 1 | x / ([2..4] + [a=0..5]) = [$a..10] + 2 | 2 | [$a*[2..10]] / x = [a=1,2,4] + 2 | 3 | x/[-5..-1,1..5] + [1..10] = [-10..10] + 1 | 3 | x = x/[a=2..6] + [round([1..20]/$a)] + 1 | 3 | x^2 = sqrt([16 - $a] + [a=1..5]) + #+end_example ** 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. +[[https://www.gnu.org/software/groff/#introduction][Groff]] is required to generate mathsheets. If ~groff~ is not installed on +the system PDF generation will fail and there will be a ~command not +found~ message in the mini-buffer. ** Usage *** Starting Mathsheet Open mathsheet using @@html:<kbd>@@M-x@@html:</kbd>@@ @@ -170,8 +192,8 @@ file. *** 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 +~peg~ is used to parse the problem templates. ~calc~ is used to solve the +problems as well as converting them to mathematical notation in EQN format. #+begin_src elisp :noweb yes :tangle mathsheet.el @@ -244,8 +266,8 @@ 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 +~mathsheet--worksheet-template~ is the Groff template for the +worksheet, which is defined in an example block below. This assigns the constant directly to that named block. ~mathsheet--num-pat~ is defined here since it is referenced in a macro @@ -260,7 +282,7 @@ scope where the macro is called. "List of variables used within a problem.") (defconst mathsheet--worksheet-template page - "LaTeX template for the worksheet.") + "Groff template for the worksheet.") (defconst mathsheet--num-pat (rx string-start (+ num) string-end) "Pattern for integers.") @@ -413,6 +435,9 @@ This adds validation checks as needed for each field. convert the numbers. Also the problem field contains multi-line delimited data so we have to parse it. +This is also where limits are set. The max problems on a sheet +is ~50~. The max columns allowed is ~4~. + #+begin_src elisp :tangle mathsheet.el (defun mathsheet--parse (record) "Parse all of the fields of the current RECORD into an alist." @@ -423,8 +448,8 @@ data so we have to parse it. ;; 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 "count" count-str (not-null-p is-num-p (in-range-p 1 50))) + (mathsheet--validate "cols" cols-str (not-null-p is-num-p (in-range-p 1 4))) (mathsheet--validate "problems" problems-str (not-null-p)) ;; convert the numbers and parse the problems field @@ -917,123 +942,191 @@ whole set and then reorder by ~order~. #+end_src ** Generate PDF *** Lay out page -This wraps the problems with a LaTeX header and footer. +This lays out the page in Groff, with placeholders for where details +must be filled in. -This template doesn't use noweb but it uses noweb syntax (~<<label>>~) +This template doesn't use [[https://orgmode.org/manual/Noweb-Reference-Syntax.html][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} +#+begin_example nroff + .VM 0 -0.5i \" reduce bottom margin + .PH "'Name: \l'20\_'''Date:\l'10\_''" \" header + <<instruction>> + .SP 1 + .fam C \" set font + <<layout>> + .MC \n[clen]p 10p \" start columns mode + <<problems>> + .1C 1 \" end of columns mode + .BS \" floating bottom box + \l'\n(.lu' \" horizontal rule + .S 8 10 \" reduce font and vertical space + .ss 6 \" reduce horizontal space + .gcolor grey \" answers color + <<answers>> + .BE +#+end_example +*** Convert calc to EQN +This converts a calc expression to EQN format for use with Groff. The +problems and answers are generated in Emacs Calc normal format. Emacs +Calc already knows how to convert between formats, so we let it do it. - \end{document} +#+name: convert-to-eqn +#+begin_src elisp :tangle mathsheet.el + (defun mathsheet--convert-to-eqn (expr) + "Format the given calc expression EXPR for groff. + + EXPR should be in normal calc format. The result is the same + expression (not simplified) but in eqn format for groff." + (let ((current-language calc-language)) + (calc-set-language 'eqn) + (let* ((calc-expr (math-read-expr expr)) + (eqn-expr (math-format-stack-value (list calc-expr 1 nil))) + (eqn-expr-cleaned (replace-regexp-in-string (rx "1:" (* space)) "" eqn-expr))) + (calc-set-language current-language) + eqn-expr-cleaned))) #+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 "\\)"))) +#+begin_src elisp :noweb yes + <<convert-to-eqn>> + (mathsheet--convert-to-eqn "x/(1+2)=3") #+end_src + +#+RESULTS: +: x over {1 + 2} = 3 + *** 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 +template, writes it to a local file, then runs ~groff~ to build a PDF named ~[template-name].pdf~. Each execution with the same template name -will overwrite the same file. +will overwrite that file. + +Sub-sections are identified by [[https://orgmode.org/manual/Noweb-Reference-Syntax.html][noweb]] syntax (~<<section>>~). The details +of how each section is filled in is described below. -#+begin_src elisp :results silent :tangle mathsheet.el +#+begin_src elisp :results silent :noweb tangle :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") + 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-buffer (insert mathsheet--worksheet-template) - (goto-char (point-min)) - (search-forward "<<instruction>>") - (replace-match "") - (insert instruction) + (let ((probs-per-col (ceiling (/ (float (length problems)) prob-cols)))) + <<fill-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")) + <<fill-layout>> - (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")))) + <<fill-problems>> + + <<fill-answers>>) + + ;; write the groff file for debugging + ;; (write-region (point-min) (point-max) (concat fname ".mm")) + + ;; run groff to generate the pdf + (let* ((default-directory mathsheet-output-directory) + (ret (shell-command-on-region + (point-min) (point-max) + (format "groff -mm -e -Tpdf - > %s" (concat fname ".pdf"))))) + (unless (eq ret 0) + (error "PDF generation failed"))))) +#+end_src + +This fills in the instruction line. It's just a single string taken +from the config and added to the top of the sheet. + +fill-instruction: +#+name: fill-instruction +#+begin_src elisp + (goto-char (point-min)) + (search-forward "<<instruction>>") + (replace-match + (if (null instruction) + "" + (concat ".B \"" instruction "\""))) #+end_src + +This figures out the column with and spacing between rows and sets +them in registers. + +Column width is computed based on line length. Line length (~\n[.l]~) is +reported in basic units, which are 1/72000 of an inch. Line length is +6.5 inches for letter paper. + +To find the space between rows we take page height, subtract header +and footer, a little more for the instruction, some more for each of +the problems, then divide the remainder by the number of problems per +column plus one. + +Groff has no rules for order of operations but calculates left to +right and numbers are integers, so we need to include parenthesis to +ensure order of operations and use large units (like points) to reduce +loss of precision due to integer division. + +fill-layout: +#+name: fill-layout +#+begin_src elisp + (goto-char (point-min)) + (search-forward "<<layout>>") + (replace-match "") + (insert (format ".nr ncols %d\n" prob-cols)) + (insert ".nr clen ((\\n[.l]/1000)-((\\n[ncols]-1)*10)/\\n[ncols])\n") + (insert (format ".nr cl %d\n" probs-per-col)) + (insert ".nr vs (\\n[.p]/1000-(2*72)-20-(12*\\n[cl]))/(\\n[cl]+1)") +#+end_src + +Here we fill the problems into the sheet. First we group them into +columns. + +fill-problems: +#+name: fill-problems +#+begin_src elisp + (goto-char (point-min)) + (search-forward "<<problems>>") + (replace-match "") + (insert ".AL\n") + (let ((colsize probs-per-col)) + (seq-do-indexed + (lambda (group index) + (unless (= index 0) + (insert ".NCOL\n")) + (dolist (row group) + (message "convert to eqn %s -> %s" (car row) (mathsheet--convert-to-eqn (car row))) + (insert (format (if (nth 3 row) + ".LI\n.EQ\n%s\n.EN\n.SP \\n[vs]p\n" + ".LI\n.EQ\n%s =\n.EN\n\\l'5\\_'\n.SP \\n[vs]p\n") + (mathsheet--convert-to-eqn (car row)))))) + (seq-partition problems colsize))) + (insert ".LE") +#+end_src + +Here we fill in the answers. They are written as a comma delimited +list at the bottom of the sheet. + +fill-answers: +#+name: fill-answers +#+begin_src elisp + (goto-char (point-min)) + (search-forward "<<answers>>") + (replace-match "") + (let ((index 0)) + (dolist (row problems) + (setq index (1+ index)) + (insert + (format ".EQ\n%d. %s%s\n.EN%s" + index + (mathsheet--convert-to-eqn (cadr row)) + (if (< index (length problems)) "\",\"~" "") + (if (< index (length problems)) "\n" ""))))) +#+end_src + ** Convenience functions *** Add key binding to form This adds the keybinding to run the mathsheet generator from the