diff --git a/README.md b/README.md index 5ec985e..981ca83 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,20 @@ +# public-tools + +Small tools kept for reuse. Each folder or script is self-contained; see the +section or README that goes with it. + +| Item | What it is | +|---|---| +| `latex-manuscript-tools/` | `texlisting.py` (code listings and prose quotes kept verbatim via `%@lst` / `%@quote` directives), `texdiff_prep.py` and `texdiff_abstract.py` (clean `latexdiff` PDFs), `Makefile.example` | +| `psi-letter-latex/` | `psicpsletter.cls`, a LaTeX class reproducing the PSI Center for Photon Science letter template, with the logo files and a fill-in template | +| `epics2sms.sh`, `smtp_send.py`, `epics2sms.conf.example` | EPICS PV monitor that sends SMS/mail alerts via PSI SMTP (below) | +| `openai/` | ChatGPT export (`conversations.json`) to one Markdown file per chat; API key helper | +| `check_md_links.py` | check the link targets of Markdown files | +| `export_apple_books.py` | export Apple Books highlights | +| `extract_competence_factor.py` | reviewer competence factors from Excel sheets: audit CSVs and histograms | +| `fix_dir_mtime.py` | set directory modification times from their contents | +| `foswiki_html2gitea_md.py` | Foswiki HTML to Gitea Markdown | + ## EPICS2SMS Monitor one or more EPICS PVs and send an SMS alert via PSI mail infrastructure. diff --git a/latex-manuscript-tools/Makefile.example b/latex-manuscript-tools/Makefile.example new file mode 100644 index 0000000..a0c8c19 --- /dev/null +++ b/latex-manuscript-tools/Makefile.example @@ -0,0 +1,78 @@ +# Makefile.example -- the targets that keep a LaTeX manuscript in sync with +# its code and its own history. Copy the variables and rules you need into +# the manuscript's Makefile; the three helper scripts live next to this file +# (set TOOLS to their location). +# +# make check-listings verify every %@lst block of the SI against the source +# make sync-listings rewrite the listing bodies and firstnumbers in place +# make sync-quotes rewrite the %@quote blocks of the response letter +# make diff *_diff.pdf against the SUBMITTED commit (green/red) +# make diff-coauthors *_diff_coauthors.pdf against the COAUTHORS commit + +# directory holding texlisting.py, texdiff_prep.py, texdiff_abstract.py +TOOLS := ../tools +# manuscript.tex, manuscript_SI.tex (holds the listings), response_reviewers.tex (holds the quotes) +MAIN := manuscript +SI := manuscript_SI +RESPONSE := response_reviewers +# git commits of the submitted version and of the version the coauthors last saw +SUBMITTED := 0000000 +COAUTHORS := 0000000 + +LATEXMK := latexmk +LATEXMK_OPTS := -pdf -interaction=nonstopmode -file-line-error -halt-on-error +# lstlisting bodies are treated like pictures (no intra-listing markup, which +# would break the verbatim code); additions underlined, deletions struck out +LATEXDIFF := latexdiff --type=UNDERLINE --math-markup=1 \ + --config="PICTUREENV=(?:picture|DIFnomarkup|lstlisting)[\w\d*@]*" + +DIFF_PDFS := $(MAIN)_diff.pdf $(SI)_diff.pdf +DIFF2_PDFS := $(MAIN)_diff_coauthors.pdf $(SI)_diff_coauthors.pdf + +.PHONY: check-listings sync-listings sync-quotes diff diff-coauthors + +check-listings: + python3 $(TOOLS)/texlisting.py check $(SI).tex + +sync-listings: + python3 $(TOOLS)/texlisting.py sync $(SI).tex + +sync-quotes: + python3 $(TOOLS)/texlisting.py sync $(RESPONSE).tex + +# the SI build refuses to proceed on listing drift; the response resyncs first +$(SI).pdf: $(SI).tex + python3 $(TOOLS)/texlisting.py check $(SI).tex + $(LATEXMK) $(LATEXMK_OPTS) $(SI).tex + +$(RESPONSE).pdf: $(RESPONSE).tex $(MAIN).tex $(SI).tex | sync-quotes + $(LATEXMK) $(LATEXMK_OPTS) $(RESPONSE).tex + +diff: + rm -f $(DIFF_PDFS) $(MAIN)_diff.tex $(SI)_diff.tex + $(MAKE) $(DIFF_PDFS) + +diff-coauthors: + rm -f $(DIFF2_PDFS) $(MAIN)_diff_coauthors.tex $(SI)_diff_coauthors.tex + $(MAKE) $(DIFF2_PDFS) + +define DIFF_RULE +%_$(1).tex: %.tex + @command -v latexdiff >/dev/null 2>&1 || { echo "ERROR: latexdiff not found."; exit 1; } + git show $(2):$$< > _submitted_$$< + @# moved floats and markup-only changes (\emph, percent style) are not content: normalise both sides first + python3 $(TOOLS)/texdiff_prep.py _submitted_$$< $$< _old_$$< _new_$$< + $(LATEXDIFF) _old_$$< _new_$$< > $$@ + @# recolour additions green instead of latexdiff's blue + sed -i '' -e 's/\\providecommand{\\DIFadd}\[1\]{{\\protect\\color{blue}/\\definecolor{diffadd}{rgb}{0,0.45,0}\\providecommand{\\DIFadd}[1]{{\\protect\\color{diffadd}/' \ + -e 's/\\providecommand{\\DIFaddFL}\[1\]{{\\protect\\color{blue}/\\providecommand{\\DIFaddFL}[1]{{\\protect\\color{diffadd}/' $$@ + @# an abstract that the class keeps in the preamble is skipped by latexdiff: mark it separately + @if grep -q 'begin{abstract}' $$<; then python3 $(TOOLS)/texdiff_abstract.py _old_$$< _new_$$< $$@; fi + rm -f _submitted_$$< _old_$$< _new_$$< + +%_$(1).pdf: %_$(1).tex + $(LATEXMK) $(LATEXMK_OPTS) $$< +endef + +$(eval $(call DIFF_RULE,diff,$(SUBMITTED))) +$(eval $(call DIFF_RULE,diff_coauthors,$(COAUTHORS))) diff --git a/latex-manuscript-tools/README.md b/latex-manuscript-tools/README.md new file mode 100644 index 0000000..9ed202a --- /dev/null +++ b/latex-manuscript-tools/README.md @@ -0,0 +1,107 @@ +# LaTeX manuscript tools + +Three Python scripts (standard library only) that keep a LaTeX manuscript in +sync with its code and with its own history, driven by comment directives that +LaTeX ignores. Distilled from the e18144 manuscript (`gitea.psi.ch:menzel/e18144`, +`code/python/`), where they run on every `make`. + +- `texlisting.py` — code listings and prose quotations reproduced verbatim. +- `texdiff_prep.py` — normalises two versions before `latexdiff` so that moved + floats and markup-only edits do not show as rewrites. +- `texdiff_abstract.py` — marks up an abstract that the document class keeps in + the preamble (e.g. `wlscirep`), which `latexdiff` skips. +- `Makefile.example` — the targets that wire them in (`check-listings`, + `sync-listings`, `sync-quotes`, `diff`, `diff-coauthors`). + +## texlisting.py + +**Code listings.** In the source file, delimit the block with marker comments +(any comment leader: `#`, `%`, `//`): + +```python +# @lst:reject +def reject_cross_frame(frames, valid, shifts, ...): + ... +# @lst:end +``` + +In the `.tex`, put a directive on the line immediately above the listing: + +```latex +%@lst file=../code/python/recon_core.py region=reject +\begin{lstlisting}[caption={...}, label={lst:reject}, firstnumber=118] +...body... +\end{lstlisting} +``` + +`lines=44-68` (1-based, inclusive) works instead of `region=`. `check` verifies +that every body and every `firstnumber` equal the source span and exits non-zero +on drift, naming the block; `sync` rewrites them in place. The marginal line +numbers of a listing therefore stay true when the code moves. + +**Prose quotations**, for a response to reviewers that must quote the revised +manuscript verbatim: + +```latex +%@quote file=manuscript.tex from="The registration routine itself" to="between the two." +\begin{changed}{Methods, Image registration and alignment} +...body... +\end{changed} +``` + +`from`/`to` are literal substrings of the source (first occurrence of `from`, +first occurrence of `to` after it, both inclusive); `region=` marker mode works +here too. Define the `changed` environment in the letter's preamble as you like +(the first argument is kept). If an edit removes a `from` anchor, the build +reports which block drifted instead of quoting stale text. + +```sh +python3 texlisting.py check manuscript_SI.tex # exit 1 on drift +python3 texlisting.py sync manuscript_SI.tex +python3 texlisting.py sync response_reviewers.tex +``` + +Paths in `file=` resolve relative to the `.tex` (or `--root DIR`). + +## texdiff_prep.py + +```sh +python3 texdiff_prep.py OLD.tex NEW.tex OLD_out.tex NEW_out.tex +latexdiff OLD_out.tex NEW_out.tex > diff.tex +``` + +Writes normalised copies of both versions: every float (figure/table +environment, matched by `\label`) of OLD is moved to where it stands in NEW, +anchored on the nearest preceding source line the two versions share verbatim; +`\emph{...}` and `\textit{...}` are reduced to their content and percentages +written as `$N\,\%$` become `N\%` on both sides. The diff then shows content +changes only (a changed caption still shows); the diff PDF carries no italics. +Extend `normalise()` for other cosmetic conventions of your manuscript. Not +handled: sentences moved between paragraphs, changed `\cite` keys. + +## texdiff_abstract.py + +```sh +python3 texdiff_abstract.py OLD.tex NEW.tex DIFF.tex # edits DIFF.tex in place +``` + +Diffs the two `abstract` bodies with `latexdiff` as a throw-away document and +splices the marked-up body into the abstract of `DIFF.tex`. Needed when the +class takes the abstract before `\begin{document}`. + +## Wiring (see Makefile.example) + +- The SI build runs `check` first, so a stale listing cannot build silently. +- The response build runs `sync` first, so the letter always quotes the current text. +- `make diff` / `make diff-coauthors` diff against two commits (`SUBMITTED`, + `COAUTHORS`), with `texdiff_prep.py` before and `texdiff_abstract.py` after + `latexdiff`, additions recoloured green. + +## The procedure these belong to + +Freeze the data into one deposit file; make every analysis script read only +it and print what it did; regenerate everything with one script whose logs +carry every number of the paper; listings by `%@lst`, quotes by `%@quote`, +numbers ideally by generated macros; content freeze before the style pass; +rebuild from the deposit directory alone before every submission. Written up +in the MA61 vault note "Manuscript reproducibility toolkit and procedure". diff --git a/latex-manuscript-tools/texdiff_abstract.py b/latex-manuscript-tools/texdiff_abstract.py new file mode 100644 index 0000000..978f30d --- /dev/null +++ b/latex-manuscript-tools/texdiff_abstract.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +""" +texdiff_abstract.py -- mark up the abstract in a latexdiff output. + +latexdiff leaves the preamble untouched, and the wlscirep class takes the +abstract *before* \\begin{document}, so `make diff` would show the abstract +without change marks. This helper diffs the abstract bodies of the old and new +sources with latexdiff itself (as a throw-away minimal document) and splices +the marked-up body into the abstract of the diff file. + +Usage: texdiff_abstract.py OLD.tex NEW.tex DIFF.tex (DIFF.tex is edited in place) +""" +from __future__ import annotations +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +ABS = re.compile(r"\\begin\{abstract\}(.*?)\\end\{abstract\}", re.S) + + +def body(path: str) -> str: + m = ABS.search(Path(path).read_text()) + if not m: + sys.exit(f"{path}: no abstract environment") + return m.group(1) + + +def main(old: str, new: str, diff: str) -> int: + with tempfile.TemporaryDirectory() as td: + a, b = Path(td, "a.tex"), Path(td, "b.tex") + wrap = "\\documentclass{article}\\begin{document}\n%s\n\\end{document}\n" + a.write_text(wrap % body(old)) + b.write_text(wrap % body(new)) + out = subprocess.run(["latexdiff", "--type=UNDERLINE", str(a), str(b)], + capture_output=True, text=True, check=True).stdout + marked = out.split("\\begin{document}", 1)[1].rsplit("\\end{document}", 1)[0] + src = Path(diff).read_text() + src, n = ABS.subn(lambda m: "\\begin{abstract}" + marked.strip("\n") + "\n\\end{abstract}", src, count=1) + if n != 1: + sys.exit(f"{diff}: no abstract environment") + Path(diff).write_text(src) + print(f"abstract marked up in {diff}") + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 4: + sys.exit(__doc__) + raise SystemExit(main(*sys.argv[1:])) diff --git a/latex-manuscript-tools/texdiff_prep.py b/latex-manuscript-tools/texdiff_prep.py new file mode 100644 index 0000000..07d12d8 --- /dev/null +++ b/latex-manuscript-tools/texdiff_prep.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +""" +texdiff_prep.py -- prepare two LaTeX sources for latexdiff so that the diff +shows content changes, not layout or markup changes. + +latexdiff has no move detection and reports every markup change as a word +change, so a relocated figure appears as a full deletion plus a full addition, +and \\emph{x} -> x as a rewritten word. This helper writes normalised copies of +both files in which + + 1. every float (figure / table environment, matched by its \\label) of the OLD + file is moved to where it stands in the NEW file, anchored on the nearest + preceding source line that the two files share verbatim; and + 2. cosmetic markup is unified in both files: \\emph{...} and \\textit{...} are + reduced to their content, and percentages written as $N\\,\\%$ become N\\%. + +The diff PDF therefore carries no italics and no float-position changes; a +changed caption still shows. The manuscript sources are not touched. + +Usage: texdiff_prep.py OLD.tex NEW.tex OLD_out.tex NEW_out.tex +""" +from __future__ import annotations +import re +import sys +from pathlib import Path + +FLOAT_BEGIN = re.compile(r"^\\begin\{(figure|table)\*?\}") +FLOAT_END = re.compile(r"^\\end\{(figure|table)\*?\}") +LABEL = re.compile(r"\\label\{([^}]*)\}") + + +def normalise(text: str) -> str: + # \emph{...} / \textit{...} -> content (one level of nested braces allowed) + for cmd in ("emph", "textit"): + pat = re.compile(r"\\" + cmd + r"\{((?:[^{}]|\{[^{}]*\})*)\}") + while pat.search(text): + text = pat.sub(r"\1", text) + # $N\,\%$ and ${\sim}N\,\%$ -> N\% and $\sim$N\% + text = re.sub(r"\$\{\\(sim|approx)\}([0-9.]+)\\,\\%\$", r"$\\\1$\2\\%", text) + text = re.sub(r"\$(f=|f\\approx)([0-9.]+)\\,\\%\$", r"$\1\2$\\%", text) + text = re.sub(r"\$([0-9.]+)\\,\\%\$", r"\1\\%", text) + return text + + +def floats(lines): + """[(label, start, end)] of the float environments, end exclusive.""" + out, i = [], 0 + while i < len(lines): + if FLOAT_BEGIN.match(lines[i]): + j = i + while j < len(lines) and not FLOAT_END.match(lines[j]): + j += 1 + block = "\n".join(lines[i:j + 1]) + m = LABEL.search(block) + out.append((m.group(1) if m else None, i, j + 1)) + i = j + 1 + else: + i += 1 + return out + + +def relocate(old_lines, new_lines): + """Move each labelled float of OLD to the position it has in NEW.""" + old_floats = {lab: (s, e) for lab, s, e in floats(old_lines) if lab} + new_floats = floats(new_lines) + inside_new = set() + for _, s, e in new_floats: + inside_new.update(range(s, e)) + old = list(old_lines) + for lab, s, e in new_floats: + if lab not in old_floats: + continue + # anchor: nearest preceding non-float, non-empty line of NEW present in OLD + anchor = None + k = s - 1 + while k >= 0 and anchor is None: + if k not in inside_new and new_lines[k].strip(): + cand = new_lines[k] + # search OLD outside floats + for oi, ol in enumerate(old): + if ol == cand: + anchor = oi + break + k -= 1 + if anchor is None: + continue + # current position of the float in OLD (recompute, earlier moves shift lines) + cur = {l: (a, b) for l, a, b in floats(old) if l}.get(lab) + if cur is None: + continue + a, b = cur + block = old[a:b] + del old[a:b] + if anchor > a: + anchor -= (b - a) + # insert after the anchor line, keeping one blank line before the float if NEW has one + ins = anchor + 1 + if s > 0 and not new_lines[s - 1].strip(): + old[ins:ins] = [""] + block + else: + old[ins:ins] = block + return old + + +def main(old: str, new: str, old_out: str, new_out: str) -> int: + old_lines = normalise(Path(old).read_text()).split("\n") + new_lines = normalise(Path(new).read_text()).split("\n") + old_lines = relocate(old_lines, new_lines) + Path(old_out).write_text("\n".join(old_lines)) + Path(new_out).write_text("\n".join(new_lines)) + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 5: + sys.exit(__doc__) + raise SystemExit(main(*sys.argv[1:])) diff --git a/latex-manuscript-tools/texlisting.py b/latex-manuscript-tools/texlisting.py new file mode 100644 index 0000000..d228d49 --- /dev/null +++ b/latex-manuscript-tools/texlisting.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +r""" +texlisting.py -- keep LaTeX code listings in sync with their source files. + +Reusable across manuscripts. Each listing in the .tex is introduced by a +directive comment on the line immediately above \\begin{lstlisting}: + + %@lst file=code/python/foo.py lines=129-150 # by 1-based line range + %@lst file=code/python/foo.py region=one_sweep # by named region + +For region mode, the source delimits the block with marker comments (any +comment leader works: #, %, //): + + # @lst:one_sweep + ...code... + # @lst:end + +The tool reproduces the named span in the following lstlisting body and keeps the +header's `firstnumber` equal to the span's first source line, so the marginal +numbers stay true even when the code moves. + +A second directive quotes prose instead of code, for a response-to-reviewers +letter that must reproduce revised manuscript text verbatim: + + %@quote file=e18144.tex from="The registration routine itself" to="between the two." + \begin{changed}{Methods, Image registration and alignment} + ...body... + \end{changed} + +`from=`/`to=` are literal substrings of the source (first occurrence of `from`, +first occurrence of `to` after it, both inclusive); `region=` marker mode works +here too. The environment name is `changed` (any first argument is kept). + +Modes: + check verify every block body matches its source span and firstnumber; + prints the first drift per block and exits non-zero if any drift. + sync rewrite the bodies and firstnumbers in place from the sources. + +Paths in `file=` are resolved relative to --root (default: the .tex's directory). + +Usage: + texlisting.py check [--root DIR] + texlisting.py sync [--root DIR] +""" +from __future__ import annotations +import argparse +import re +import sys +from pathlib import Path + +DIRECTIVE = re.compile(r"^%@(?:lst|quote)\s+(?P.*\S)\s*$") +BEGIN = re.compile(r"^\\begin\{(?:lstlisting|changed)\}") +END = re.compile(r"^\\end\{(?:lstlisting|changed)\}") +FIRSTNUM = re.compile(r"firstnumber=\d+") +MARK_BEGIN = re.compile(r"^\s*(?:#|%|//)\s*@lst:(?P\S+)\s*$") +MARK_END = re.compile(r"^\s*(?:#|%|//)\s*@lst:end\s*$") + + +def parse_kv(s: str) -> dict: + """key=value pairs; a value may be double-quoted to contain spaces.""" + d = {} + for k, q, v in re.findall(r'(\w+)=(?:"((?:[^"\\]|\\.)*)"|(\S+))', s): + d[k] = q.replace('\\"', '"') if q else v + return d + + +def extract(directive: dict, root: Path): + """Return (code_lines, firstnumber) for a directive, or raise ValueError.""" + src = (root / directive["file"]).read_text().splitlines() + if "from" in directive: + text = "\n".join(src) + a = text.find(directive["from"]) + if a < 0: + raise ValueError(f"from= text not found in {directive['file']}: {directive['from']!r}") + b = text.find(directive["to"], a) + if b < 0: + raise ValueError(f"to= text not found after from= in {directive['file']}: {directive['to']!r}") + quote = text[a:b + len(directive["to"])] + return [l.rstrip() for l in quote.split("\n")], text.count("\n", 0, a) + 1 + if "lines" in directive: + a, b = (int(x) for x in directive["lines"].split("-")) + return [l.rstrip() for l in src[a - 1:b]], a + if "region" in directive: + name = directive["region"] + start = end = None + for i, l in enumerate(src): + m = MARK_BEGIN.match(l) + if m and m["name"] == name: + start = i + elif start is not None and MARK_END.match(l): + end = i + break + if start is None: + raise ValueError(f"region '{name}' begin marker not found in {directive['file']}") + if end is None: + raise ValueError(f"region '{name}' end marker not found in {directive['file']}") + return [l.rstrip() for l in src[start + 1:end]], start + 2 # 1-based line after marker + raise ValueError(f"directive needs lines=, region= or from=/to=: {directive}") + + +def blocks(lines): + """Yield (dir_idx, header_idx, body_slice, end_idx, directive) for each %@lst.""" + i = 0 + while i < len(lines): + m = DIRECTIVE.match(lines[i]) + if m: + j = i + 1 + while j < len(lines) and not BEGIN.match(lines[j]) and lines[j].strip() == "": + j += 1 + if j < len(lines) and BEGIN.match(lines[j]): + k = j + 1 + while k < len(lines) and not END.match(lines[k]): + k += 1 + yield i, j, slice(j + 1, k), k, parse_kv(m["args"]) + i = k + 1 + continue + i += 1 + + +def run(texpath: Path, root: Path, mode: str) -> int: + lines = texpath.read_text().splitlines() + edits = [] # (header_idx, body_slice, end_idx, new_header, code) + ok = True + n = 0 + for di, hi, bsl, ei, directive in blocks(lines): + n += 1 + tag = directive.get("region") or directive.get("lines") or directive.get("from", "?")[:32] + try: + code, firstnum = extract(directive, root) + except (FileNotFoundError, ValueError) as e: + print(f"[ERROR ] {directive.get('file','?')} [{tag}]: {e}"); ok = False; continue + body = [l.rstrip() for l in lines[bsl]] + header = lines[hi] + have_fn = FIRSTNUM.search(header) + new_header = FIRSTNUM.sub(f"firstnumber={firstnum}", header) if have_fn else header + if mode == "check": + drift = None + hdr_fn = int(have_fn.group().split("=")[1]) if have_fn else None + if hdr_fn is not None and hdr_fn != firstnum: + drift = f"firstnumber header={hdr_fn} source={firstnum}" + else: + for a, b in zip(body, code): + if a != b: + drift = f"body:\n tex: {a!r}\n src: {b!r}"; break + else: + if len(body) != len(code): + drift = f"length tex={len(body)} src={len(code)}" + if drift: + ok = False + print(f"[DRIFT ] {directive['file']} [{tag}] -> {drift}") + else: + print(f"[OK ] {directive['file']} [{tag}] first={firstnum} lines={len(code)}") + else: # sync + if new_header != header or body != code: + edits.append((hi, bsl, ei, new_header, code)) + if mode == "sync": + # apply from the bottom so indices stay valid + for hi, bsl, ei, new_header, code in sorted(edits, key=lambda e: -e[0]): + lines[hi] = new_header + lines[bsl.start:ei] = code + if edits: + texpath.write_text("\n".join(lines) + "\n") + print(f"sync: {len(edits)} of {n} listing(s) updated -> {texpath}") + return 0 + print(f"\n{'ALL MATCH' if ok else '>>> LISTING DRIFT'} ({n} listings)") + return 0 if ok else 1 + + +def main(argv=None) -> int: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("mode", choices=["check", "sync"]) + ap.add_argument("texfile", type=Path) + ap.add_argument("--root", type=Path, default=None, + help="base dir for file= paths (default: the .tex's directory)") + args = ap.parse_args(argv) + root = args.root or args.texfile.resolve().parent + return run(args.texfile, root, args.mode) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/psi-letter-latex/README.md b/psi-letter-latex/README.md new file mode 100644 index 0000000..ffbdad6 --- /dev/null +++ b/psi-letter-latex/README.md @@ -0,0 +1,66 @@ +# PSI Center for Photon Science: LaTeX letter template + +A LaTeX class that reproduces the look of the PSI Center for Photon Science +Word letter template (`VO-9722-835_0_CPS Word Template Letter (EN).dotx`): +top-left logo on every page, small sender block in the upper right, recipient / +date / bold subject / body / two-signatory closing, and a "Page X / Y" footer. + +## Files + +- `psicpsletter.cls` — the letter class. +- `letter_template.tex` — a fill-in-the-blanks starting point. +- `cps_01_sp.pdf` / `.svg` / `.png` / `.eps` — the CPS logo (black "positive" + version, `sp`). `cps_01_sn.*` is the white "negative" version for dark + backgrounds. The class uses `cps_01_sp.pdf`. + +## Build + +```sh +latexmk -pdf letter_template.tex +``` + +Uses **pdflatex**. The class file and `cps_01_sp.pdf` must sit in the same +directory as your `.tex` file (copy the two next to your letter). Used for the +cover letters of the e18144 manuscript (`latex/cover_letter_*.tex` there). + +## Filling in a letter + +```latex +\documentclass{psicpsletter} + +\sendername{Andreas Menzel} +\senderjob{Center for Photon Science} +\senderroom{WLGA/018} +\senderdirect{45 66} % completes "Direct +41 56 310 __ __" +\senderemail{andreas.menzel} % completes "@psi.ch" + +\recipient{The Editorial Board\\ \textit{Scientific Reports}\\ Nature Portfolio} +\letterplace{Villigen PSI} % default +\letterdate{17 July 2026} % omit to use \today +\subject{Your subject line} + +\begin{document} +\opening{Dear ...,} + +Body paragraphs, separated by blank lines. + +\closing{Yours sincerely,} +\signatures + {\sigcell{}{First Last}{Function}} % left signatory + {\sigcell{}{First Last}{Function}} % right signatory (leave {} for one) +\end{document} +``` + +`\sigcell{}{}{}` — pass an `\includegraphics{...}` of a +scanned signature as the first argument, or leave it empty (`{}`) for blank +space to sign by hand. + +## Fidelity notes + +- Geometry, font sizes and spacing are taken from the `.dotx`: A4; margins + 25/25/49/20 mm; body 10.5/14 pt; sender block 8 pt; subject 12 pt bold. +- The Word template uses **Aptos**, which is not a free/TeX font. This class + substitutes **Source Sans Pro**, the closest freely available neo-grotesque. + To change it, edit the single `\RequirePackage` line marked `FONT` in the + class (e.g. `lato`), or switch to xelatex/lualatex + `fontspec` with the real + Aptos font if you have it installed. diff --git a/psi-letter-latex/cps_01_sn.pdf b/psi-letter-latex/cps_01_sn.pdf new file mode 100644 index 0000000..60de88c Binary files /dev/null and b/psi-letter-latex/cps_01_sn.pdf differ diff --git a/psi-letter-latex/cps_01_sn.png b/psi-letter-latex/cps_01_sn.png new file mode 100644 index 0000000..9537190 Binary files /dev/null and b/psi-letter-latex/cps_01_sn.png differ diff --git a/psi-letter-latex/cps_01_sn.svg b/psi-letter-latex/cps_01_sn.svg new file mode 100644 index 0000000..ec3a276 --- /dev/null +++ b/psi-letter-latex/cps_01_sn.svg @@ -0,0 +1,675 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/psi-letter-latex/cps_01_sp.eps b/psi-letter-latex/cps_01_sp.eps new file mode 100644 index 0000000..4d6379c Binary files /dev/null and b/psi-letter-latex/cps_01_sp.eps differ diff --git a/psi-letter-latex/cps_01_sp.pdf b/psi-letter-latex/cps_01_sp.pdf new file mode 100644 index 0000000..6da28d7 Binary files /dev/null and b/psi-letter-latex/cps_01_sp.pdf differ diff --git a/psi-letter-latex/cps_01_sp.png b/psi-letter-latex/cps_01_sp.png new file mode 100644 index 0000000..61420b1 Binary files /dev/null and b/psi-letter-latex/cps_01_sp.png differ diff --git a/psi-letter-latex/cps_01_sp.svg b/psi-letter-latex/cps_01_sp.svg new file mode 100644 index 0000000..a3430c3 --- /dev/null +++ b/psi-letter-latex/cps_01_sp.svg @@ -0,0 +1,671 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/psi-letter-latex/letter_template.pdf b/psi-letter-latex/letter_template.pdf new file mode 100644 index 0000000..f7e8bf5 Binary files /dev/null and b/psi-letter-latex/letter_template.pdf differ diff --git a/psi-letter-latex/letter_template.tex b/psi-letter-latex/letter_template.tex new file mode 100644 index 0000000..0b1aca4 --- /dev/null +++ b/psi-letter-latex/letter_template.tex @@ -0,0 +1,43 @@ +%% letter_template.tex — starting point for a PSI Center for Photon Science +%% letter. Copy this file, fill in the fields, and build with: +%% +%% latexmk -pdf letter_template.tex +%% +%% The class file psicpsletter.cls and the logo cps_01_sp.pdf must sit next to +%% the .tex file (as they do in this folder). Placeholders below mirror the +%% original Word template fields. + +\documentclass{psicpsletter} + +% --- personal sender details (upper-right block) ---------------------------- +\sendername{First name Surname} +\senderjob{Job title or function} +\senderroom{Building/Room number} +\senderdirect{00 00} % completes "Direct +41 56 310 __ __" +\senderemail{firstname.surname} % completes "@psi.ch" + +% --- recipient, place, date, subject ---------------------------------------- +\recipient{% + Recipient name\\ + Organisation\\ + Street\\ + Postcode City\\ + Country% +} +\letterplace{Villigen PSI} +\letterdate{4 April 2024} % omit to use \today +\subject{Subject} + +\begin{document} + +\opening{Salutation} + +Text. + +\closing{Yours sincerely} + +\signatures + {\sigcell{}{First name Surname}{Function}} + {\sigcell{}{First name Surname}{Function}} + +\end{document} diff --git a/psi-letter-latex/psicpsletter.cls b/psi-letter-latex/psicpsletter.cls new file mode 100644 index 0000000..de2bbd0 --- /dev/null +++ b/psi-letter-latex/psicpsletter.cls @@ -0,0 +1,168 @@ +%% psicpsletter.cls — LaTeX letter class mimicking the PSI Center for Photon +%% Science Word letter template (VO-9722-835, "CPS Word Template Letter"). +%% +%% Reproduces the template's page geometry, the small upper-right sender block, +%% the recipient / date / bold-subject / body / two-signatory closing, the +%% top-left logo on every page, and the "Page X / Y" footer. +%% +%% Engine: pdflatex (matches the e18144 manuscript build). latexmk -pdf. +%% Font: the template uses Aptos, which is not a free/TeX font. The closest +%% freely available neo-grotesque is Source Sans Pro, used here. To +%% switch, change the single \RequirePackage line marked FONT below +%% (e.g. lato, or fontspec+Aptos with xelatex/lualatex). +%% +%% Geometry is taken verbatim from the .dotx: A4, margins 25/25/49/20 mm, +%% body 10.5/14 pt, sender 8/10 pt, subject 12 pt bold, signatory function 9 pt. + +\NeedsTeXFormat{LaTeX2e} +\ProvidesClass{psicpsletter}[2026/07/17 PSI Center for Photon Science letter] + +\LoadClass[10pt,a4paper]{article} + +% ---------------------------------------------------------------- packages --- +\RequirePackage[T1]{fontenc} +\RequirePackage[utf8]{inputenc} +\RequirePackage[default]{sourcesanspro} % FONT: Aptos substitute (neo-grotesque) +\RequirePackage[english]{babel} +\RequirePackage{graphicx} +\RequirePackage{ragged2e} +\RequirePackage[absolute,overlay]{textpos} +\RequirePackage{eso-pic} +\RequirePackage{lastpage} +\RequirePackage{calc} +\RequirePackage[a4paper,left=25mm,right=25mm,top=49mm,bottom=20mm]{geometry} + +% flat-left body, no paragraph indent, blank line between paragraphs +\setlength{\parindent}{0pt} +\setlength{\parskip}{\medskipamount} +\RaggedRight + +% --------------------------------------------------- base font size / leading +% Template body: 10.5 pt on 14 pt leading. +\renewcommand\normalsize{\fontsize{10.5}{14}\selectfont} +\normalsize +\AtBeginDocument{\normalsize} + +% suppress article's centred page number; the footer is drawn via eso-pic +\pagestyle{empty} +\AtBeginDocument{\pagestyle{empty}\thispagestyle{empty}} + +% ------------------------------------------------------------ layout metrics - +% Logo (top-left, all pages). Provided vector logo cps_01_sp (2.74:1). +\newcommand{\psi@logofile}{cps_01_sp} +\newlength{\psi@logowidth}\setlength{\psi@logowidth}{48mm} +\newlength{\psi@logoleft}\setlength{\psi@logoleft}{25mm} % left text margin +\newlength{\psi@logotop}\setlength{\psi@logotop}{11mm} % from page top + +% Sender block (top-right, page 1). Right edge at right text margin (185 mm). +\newlength{\psi@senderwidth}\setlength{\psi@senderwidth}{45mm} +\newlength{\psi@senderleft}\setlength{\psi@senderleft}{140mm} +\newlength{\psi@sendertop}\setlength{\psi@sendertop}{11mm} + +% textpos in millimetres +\setlength{\TPHorizModule}{1mm}\setlength{\TPVertModule}{1mm} + +% ----------------------------------------------------------- sender fields --- +% Fixed institutional block (rarely changed). +\newcommand{\psi@instname}{Paul Scherrer Institute PSI} +\newcommand{\psi@center}{Center for Photon Science} +\newcommand{\psi@street}{Forschungsstrasse 111} +\newcommand{\psi@city}{5232 Villigen PSI} +\newcommand{\psi@country}{Switzerland} +\newcommand{\psi@phone}{Phone +41 56 310 21 11} +\newcommand{\psi@web}{www.psi.ch} + +% Personal fields (set per letter). Sensible defaults / placeholders. +\newcommand{\sendername}[1]{\def\psi@sendername{#1}} \def\psi@sendername{First name Surname} +\newcommand{\senderjob}[1]{\def\psi@senderjob{#1}} \def\psi@senderjob{Job title or function} +\newcommand{\senderroom}[1]{\def\psi@senderroom{#1}} \def\psi@senderroom{Building/Room number} +\newcommand{\senderdirect}[1]{\def\psi@senderdirect{#1}} \def\psi@senderdirect{00 00} +\newcommand{\senderemail}[1]{\def\psi@senderemail{#1}} \def\psi@senderemail{firstname.surname} + +% ---------------------------------------------------------- letter fields ---- +\newcommand{\recipient}[1]{\def\psi@recipient{#1}} \def\psi@recipient{Recipient} +\newcommand{\subject}[1]{\def\psi@subject{#1}} \def\psi@subject{Subject} +\newcommand{\letterplace}[1]{\def\psi@place{#1}} \def\psi@place{Villigen PSI} +\newcommand{\letterdate}[1]{\def\psi@date{#1}} \def\psi@date{\today} + +% ------------------------------------------------------- per-page overlays --- +% Logo on every page. +\AddToShipoutPictureFG{% + \put(\LenToUnit{\psi@logoleft},\LenToUnit{\paperheight-\psi@logotop}){% + \raisebox{-\height}{\includegraphics[width=\psi@logowidth]{\psi@logofile}}}% +} +% Footer "Page X / Y", small, right-aligned, on every page. +\AddToShipoutPictureFG{% + \put(\LenToUnit{25mm},\LenToUnit{12mm}){% + \makebox[160mm][r]{\fontsize{8}{10}\selectfont + Page \thepage\ /\ \pageref*{LastPage}}}% +} + +% ------------------------------------------------------- sender block (p.1) -- +\newcommand{\psi@senderblock}{% + \begin{textblock*}{\psi@senderwidth}(\psi@senderleft,\psi@sendertop)% + \fontsize{8}{10}\selectfont\RaggedRight + \textbf{\psi@instname}\\ + \psi@center\par + \psi@street\\ + \psi@city\\ + \psi@country\par + \psi@phone\\ + \psi@web + \vspace{10pt}\par + \textbf{\psi@sendername}\\ + \psi@senderjob\par + \psi@senderroom\\ + Direct +41 56 310 \psi@senderdirect\\ + \psi@senderemail @psi.ch% + \end{textblock*}% +} + +% ------------------------------------------------------------ opening area --- +% Prints the sender block (p.1), recipient, date, and bold subject, then the +% salutation. Call \opening{Dear ...} once at the start of the letter body. +\newcommand{\opening}[1]{% + \psi@senderblock + % recipient address, top of the flow + {\psi@recipient\par} + % date, with generous space above (matches template's large "before") + {\vspace{9mm}\noindent\psi@place, \psi@date\par} + % bold subject line + {\vspace{6mm}\noindent\fontsize{12}{15}\selectfont\bfseries\psi@subject\par} + % salutation + {\vspace{5mm}\noindent #1\par} + \vspace{\medskipamount}% +} + +% ------------------------------------------------------------- closing area -- +% \closing{Yours sincerely} prints the sign-off + institution line. +\newcommand{\closing}[1]{% + \par\vspace{\medskipamount}% + \noindent #1\par + \noindent\psi@instname\par +} + +% One signatory cell: optional signature image, bold name, 9 pt function. +% \sigcell{}{}{} +\newcommand{\sigcell}[3]{% + \begin{minipage}[t]{78mm}% + \raggedright + \setlength{\parskip}{0pt}% + \ifx\relax#1\relax + \rule{0pt}{16mm}% empty signature space + \else + \raisebox{0pt}[16mm][0pt]{\raisebox{-\height}{#1}}% + \fi\par + \textbf{#2}\\ + {\fontsize{9}{11}\selectfont #3}% + \end{minipage}% +} + +% Two signatories side by side (leave the second empty for a single signatory). +% \signatures{\sigcell{...}{...}{...}}{\sigcell{...}{...}{...}} +\newcommand{\signatures}[2]{% + \par\vspace{5mm}\noindent + #1\hfill#2\par +} + +\endinput