aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLoek Le Blansch <loek@pipeframe.xyz>2024-09-18 15:07:19 +0200
committerLoek Le Blansch <loek@pipeframe.xyz>2024-09-18 15:07:19 +0200
commit927764af4da43d1813ff58a6fab25f433c571e98 (patch)
tree17025b34953057279e0afd21583eaa83de930dcb
parent9f736b9e3d4b20c7dae1063bd572c1f802cde649 (diff)
parentabab5051391b7f8a212539400433eb1db0bb4f06 (diff)
Merge branch 'loek/requirements'
-rw-r--r--example.tex2
-rw-r--r--glossary.bib13
-rw-r--r--projdoc.cls86
-rw-r--r--readme.md6
-rw-r--r--reqs.toml39
-rw-r--r--requirements.tex22
-rwxr-xr-xscripts/reqs2tex.py132
-rw-r--r--scripts/tex.py37
-rwxr-xr-xscripts/time2tex.py111
9 files changed, 326 insertions, 122 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..fccf8c1 100644
--- a/projdoc.cls
+++ b/projdoc.cls
@@ -145,13 +145,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 +215,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 +299,8 @@
\def\UrlRight{\hbox{\,}}%
}
\DefineVerbatimEnvironment{blockcode}{Verbatim}{
- tabsize=2,
- obeytabs,
+ tabsize=2,
+ obeytabs,
}
% scale down image if it exceeds page margins
@@ -321,3 +327,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
+
diff --git a/readme.md b/readme.md
index 7b58cfd..818d445 100644
--- a/readme.md
+++ b/readme.md
@@ -18,12 +18,6 @@ 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`).
-
[vscode]: https://code.visualstudio.com
[latexworkshop]: https://marketplace.visualstudio.com/items?itemName=James-Yu.latex-workshop
[vimtex]: https://github.com/lervag/vimtex
diff --git a/reqs.toml b/reqs.toml
index c05cf71..6645ea4 100644
--- a/reqs.toml
+++ b/reqs.toml
@@ -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..78496e8 100644
--- a/requirements.tex
+++ b/requirements.tex
@@ -6,6 +6,28 @@
\projdoc@description@labelindent=0pt
\projdoc@setdescriptionstyle
\makeatother
+\setcounter{secnumdepth}{1}
+
+\usepackage{bophook}
+\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
+\AtBeginPage{\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 = ""