diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml new file mode 100644 index 0000000..96771fa --- /dev/null +++ b/.github/workflows/check.yml @@ -0,0 +1,37 @@ +name: Check + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + render: + name: Render (Quarto ${{ matrix.quarto }}) + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + # The extensions declare quarto-required >=1.7.0; test that floor and + # the current stable release. + quarto: ["1.7.34", "1.10.19"] + steps: + - uses: actions/checkout@v7 + + - name: Install XeLaTeX and image tools + run: | + sudo apt-get update + sudo apt-get install --yes --no-install-recommends \ + texlive-xetex texlive-latex-recommended texlive-latex-extra \ + texlive-fonts-recommended lmodern \ + libxml2-utils librsvg2-bin poppler-utils imagemagick + + - uses: quarto-dev/quarto-actions/setup@v2 + with: + version: ${{ matrix.quarto }} + + - run: scripts/check.sh diff --git a/.gitignore b/.gitignore index a58ad0d..337aa65 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ /example/paper.html /example/paper.pdf /example/fonts/ +/example/policyengine-wordmark.pdf +/example/policyengine-wordmark.svg /output/ /tmp/ diff --git a/README.md b/README.md index 6602462..2ac617d 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ Use `policyengine-paper-pdf` and `policyengine-paper-html` in the document Metadata: +- `wordmark`: the PolicyEngine wordmark heads the PDF and HTML title blocks; + defaults to `true`. Set `wordmark: false` to omit it from a document, or set + it under one format to omit it there only. When no author lists an + affiliation, the title block falls back to a "PolicyEngine" byline; the + wordmark replaces that fallback. Author affiliations always print. - `kicker`: short register line above the title; defaults to `WORKING PAPER`. - `revision`: revision number shown with the ISO document date. - `status`: Markdown body for the status banner; the format supplies its label. @@ -21,6 +26,23 @@ Metadata: See `example/paper.qmd` for a minimal two-format paper. +Rendering copies the format's resources beside the document: `fonts/` and +`policyengine-wordmark.pdf` for PDF, `policyengine-wordmark.svg` for HTML. +Projects with an output directory also copy the SVG beside each page. Add these +copies to `.gitignore`. + +## Development + +`scripts/check.sh` validates the SVG assets, confirms the committed PDF +wordmark still matches the SVG, and renders test documents in every paper +format with and without the wordmark. It needs Quarto, XeLaTeX, xmllint, +rsvg-convert, pdftoppm and ImageMagick; CI runs it on every pull request. + +The PDF wordmark is a vector conversion of +`_extensions/policyengine/assets/policyengine-wordmark.svg`, committed so +rendering needs no conversion step. After editing the SVG, run +`scripts/build-wordmark-pdf.sh` and commit both files. + ## License Code in this repository is released under the [MIT License](LICENSE). Original text and figures are released under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) with attribution to PolicyEngine. Third-party data and materials keep their own terms. diff --git a/_extensions/policyengine-paper/_extension.yml b/_extensions/policyengine-paper/_extension.yml index 5bc4018..6ca33f7 100644 --- a/_extensions/policyengine-paper/_extension.yml +++ b/_extensions/policyengine-paper/_extension.yml @@ -6,6 +6,8 @@ contributes: formats: common: number-sections: true + # The title block shows the theme wordmark; `wordmark: false` omits it. + wordmark: true metadata-files: - ../policyengine/theme-policyengine.yml pdf: @@ -15,6 +17,7 @@ contributes: - margin=1.1in format-resources: - ../policyengine/fonts + - ../policyengine/assets/policyengine-wordmark.pdf colorlinks: true # Source: policyengine-ui-kit/src/theme/tokens.css:51 (--border). code-block-border-left: "#E2E8F0" @@ -32,5 +35,7 @@ contributes: - default - ../policyengine/policyengine.scss css: ../policyengine/fonts.css + format-resources: + - ../policyengine/assets/policyengine-wordmark.svg template-partials: - ../policyengine/partials/title-block.html diff --git a/_extensions/policyengine/_extension.yml b/_extensions/policyengine/_extension.yml index d429344..feb5ebc 100644 --- a/_extensions/policyengine/_extension.yml +++ b/_extensions/policyengine/_extension.yml @@ -6,6 +6,8 @@ contributes: formats: common: number-sections: true + # The title block shows the theme wordmark; `wordmark: false` omits it. + wordmark: true metadata-files: - theme-policyengine.yml pdf: @@ -15,6 +17,7 @@ contributes: - margin=1.1in format-resources: - fonts + - assets/policyengine-wordmark.pdf colorlinks: true # Source: policyengine-ui-kit/src/theme/tokens.css:51 (--border). code-block-border-left: "#E2E8F0" @@ -32,5 +35,7 @@ contributes: - default - policyengine.scss css: fonts.css + format-resources: + - assets/policyengine-wordmark.svg template-partials: - partials/title-block.html diff --git a/_extensions/policyengine/assets/policyengine-wordmark.pdf b/_extensions/policyengine/assets/policyengine-wordmark.pdf new file mode 100644 index 0000000..6e22a7a Binary files /dev/null and b/_extensions/policyengine/assets/policyengine-wordmark.pdf differ diff --git a/_extensions/policyengine/assets/policyengine-wordmark.svg b/_extensions/policyengine/assets/policyengine-wordmark.svg index 4d82226..7f75c32 100644 --- a/_extensions/policyengine/assets/policyengine-wordmark.svg +++ b/_extensions/policyengine/assets/policyengine-wordmark.svg @@ -1,5 +1,5 @@ - + diff --git a/_extensions/policyengine/partials/before-title.tex b/_extensions/policyengine/partials/before-title.tex index 46a386c..a1537ff 100644 --- a/_extensions/policyengine/partials/before-title.tex +++ b/_extensions/policyengine/partials/before-title.tex @@ -5,6 +5,9 @@ \usepackage{titlesec} \usepackage{etoolbox} \usepackage{xurl} +% Quarto's template loads graphicx only under its `graphics` variable; the +% title block's wordmark needs it regardless. +\usepackage{graphicx} \setmainfont[ Path={$policyengine-font-dir$/inter/}, diff --git a/_extensions/policyengine/partials/title-block.html b/_extensions/policyengine/partials/title-block.html index 8062359..0ff95e7 100644 --- a/_extensions/policyengine/partials/title-block.html +++ b/_extensions/policyengine/partials/title-block.html @@ -8,6 +8,10 @@
+ $if(wordmark)$ +
$policyengine-theme.wordmark-alt$ + $endif$ +
$if(kicker)$$kicker$$else$WORKING PAPER$endif$ @@ -24,13 +28,17 @@

