diff options
-rw-r--r-- | example.tex | 2 | ||||
-rw-r--r-- | glossary.bib | 13 | ||||
-rw-r--r-- | projdoc.cls | 89 | ||||
-rw-r--r-- | readme.md | 35 | ||||
-rw-r--r-- | reqs.toml | 39 | ||||
-rw-r--r-- | requirements.tex | 21 | ||||
-rwxr-xr-x | scripts/reqs2tex.py | 132 | ||||
-rw-r--r-- | scripts/tex.py | 37 | ||||
-rwxr-xr-x | scripts/time2tex.py | 111 | ||||
-rw-r--r-- | time.txt | 3 |
10 files changed, 353 insertions, 129 deletions
diff --git a/example.tex b/example.tex index e0d21c0..24c525b 100644 --- a/example.tex +++ b/example.tex @@ -3,8 +3,6 @@ % with the [draft] option. this replaces all images with placeholders. \input{meta.tex} -\externaldocument{reqs}[./requirements.pdf] - \title{Example Document} \begin{document} diff --git a/glossary.bib b/glossary.bib index 437db86..8bf48ac 100644 --- a/glossary.bib +++ b/glossary.bib @@ -33,3 +33,16 @@ long = {Application Programming Interface}, } +@acronym{reqlabel-priority, + short = {pri}, + long = {priority}, +} +@acronym{reqlabel-description, + short = {req}, + long = {requirement}, +} +@acronym{reqlabel-done, + short = {DoD}, + long = {definition of done}, +} + diff --git a/projdoc.cls b/projdoc.cls index fe8c8bc..fe6317b 100644 --- a/projdoc.cls +++ b/projdoc.cls @@ -75,6 +75,9 @@ \bigskipamount=7mm \medskipamount=4mm +% number paragraphs by default +\setcounter{secnumdepth}{4} + % section placement / appearance \newlength{\sectionpenalty} \newlength{\subsectionpenalty} @@ -145,13 +148,13 @@ % \cref{<refname>} is used \makeatletter \NewDocumentCommand{\customlabel}{omm}{% - \begingroup - \cref@constructprefix{#1}{\cref@result}% - \protected@edef\@currentlabel{#3}% - \protected@edef\@currentlabelname{#3}% - \protected@edef\cref@currentlabel{[#1][][\cref@result]#3} - \label[#1]{#2}% - \endgroup + \begingroup + \cref@constructprefix{#1}{\cref@result}% + \protected@edef\@currentlabel{#3}% + \protected@edef\@currentlabelname{#3}% + \protected@edef\cref@currentlabel{[#1][][\cref@result]#3} + \label[#1]{#2}% + \endgroup } \makeatother @@ -215,6 +218,12 @@ selection={recorded and deps and see}, ] +% allow cross-references to requirements.pdf from all documents except +% requirements.pdf itself +\IfEq*{\jobname}{requirements}{}{ + \externaldocument{reqs}[requirements.pdf] +} + % default document header/trailer \makeatletter \def\projdoc@header{ @@ -293,8 +302,8 @@ \def\UrlRight{\hbox{\,}}% } \DefineVerbatimEnvironment{blockcode}{Verbatim}{ - tabsize=2, - obeytabs, + tabsize=2, + obeytabs, } % scale down image if it exceeds page margins @@ -321,3 +330,65 @@ \crefname{test}{test}{tests} \Crefname{test}{Test}{Tests} +% fix cleveref showing filename to external cross-reference +% see <https://tex.stackexchange.com/a/708445/211562> +% edited from cleveref source +\makeatletter +\def\cref@getref#1#2{% + \expandafter\let\expandafter#2\csname r@#1@cref\endcsname% + \expandafter\expandafter\expandafter\def% + \expandafter\expandafter\expandafter#2% + \expandafter\expandafter\expandafter{% + \expandafter\@firstoffive#2}}% +\def\cpageref@getref#1#2{% + \expandafter\let\expandafter#2\csname r@#1@cref\endcsname% + \expandafter\expandafter\expandafter\def% + \expandafter\expandafter\expandafter#2% + \expandafter\expandafter\expandafter{% + \expandafter\@secondoffive#2}}% +\AtBeginDocument{% + \def\label@noarg#1{% + \cref@old@label{#1}% + \@bsphack% + \edef\@tempa{{page}{\the\c@page}}% + \setcounter{page}{1}% + \edef\@tempb{\thepage}% + \expandafter\setcounter\@tempa% + \cref@constructprefix{page}{\cref@result}% + \protected@write\@auxout{}{% + \string\newlabel{#1@cref}{% + {\cref@currentlabel}% + {[\@tempb][\arabic{page}][\cref@result]\thepage}% + {}% + {}% + {}% + }% + }% + \@esphack% + }% + \def\label@optarg[#1]#2{% + \cref@old@label{#2}% + \@bsphack% + \edef\@tempa{{page}{\the\c@page}}% + \setcounter{page}{1}% + \edef\@tempb{\thepage}% + \expandafter\setcounter\@tempa% + \cref@constructprefix{page}{\cref@result}% + \protected@edef\cref@currentlabel{% + \expandafter\cref@override@label@type% + \cref@currentlabel\@nil{#1}% + }% + \protected@write\@auxout{}{% + \string\newlabel{#2@cref}{% + {\cref@currentlabel}% + {[\@tempb][\arabic{page}][\cref@result]\thepage}% + {}% + {}% + {}% + }% + }% + \@esphack% + }% +} +\makeatother + @@ -3,26 +3,35 @@ systems programming in c++ minor project documentation Please see [style.md](./style.md) for writing style and -[contributing.md](./contributing.md) for coding and git standards. +[contributing.md](./contributing.md) for coding and git standards. There is +also [an example document](./example.tex) which may be used to copy/paste LaTeX +snippets for specific formatting. ## Compilation -Requirements: - -- A LaTeX distribution that includes the XeLaTeX compiler and latexmk +Prerequisites: +- A LaTeX distribution that includes XeLaTeX and latexmk - PlantUML - Python 3 - Fonts (see see [style.md](./style.md) for download links) -A `latexmkrc` file is provided for copmilation with latexmk. The documents -should also compile under [Visual Studio Code][vscode] using the [LaTeX -Workshop extension][latexworkshop], as well as [VimTeX][vimtex]. - -## TODO - -- Requirement cross-references are broken (they print both the label and the - path to the other document, should be label only). Interesting: - `\creflabelformat` and `\@templabel` (inside #2 of `\creflabelformat`). +All documents are compiled using latexmk, and this repository contains +additional configuration files for the following editors: +- [Visual Studio Code][vscode] + [LaTeX Workshop][latexworkshop] +- (Neo)Vim + [VimTeX][vimtex] (source `.vimrc` to fix custom verb command + highlighting) + +## Special files + +- `time.txt` contains tracked time for each team member. This file is + automatically converted using [time2tex](scripts/time2tex.py) when compiling + [timerep.tex](./timerep.tex). +- `reqs.toml` contains the project requirements. This file is converted using + [reqs2tex](scripts/reqs2tex.py) for [requirements.tex](./requirements.tex) + and also generates an `.aux` file for cross-referencing the requirements from + other documents. +- `sources.bib` contains all bibliography entries / references +- `glossary.bib` contains all glossary entries [vscode]: https://code.visualstudio.com [latexworkshop]: https://marketplace.visualstudio.com/items?itemName=James-Yu.latex-workshop @@ -10,33 +10,46 @@ # (cross-)referenced from LaTeX by prefixing this ID with `req:` and # substituting dots for colons (i.e. this requirement is referenced as # \cref{req:audio:async-api}). -[audio.async-api] +[audio] # Requirement type ('system' | 'user') -type = 'system' +type = 'user' # MoSCoW priority ('must' | 'should' | 'could' | 'will not') priority = 'must' # Requirement body. Supports LaTeX formatting. (tip: use single quotes so # backslash doesn't act as an escape character) description = ''' -The public audio \gls{api} supports starting audio samples asynchronously -(i.e.~fire and forget). +The engine allows the game programmer to easily start, pause and stop +background music, while simultaniously playing sound effects. ''' -# Definition of done (user requirements only). If 'done' is a string, it is -# treated as LaTeX code (like description), if it is a list of strings, each -# item is treated as the ID of another requirement. +# Definition of done. If 'done' is a string, it is treated as LaTeX code, if it +# is a list of strings, each item is treated as the ID of another requirement, +# and the references are checked before LaTeX runs. +done = [ + 'audio.async-api', + 'audio.handle', + 'audio.stream-mix', + 'audio.volume', +] #done = 'When I feel like it' -#done = [ 'audio.handle', 'audio.stream-mix' ] # Requirements that are no longer applicable should set `deleted` to `true`. # This will make sure the requirements are numbered consistently across # different document revisions. #deleted = true +[audio.async-api] +type = 'system' +priority = 'must' +description = ''' +The public audio \gls{api} supports starting audio samples asynchronously +(i.e.~fire and forget). +''' + [audio.handle] type = 'system' priority = 'must' description = ''' The public audio \gls{api} allows the game programmer to control (i.e.~play, -pause and stop) audio samples after they are created/initialized. +pause, resume and stop) audio samples after they are created/initialized. ''' [audio.stream-mix] @@ -46,6 +59,14 @@ description = ''' The audio system supports playing multiple audio streams simultaniously. ''' +[audio.volume] +type = 'system' +priority = 'must' +description = ''' +The public audio \gls{api} allows the game programmer to control the volume of +audio samples. +''' + [aux.license] type = 'system' priority = 'must' diff --git a/requirements.tex b/requirements.tex index 2936272..be0e103 100644 --- a/requirements.tex +++ b/requirements.tex @@ -6,6 +6,27 @@ \projdoc@description@labelindent=0pt \projdoc@setdescriptionstyle \makeatother +\setcounter{secnumdepth}{1} + +\makeatletter +\def\reqlabel#1{\csuse{reqlabel@#1}} +\def\reqlabelreset{% + \global\def\reqlabel@priority{% + \Glsdesc{reqlabel-priority}% + \global\def\reqlabel@priority{\Glstext{reqlabel-priority}}% + }% + \global\def\reqlabel@description{% + \Glsdesc{reqlabel-description}% + \global\def\reqlabel@description{\Glstext{reqlabel-description}}% + }% + \global\def\reqlabel@done{% + \Glsdesc{reqlabel-done}% + \global\def\reqlabel@done{\Glstext{reqlabel-done}}% + }% +} +% Abbreviate requirement field labels after first occurrence on every page +\AtBeginShipout{\reqlabelreset} +\makeatother \title{Requirements} diff --git a/scripts/reqs2tex.py b/scripts/reqs2tex.py index 6b7b77a..e5f063d 100755 --- a/scripts/reqs2tex.py +++ b/scripts/reqs2tex.py @@ -1,17 +1,48 @@ #!/bin/python3 import sys, tomllib, tex +from enum import StrEnum + +def label2ref(*labels): + return ",".join(["req:" + label.replace('.', ':') for label in labels]) + +class KEY(StrEnum): + LABEL = 'label' + TYPE = 'type' + ID = 'id' + INDEX = 'index' + DELETED = 'deleted' + DONE = 'done' + DESCRIPTION = 'description' + PRIORITY = 'priority' + +class REQ_TYPE(StrEnum): + SYSTEM = 'system' + USER = 'user' + +class REQ_PRIORITY(StrEnum): + MUST = 'must' + SHOULD = 'should' + COULD = 'could' + WONT = 'will not' def flatten(data): - if 'description' in data: - return [ data ] out = [] for key, value in data.items(): + # this item is a requirement + if key == KEY.DESCRIPTION: + out.append(data) + + # skip over reserved keys + if key in KEY: continue + + # recursively flatten other requirements items = flatten(value) + # and prefix them with the current key for item in items: - if 'label' in item: - item['label'] = f"{key}.{item['label']}" + if KEY.LABEL in item: + item[KEY.LABEL] = f"{key}.{item[KEY.LABEL]}" else: - item['label'] = f"{key}" + item[KEY.LABEL] = f"{key}" out += items return out @@ -20,72 +51,99 @@ def make_id(item): global id_counter id_counter += 1 return "{type_short}#{counter:03d}".format( - type_short = item['type'][0].upper(), + type_short = item[KEY.TYPE][0].upper(), counter = id_counter, ) def sanitize(item, ids): def die(msg): - print(f"[{item['label']}]: {msg}") + print(f"[{item[KEY.LABEL]}]: {msg}") exit(1) # ensure properties - item['description'] = item.get('description') - item['done'] = item.get('done') - item['priority'] = item.get('priority') - item['type'] = item.get('type') + item[KEY.DESCRIPTION] = item.get(KEY.DESCRIPTION) + item[KEY.DONE] = item.get(KEY.DONE) + item[KEY.PRIORITY] = item.get(KEY.PRIORITY) + item[KEY.TYPE] = item.get(KEY.TYPE) # type checks - if item['type'] not in ['system', 'user']: - die(f"unknown or missing requirement type {repr(item['type'])}") - if item['priority'] not in ['must', 'should', 'could', 'will not']: - die(f"unknown or missing requirement priority {repr(item['type'])}") + if item[KEY.TYPE] not in REQ_TYPE: + die(f"unknown or missing requirement type: {repr(item[KEY.TYPE])}") + if item[KEY.PRIORITY] not in REQ_PRIORITY: + die(f"unknown or missing requirement priority: {repr(item[KEY.PRIORITY])}") # conversions - if isinstance(item['done'], list): + if isinstance(item[KEY.DONE], list): # safety check - if not set(item['done']).issubset(ids): + if not set(item[KEY.DONE]).issubset(ids): die("definition of done includes unknown requirement(s)") - item['done'] = tex.cmd('Cref', tex.label2ref(*item['done'])) + item[KEY.DONE] = tex.cmd('Cref', label2ref(*item[KEY.DONE])) def convert(data): reqs = flatten(data) + all_ids = [item[KEY.LABEL] for item in reqs] index = 0 for item in reqs: - item['id'] = tex.esc(make_id(item)) - item['deleted'] = item.get('deleted', False) - if item['deleted']: continue - item['index'] = index + item[KEY.ID] = tex.esc(make_id(item)) + item[KEY.DELETED] = item.get(KEY.DELETED, False) + if item[KEY.DELETED]: continue + item[KEY.INDEX] = index index += 1 - sanitize(item, [req['label'] for req in reqs]) + sanitize(item, all_ids) # skip deleted requirements (but process for make_id) - reqs = [item for item in reqs if item['deleted'] == False] + reqs = [item for item in reqs if item[KEY.DELETED] == False] return reqs def fmt_aux(data): out = [] - for req in data: - ref = tex.label2ref(req['label']) + for item in data: + ref = label2ref(item[KEY.LABEL]) out += [ - tex.cmd('newlabel', f"{ref}", tex.group(req['id'], req['id'], 'ggg', 'hhh', 'iii')), - tex.cmd('newlabel', f"{ref}@cref", tex.group(f"[requirement][aaa][bbb]{req['id']}", '[ccc][ddd][eee]fff')), + tex.cmd('newlabel', f"{ref}", tex.group( + item[KEY.ID], + '', + '', + ref, + '', + )), + tex.cmd('newlabel', f"{ref}@cref", tex.group( + f"[requirement][][]{item[KEY.ID]}", + '[][][]', + '', + '', + '', + )), ] return "\n".join(out) def fmt_tex(data): - out = [] - for req in data: - out.append( - tex.cmd('subsection', req['id']) + "\n\n" +\ - tex.env('description', - tex.cmd('item', ['Priority']) + req['priority'].title() +\ - tex.cmd('item', ['Requirement']) + req['description'] +\ - (tex.cmd('item', ['Definition of done']) + req['done'] if req['done'] is not None else "") + out = "" + for item in data: + out += tex.join( + tex.cmd('subsection', f"{item[KEY.ID]}: {item[KEY.LABEL]}".upper()), + tex.withatletter( + tex.cmd('cref@constructprefix', 'requirement', r'\cref@result'), + tex.pedef('@currentlabel', item[KEY.ID]), + tex.pedef('@currentlabelname', item[KEY.ID]), + tex.pedef('cref@currentlabel', tex.group(['requirement'], [''], [r'\cref@result']) + item[KEY.ID]), + ), + tex.cmd('label', ['requirement'], label2ref(item[KEY.LABEL])), + tex.cmd('parbox', tex.cmd('linewidth'), + tex.env('description', tex.join( + tex.cmd('item', [tex.cmd('reqlabel', 'priority')]), + item[KEY.PRIORITY].title(), + tex.cmd('item', [tex.cmd('reqlabel', 'description')]), + item[KEY.DESCRIPTION], + *([ + tex.cmd('item', [tex.cmd('reqlabel', 'done')]), + item[KEY.DONE] + ] if item[KEY.DONE] is not None else []), + )), ) ) - return "\n\n".join(out) + return out def main(input_file): data = {} diff --git a/scripts/tex.py b/scripts/tex.py index 2509a87..07d275a 100644 --- a/scripts/tex.py +++ b/scripts/tex.py @@ -9,6 +9,9 @@ def group(*args): out += "{" + arg + "}" return out +def join(*things): + return "".join(things) + def string(content): return r"\string" + content @@ -18,11 +21,14 @@ def cmd(*args): if len(args) == 0: args = [""] return f"\\{name}" + group(*args) +def pedef(*args): + return r"\protected@edef" + cmd(*args) + def csdef(*args): return r"\def" + cmd(*args) -def auxout(content): - return r"\write\@auxout" + group(content) +def auxout(*content): + return r"\write\@auxout" + group(join(*content)) def scmd(*args): return string(cmd(*args)) @@ -47,6 +53,29 @@ def esc(plain): def tabrule(*cells): return "&".join(cells) + "\\\\" -def label2ref(*labels): - return ",".join(["req:" + label.replace('.', ':') for label in labels]) +def withatletter(*content): + return join( + cmd('makeatletter'), + *content, + cmd('makeatother'), + ) + +def explist(*items): + out = [] + for item in items: + if isinstance(item, str) or not hasattr(item, '__iter__'): + out.append(item) + else: + out += explist(*item) + return out + +def sec(level, heading): + level = max(min(3, level), 0) + section = [ + 'section', + 'subsection', + 'subsubsection', + 'paragraph', + ][level] + return cmd(section, heading) diff --git a/scripts/time2tex.py b/scripts/time2tex.py index 8c3dd9b..a5d6802 100755 --- a/scripts/time2tex.py +++ b/scripts/time2tex.py @@ -35,35 +35,30 @@ def fmt_member_overview(times): tracked[time["name"]] += time["duration"] total_time += time["duration"] - out = "" - - # header - out += tex.cmd('toprule') - out += tex.tabrule(tex.cmd('textbf', 'Member'), tex.cmd('textbf', 'Tracked'), '') - out += tex.cmd('midrule') - - # member overview members = sorted(list(set(time["name"] for time in times))) - for name in members: - out += tex.tabrule(name, fmt_duration(tracked[name]), fmt_percentage(tracked[name] / total_time)) - out += tex.cmd('midrule') - - # sum - out += tex.tabrule('', fmt_duration(total_time), '') - out += tex.cmd('bottomrule') - - out = tex.env('tabular', 'lr@{~}l', out) - out = tex.cmd('centering') +\ - out +\ - tex.cmd('caption', 'Tracked time per group member') +\ - tex.cmd('label', 'tab:time-member') - out = tex.env('table', out) - - return out + return tex.env('table', tex.join( + tex.cmd('centering'), + tex.env('tabular', 'lr@{~}l', tex.join( + tex.cmd('toprule'), + tex.tabrule(tex.cmd('textbf', 'Member'), tex.cmd('textbf', 'Tracked')), + tex.cmd('midrule'), + *[ + tex.tabrule( + name, + fmt_duration(tracked[name]), + fmt_percentage(tracked[name] / total_time)) + for name in members + ], + tex.cmd('midrule'), + tex.tabrule('', fmt_duration(total_time), ''), + tex.cmd('bottomrule'), + )), + tex.cmd('caption', 'Tracked time per group member'), + tex.cmd('label', 'tab:time-member'), + )) def fmt_weekly_overview(times): # calculations - out = "" weeks = [] member_totals = {} total_time = sum(time["duration"] for time in times) @@ -93,34 +88,40 @@ def fmt_weekly_overview(times): for member in members: member_totals[member] = sum(time["duration"] for time in times if time["name"] == member) - # TODO: refactor - # begin table - out += r"\begin{table}\centering" - out += r"\fitimg{" - out += f"\\begin{{tabular}}{{l{'r@{~}l' * len(members)}@{{\\qquad}}r}}\\toprule" - out += r"\textbf{\#}" - for member in members: - out += f"&\\textbf{{{member}}}&" - out += r"&\textbf{Subtotal}\\\midrule{}" - - for entry in weeks: - out += f"{entry['num']}" - for member in members: - out += f"&{fmt_duration(entry['members'][member])}&{fmt_percentage(entry['members'][member] / entry['total'])}" - out += f"&{fmt_duration(entry['total'])}\\\\" - - out += r"\midrule{}" - for member in members: - out += f"&{fmt_duration(member_totals[member])}&{fmt_percentage(member_totals[member] / total_time)}" - out += f"&{fmt_duration(total_time)}\\\\" - - # end table - out += r"\bottomrule\end{tabular}" - out += r"}" # \fitimg - out += r"\caption{Tracked time per week}\label{tab:time-weekly}" - out += r"\end{table}" - - return out + return tex.env('table', tex.join( + tex.cmd('centering'), + tex.cmd('fitimg', + tex.env('tabular', r'l' + r'r@{~}l' * len(members) + r'@{\qquad}r', tex.join( + tex.cmd('toprule'), + tex.tabrule(*[ + tex.cmd('textbf', cell) + for cell in [ + tex.esc("#"), + *tex.explist([ member, "" ] for member in members), + "Subtotal", + ] + ]), + tex.cmd('midrule'), + *[ + tex.tabrule(*[ + str(entry['num']), + *tex.explist( + [ + fmt_duration(entry['members'][member]), + fmt_percentage(entry['members'][member] / entry['total']), + ] + for member in members + ), + fmt_duration(entry['total']), + ]) + for entry in weeks + ], + tex.cmd('bottomrule'), + )), + ), + tex.cmd('caption', 'Tracked time per week'), + tex.cmd('label', 'tab:time-weekly'), + )) def duration2secs(duration): out = 0 # output (seconds) @@ -182,13 +183,13 @@ def parse(content): return out def fmt(times): - return "\n\n".join([ + return tex.join( tex.cmd('section', 'Overviews'), tex.cmd('subsection', 'Members'), fmt_member_overview(times), tex.cmd('subsection', 'Weekly'), fmt_weekly_overview(times), - ]) + ) def main(input_file): content = "" @@ -30,6 +30,9 @@ loek: 2024-09-15 2h55m docs :: requirements loek: 2024-09-16 2h30m docs :: requirements loek: 2024-09-17 1h20m project meeting loek: 2024-09-17 55m bugs :: cross-platform latexmk filespec +loek: 2024-09-17 2h25m docs :: requirements +loek: 2024-09-18 2h20m docs :: requirements +loek: 2024-09-18 1h50m research :: audio max: 2024-09-02 1h project kickoff max: 2024-09-02 45m first project meeting |