$title$

$endif$ -
$if(by-affiliation)$ +
$for(by-affiliation)$$it.name$$sep$$endfor$ +
+ $elseif(wordmark)$ + $-- The wordmark already names the theme affiliation. $elseif(policyengine-theme.affiliation)$ +
$policyengine-theme.affiliation$ - $endif$
+ $endif$ $if(revision)$
Revision $revision$$if(date-meta)$ $date-meta$$endif$
diff --git a/_extensions/policyengine/partials/title.tex b/_extensions/policyengine/partials/title.tex index 8a28f07..3e918c2 100644 --- a/_extensions/policyengine/partials/title.tex +++ b/_extensions/policyengine/partials/title.tex @@ -3,6 +3,10 @@ \begingroup \setlength{\parindent}{0pt}% \begin{flushleft} + $if(wordmark)$ + \includegraphics[height=0.3in,alt={$policyengine-theme.wordmark-alt$}]{$policyengine-theme.wordmark-pdf$}\par + \vspace{1.6em} + $endif$ {\policyenginemono\fontsize{7}{9}\selectfont {\color{policyengineaccent}Σ}\hspace{0.7em}% \MakeUppercase{$if(kicker)$$kicker$$else$WORKING PAPER$endif$}\par} @@ -18,6 +22,8 @@ $if(by-affiliation)$ {\sffamily\small\color{policyenginemuted} $for(by-affiliation)$$it.name$$sep$\enspace\textperiodcentered\enspace{}$endfor$\par} + $elseif(wordmark)$ + $-- The wordmark already names the theme affiliation. $elseif(policyengine-theme.affiliation)$ {\sffamily\small\color{policyenginemuted} $policyengine-theme.affiliation$\par} $endif$ diff --git a/_extensions/policyengine/policyengine.scss b/_extensions/policyengine/policyengine.scss index 2f0e66d..2030692 100644 --- a/_extensions/policyengine/policyengine.scss +++ b/_extensions/policyengine/policyengine.scss @@ -62,6 +62,13 @@ a:focus { margin-block: 0 2.2rem; } +.policyengine-wordmark { + display: block; + width: auto; + height: 2.25rem; + margin-bottom: 1.6rem; +} + .policyengine-kicker, .policyengine-revision, .policyengine-status-label { diff --git a/_extensions/policyengine/theme-policyengine.yml b/_extensions/policyengine/theme-policyengine.yml index 812d77b..737a46a 100644 --- a/_extensions/policyengine/theme-policyengine.yml +++ b/_extensions/policyengine/theme-policyengine.yml @@ -9,4 +9,10 @@ policyengine-theme: # Source: policyengine-ui-kit/src/theme/tokens.css:66 (--chart-3). accent-hover-tex: "285E61" affiliation: PolicyEngine - wordmark: assets/policyengine-wordmark.svg + # Quarto copies format-resources beside the document by basename, so the + # partials name the copies, not the assets/ paths. + # Source: policyengine-ui-kit/src/assets/logos/policyengine/teal.svg. + wordmark-svg: policyengine-wordmark.svg + # Built from the SVG by scripts/build-wordmark-pdf.sh. + wordmark-pdf: policyengine-wordmark.pdf + wordmark-alt: PolicyEngine diff --git a/example/paper.qmd b/example/paper.qmd index 33908ee..5fd717d 100644 --- a/example/paper.qmd +++ b/example/paper.qmd @@ -6,6 +6,8 @@ author: - name: PolicyEngine date: 2026-08-15 date-format: iso +# The wordmark heads the title block by default; false omits it. +wordmark: true kicker: working paper revision: "1" status: | @@ -13,8 +15,8 @@ status: | [local-font link](https://policyengine.org/). theme: policyengine abstract: | - A minimal paper that exercises the title, abstract, status, headings, - links, footnotes, and code treatments. + A minimal paper that exercises the wordmark, title, abstract, status, + headings, links, footnotes, and code treatments. format: policyengine-paper-pdf: default policyengine-paper-html: default diff --git a/scripts/build-wordmark-pdf.sh b/scripts/build-wordmark-pdf.sh new file mode 100755 index 0000000..9b6e850 --- /dev/null +++ b/scripts/build-wordmark-pdf.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Rebuild the vector PDF wordmark that xelatex includes in the PDF title block. +# The PDF is committed so rendering a paper needs no SVG conversion step; run +# this script whenever assets/policyengine-wordmark.svg changes, then commit +# both files. scripts/check.sh fails if the two drift apart. +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +assets="$root/_extensions/policyengine/assets" + +# A fixed epoch keeps cairo's CreationDate stable, so a rebuild with the same +# librsvg and cairo versions is byte-identical. +SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-0}" rsvg-convert \ + --format=pdf \ + --output="$assets/policyengine-wordmark.pdf" \ + "$assets/policyengine-wordmark.svg" + +echo "Wrote $assets/policyengine-wordmark.pdf" diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 0000000..8da597c --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,197 @@ +#!/usr/bin/env bash +# Validate the wordmark assets, then render test documents in every paper +# format with and without the wordmark and check each title block. Needs +# quarto, xelatex, xmllint, rsvg-convert, pdftoppm and ImageMagick's compare. +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +assets="$root/_extensions/policyengine/assets" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +failures=0 +ok() { printf 'ok - %s\n' "$1"; } +not_ok() { + printf 'not ok - %s\n' "$1" + failures=$((failures + 1)) +} +check() { + local description="$1" + shift + if "$@"; then ok "$description"; else not_ok "$description"; fi +} +has() { grep -qF -- "$2" "$1"; } +lacks() { ! grep -qF -- "$2" "$1"; } + +# The SVG assets must parse as XML; rsvg-convert and browsers reject a +# comment that contains a double hyphen. +while IFS= read -r svg; do + check "$svg parses as XML" xmllint --noout "$root/$svg" +done < <(git -C "$root" ls-files '*.svg') + +# The committed PDF must still depict the SVG. Rasterize both at the SVG's +# size and count pixels that differ by more than 10%: the two renderers +# disagree on about 0.06% (antialiasing), a blank or mirrored wordmark on +# 10-18%, so 0.5% separates drift from noise. +pdf_matches_svg() { + local width height differing + rsvg-convert --background-color=white --output="$work/svg.png" \ + "$assets/policyengine-wordmark.svg" || return 1 + read -r width height < <(identify -format '%w %h\n' "$work/svg.png") || return 1 + pdftoppm -singlefile -png -scale-to-x "$width" -scale-to-y "$height" \ + "$assets/policyengine-wordmark.pdf" "$work/pdf" || return 1 + differing="$({ compare -metric AE -fuzz 10% "$work/svg.png" "$work/pdf.png" null: 2>&1 || true; } | awk '{print $1}')" + printf '# %s of %d pixels differ\n' "$differing" "$((width * height))" + awk -v d="$differing" -v n="$((width * height))" 'BEGIN { exit !(d != "" && d / n < 0.005) }' +} +check "policyengine-wordmark.pdf matches the SVG" pdf_matches_svg + +project="$work/project" +mkdir -p "$project" +cp -R "$root/_extensions" "$project/" +printf 'project:\n type: default\n' >"$project/_quarto.yml" + +render() { + local log="$work/$1.log" + if (cd "$project" && quarto render "$1/paper.qmd" -M keep-tex:true) >"$log" 2>&1; then + ok "$1 renders" + else + not_ok "$1 renders" + sed 's/^/ /' "$log" + return 1 + fi +} + +wordmark_tex=']{policyengine-wordmark.pdf}' +wordmark_html='PolicyEngine' +theme_affiliation_tex='\color{policyenginemuted} PolicyEngine\par}' +affiliation_html='PolicyEngine' + +# The shipped example: wordmark by default, author affiliation kept. +mkdir -p "$project/example" +cp "$root/example/paper.qmd" "$project/example/" +if render example; then + d="$project/example" + check "example PDF includes the wordmark" has "$d/paper.tex" "$wordmark_tex" + check "example PDF exists" test -s "$d/paper.pdf" + check "example HTML includes the wordmark" has "$d/paper.html" "$wordmark_html" + check "example HTML wordmark file sits beside the page" test -s "$d/policyengine-wordmark.svg" + check "example HTML keeps the author affiliation" has "$d/paper.html" "$affiliation_html" +fi + +case_document() { + mkdir -p "$project/$1" + cat >"$project/$1/paper.qmd" +} + +# Without author affiliations, the wordmark replaces the theme byline. +case_document no-affiliation <<'EOF' +--- +title: "No affiliation" +author: Ada Example +format: + policyengine-paper-pdf: default + policyengine-paper-html: default +--- +Body. +EOF +if render no-affiliation; then + d="$project/no-affiliation" + check "no-affiliation PDF includes the wordmark" has "$d/paper.tex" "$wordmark_tex" + check "no-affiliation PDF omits the theme byline" lacks "$d/paper.tex" "$theme_affiliation_tex" + check "no-affiliation HTML includes the wordmark" has "$d/paper.html" "$wordmark_html" + check "no-affiliation HTML omits the theme byline" lacks "$d/paper.html" "policyengine-affiliations" +fi + +# `wordmark: false` removes it from both formats and restores the byline. +case_document opt-out <<'EOF' +--- +title: "Opt out" +author: Ada Example +wordmark: false +format: + policyengine-paper-pdf: default + policyengine-paper-html: default +--- +Body. +EOF +if render opt-out; then + d="$project/opt-out" + check "opt-out PDF omits the wordmark" lacks "$d/paper.tex" "policyengine-wordmark" + check "opt-out PDF prints the theme byline" has "$d/paper.tex" "$theme_affiliation_tex" + check "opt-out HTML omits the wordmark" lacks "$d/paper.html" "policyengine-wordmark" + check "opt-out HTML prints the theme byline" has "$d/paper.html" "$affiliation_html" +fi + +# A format-level `wordmark: false` affects only that format. +case_document pdf-opt-out <<'EOF' +--- +title: "PDF opt out" +author: Ada Example +format: + policyengine-paper-pdf: + wordmark: false + policyengine-paper-html: default +--- +Body. +EOF +if render pdf-opt-out; then + d="$project/pdf-opt-out" + check "pdf-opt-out PDF omits the wordmark" lacks "$d/paper.tex" "policyengine-wordmark" + check "pdf-opt-out HTML includes the wordmark" has "$d/paper.html" "$wordmark_html" +fi + +# Self-contained HTML embeds the SVG instead of linking it. +case_document embedded <<'EOF' +--- +title: "Embedded" +author: Ada Example +format: + policyengine-paper-html: + embed-resources: true +--- +Body. +EOF +if render embedded; then + d="$project/embedded" + check "embedded HTML inlines the wordmark" has "$d/paper.html" 'class="policyengine-wordmark" src="data:image/svg+xml;base64,' + check "embedded HTML does not link the wordmark file" lacks "$d/paper.html" 'src="policyengine-wordmark.svg"' +fi + +# The base extension's HTML format shares the partials. Its PDF format is not +# rendered here: it already failed before the wordmark, because Quarto +# resolves its policyengine-font-dir to an extension path that pandoc escapes. +case_document base-html <<'EOF' +--- +title: "Base HTML" +author: Ada Example +format: + policyengine-html: default +--- +Body. +EOF +if render base-html; then + check "base-html includes the wordmark" has "$project/base-html/paper.html" "$wordmark_html" +fi + +# Projects with an output directory copy the wordmark beside the page. +site="$work/site" +mkdir -p "$site/papers" +cp -R "$root/_extensions" "$site/" +printf 'project:\n type: website\n output-dir: _site\n' >"$site/_quarto.yml" +printf -- '---\ntitle: Home\n---\n' >"$site/index.qmd" +printf -- '---\ntitle: "In a website"\nformat: policyengine-paper-html\n---\nBody.\n' >"$site/papers/paper.qmd" +if (cd "$site" && quarto render) >"$work/site.log" 2>&1; then + ok "website renders" + check "website output includes the wordmark" has "$site/_site/papers/paper.html" "$wordmark_html" + check "website output copies the wordmark file" test -s "$site/_site/papers/policyengine-wordmark.svg" +else + not_ok "website renders" + sed 's/^/ /' "$work/site.log" +fi + +if ((failures > 0)); then + printf '%d check(s) failed\n' "$failures" + exit 1 +fi +echo "All checks passed"