Port html2pdf and CI improvements from the DeLiKo@DNB poster project
ci/html2pdf:
- Temporarily inject CSS that forces exact A0 dimensions and prevents
Chrome's print shrinking (restored via trap, also on failure)
- Enable automatic hyphenation with full google-chrome by seeding a
persistent profile with the hyphen-data component; chrome-headless-shell
(CI) bundles the dictionaries and needs no help. Language-aware via
<html lang> with a build-time warning if no dictionary is found
- Work from the input file's directory so relative asset links resolve
- Honor the optional output-path argument (previously silently ignored);
URLs still bypass injection for the CI smoke test
.gitlab-ci.yml:
- Apt mirror auto-selection with retries and timeouts
- Cache apt archives, ccache, and the R library between runs
- Install Node.js 24 LTS (distro Node 18 is EOL and too old for
current @puppeteer/browsers)
- Install emoji/symbol fonts; add ghostscript and an A1 downscale step
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Change-Id: I704f1c1749690f3a86619ad724fa3e4484c0824a
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 79961b1..63cd7ee 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -1,41 +1,175 @@
# use the verse rocker image, as it contains tidyverse, devtools and some texlive
image: rocker/tidyverse
+variables:
+ # Set `CCACHE_BASEDIR` and `CCACHE_DIR` to point `ccache` towards the cached
+ # path on the gitlab-runner. This enables to cache the output of `ccache`
+ # between various runs.
+ CCACHE_BASEDIR: "${CI_PROJECT_DIR}"
+ CCACHE_DIR: "${CI_PROJECT_DIR}/ccache"
+ # Set `ccache` to `content` to prevent rebuilding of the CI/CD containers to
+ # trigger a recreate of the cache. By using `content` the compiler's `mtime`
+ # is not considered as part of the hash.
+ CCACHE_COMPILERCHECK: "content"
+ # Enable caching for `apt-get`.
+ APT_CACHE_DIR: "${CI_PROJECT_DIR}/apt-cache"
+ # Export `noninteractive` frontend to prevent requesting user input.
+ DEBIAN_FRONTEND: "noninteractive"
+ R_LIBS_USER: "RLIB/library"
+ SOURCE_FILE: "inst/rmarkdown/templates/posterdown_ids/skeleton/skeleton.Rmd"
+
# define stages of runner. at the moment,
# just build (no test or deploy).
stages:
- build
-variables:
- SOURCE_FILE: "inst/rmarkdown/templates/posterdown_ids/skeleton/skeleton.Rmd"
-# CHROME_VERSION: "114.0.5735.198-1"
-
build-job:
stage: build
cache:
- key: icc
+ key: posterdown-ids
paths:
- - ./cache
+ - cache
+ - apt-cache/
+ - ccache/
+ - RLIB/library/
artifacts:
paths:
- "target/*"
+
before_script:
- source `find .. -name section_helper.sh`
+ - start_section apt_mirrors "Configuring apt mirror and retries"
+ - . /etc/os-release || true
+ - |
+ # Prefer mirror lists (automatic, resilient). Optionally force a German mirror
+ # by setting APT_FORCE_DE_MIRROR=1 in CI/CD variables if you want a fixed DE mirror.
+ # Handle both classic sources.list and Deb822 .sources files.
+ set -eu
+ APT_FILES=(/etc/apt/sources.list /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources)
+ # shellcheck disable=SC2068
+ APT_FILES=( ${APT_FILES[@]} )
+ echo "--- os-release (selected) ---"
+ grep -E '^(ID|VERSION_ID|VERSION_CODENAME|UBUNTU_CODENAME)=' /etc/os-release || true
+ echo "--- resolved APT source files ---"
+ for f in ${APT_FILES[@]}; do [ -e "$f" ] && echo " - $f"; done
+ echo "--- env summary ---"
+ echo "APT_FORCE_DE_MIRROR=${APT_FORCE_DE_MIRROR:-} ID=${ID:-} VERSION_CODENAME=${VERSION_CODENAME:-} UBUNTU_CODENAME=${UBUNTU_CODENAME:-}"
+ echo "--- apt sources (pre) ---"
+ for f in ${APT_FILES[@]}; do
+ [ -e "$f" ] || continue
+ echo "### $f"
+ grep -nE '^(deb|deb-src)|^(Types|URIs|Suites|Components)=' "$f" || sed -n '1,40p' "$f" || true
+ done
+ echo "--- rewrite mode decision ---"
+ AUTOPICK="${APT_AUTOPICK_MIRROR:-}"
+ if [ "${APT_FORCE_DE_MIRROR:-}" = "auto" ]; then AUTOPICK=1; fi
+ if [ "${AUTOPICK}" = "1" ]; then
+ echo "Mode=AUTO_PICK OS=${ID:-unknown}"
+ if [ "${ID:-}" = "ubuntu" ] && command -v curl >/dev/null 2>&1; then
+ CODE_NAME="${UBUNTU_CODENAME:-${VERSION_CODENAME:-}}"
+ LIST_URL="https://mirrors.ubuntu.com/mirrors.txt"
+ echo "Fetching mirror list: $LIST_URL"
+ MIRRORS=$(curl -fsSL "$LIST_URL" | grep -E '^https?://.+' | sed 's#/*$##' | head -n 20 || true)
+ BEST_URL=""; BEST_TIME="9999"
+ echo "Testing up to 20 mirrors for ${CODE_NAME} InRelease"
+ for m in $MIRRORS; do
+ TEST_URL="$m/dists/${CODE_NAME}/InRelease"
+ RES=$(curl -m 3 --connect-timeout 2 -o /dev/null -s -w '%{time_total} %{http_code}' "$TEST_URL" || echo "9.999 000")
+ TME=${RES%% *}; CODE=${RES##* }
+ echo " $(printf '%6s' "$TME")s $CODE $m"
+ if [ "$CODE" = "200" ]; then
+ BETTER=$(awk -v a="$TME" -v b="$BEST_TIME" 'BEGIN{print (a<b)?"1":"0"}')
+ if [ "$BETTER" = "1" ]; then BEST_TIME="$TME"; BEST_URL="$m"; fi
+ fi
+ done
+ if [ -n "$BEST_URL" ]; then
+ MIRROR_URL="${BEST_URL}"
+ echo "Selected fastest: $MIRROR_URL (time=${BEST_TIME}s)"
+ for f in ${APT_FILES[@]}; do
+ [ -e "$f" ] || continue
+ # Replace any Ubuntu archive base with selected mirror
+ sed -i -E "s#https?://[^ ]*/ubuntu#${MIRROR_URL}#g" "$f" || true
+ sed -i -E "s#mirror://mirrors\.ubuntu\.com/mirrors\.txt#${MIRROR_URL}#g" "$f" || true
+ done
+ else
+ echo "Autopick failed to find a working mirror; falling back to mirror list."
+ AUTOPICK=0
+ fi
+ else
+ echo "Autopick not available (non-Ubuntu or curl missing); falling back."
+ AUTOPICK=0
+ fi
+ fi
+ if [ "${AUTOPICK}" != "1" ] && [ "${APT_FORCE_DE_MIRROR:-}" = "1" ]; then
+ echo "Mode=FORCED_MIRROR OS=${ID:-unknown}"
+ if [ "${ID:-}" = "ubuntu" ]; then
+ MIRROR_URL="http://de.archive.ubuntu.com/ubuntu"
+ for f in ${APT_FILES[@]}; do
+ [ -e "$f" ] || continue
+ sed -i -E "s#https?://[^ ]*ubuntu.com/ubuntu#${MIRROR_URL}#g" "$f" || true
+ sed -i -E "s#mirror://mirrors\.ubuntu\.com/mirrors\.txt#${MIRROR_URL}#g" "$f" || true
+ done
+ elif [ "${ID:-}" = "debian" ]; then
+ MIRROR_URL="http://ftp.de.debian.org/debian"
+ for f in ${APT_FILES[@]}; do
+ [ -e "$f" ] || continue
+ sed -i -E "s#https?://(deb|ftp)[^ ]*debian.org/debian#${MIRROR_URL}#g" "$f" || true
+ sed -i -E "s#mirror://mirrors\.debian\.org/debian#${MIRROR_URL}#g" "$f" || true
+ done
+ fi
+ elif [ "${AUTOPICK}" != "1" ]; then
+ echo "Mode=MIRROR_LIST OS=${ID:-unknown}"
+ if [ "${ID:-}" = "ubuntu" ]; then
+ for f in ${APT_FILES[@]}; do [ -e "$f" ] && sed -i -E 's#https?://[^ ]*ubuntu.com/ubuntu#mirror://mirrors.ubuntu.com/mirrors.txt#g' "$f" || true; done
+ elif [ "${ID:-}" = "debian" ]; then
+ for f in ${APT_FILES[@]}; do [ -e "$f" ] && sed -i -E 's#https?://(deb|ftp)[^ ]*debian.org/debian#mirror://mirrors.debian.org/debian#g' "$f" || true; done
+ fi
+ fi
+ echo "--- apt sources (post) ---"
+ for f in ${APT_FILES[@]}; do
+ [ -e "$f" ] || continue
+ echo "### $f"
+ grep -nE '^(deb|deb-src)|^(Types|URIs|Suites|Components)=' "$f" || sed -n '1,40p' "$f" || true
+ done
+ # Show which mirror hosts remain after rewriting, to aid debugging
+ echo "APT_FORCE_DE_MIRROR=${APT_FORCE_DE_MIRROR:-} ID=${ID:-} VERSION_CODENAME=${VERSION_CODENAME:-}"
+ grep -R "archive\.ubuntu\.com/ubuntu\|ubuntu\.com/ubuntu\|debian\.org/debian\|mirrors\.ubuntu\.com\|mirrors\.debian\.org" -n /etc/apt || true
+ echo "--- apt origins (apt-cache policy) ---"
+ apt-cache policy | sed -n '1,120p' || true
+ printf 'Acquire::Retries "5";\nAcquire::http::Timeout "30";\nAcquire::https::Timeout "30";\nAcquire::http::Pipeline-Depth "0";\n' > /etc/apt/apt.conf.d/99retries
+ - end_section apt_mirrors
+
- start_section install_linux_packages "Installing missing Linux packages"
- - apt-get update
- - apt-get install -y libasound2t64 npm curl libvulkan1 libu2f-udev fonts-liberation build-essential libglpk40 libcurl4-gnutls-dev libxml2-dev libsodium-dev libsecret-1-dev libfontconfig1-dev libssl-dev libxt6 libpq-dev imagemagick inkscape scour poppler-utils fonts-stix
+ - mkdir -pv $APT_CACHE_DIR ccache $R_LIBS_USER
+ - apt-get -o Acquire::Retries=5 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update
+ - apt-get install --no-install-recommends -o dir::cache::archives="$APT_CACHE_DIR" -y libasound2t64 npm curl ccache libvulkan1 libu2f-udev fonts-liberation build-essential libglpk40 libcurl4-gnutls-dev libxml2-dev libsodium-dev libsecret-1-dev libfontconfig1-dev libssl-dev libxt6 libpq-dev imagemagick ghostscript inkscape scour poppler-utils fonts-stix
+ # The distro Node (18, EOL) is too old for current tooling. Install the
+ # current Node.js LTS (24.x) from the official tarball into /usr/local, which
+ # precedes /usr/bin on PATH so `node`/`npx` resolve to it. We deliberately do
+ # NOT use NodeSource/apt: its `nodejs` package does not satisfy `nodejs:any`,
+ # which breaks the distro `libnode-dev`/`node-acorn` sysdeps pak installs later.
+ - NODE_TARBALL=$(curl -fsSL https://nodejs.org/dist/latest-v24.x/ | grep -oE 'node-v24\.[0-9]+\.[0-9]+-linux-x64\.tar\.gz' | head -n1)
+ - curl -fsSL "https://nodejs.org/dist/latest-v24.x/$NODE_TARBALL" | tar -xz -C /usr/local --strip-components=1
+ - node --version
- npx @puppeteer/browsers install chrome-headless-shell@stable
- export CHROME=$(find ~+ -name chrome-headless-shell -type f)
- echo "CHROME=$CHROME"
- ln -s $CHROME /usr/bin/google-chrome
+ - ln -s $(which ccache) /usr/local/sbin/gcc
+ - ln -s $(which ccache) /usr/local/sbin/g++
- locale-gen de_DE.utf8 en_GB.utf8 en_US.utf8
- end_section install_linux_packages
- - start_section install_fonts "Installing Libertinus and Fira fonts"
+ - start_section testing_chromium "Testing chrome"
+ - google-chrome --version
+ - end_section testing_chromium
+
+ - start_section install_fonts "Installing Fira and emoji fonts"
- curl -Ls https://github.com/mozilla/Fira/archive/refs/tags/4.202.tar.gz | tar -C /usr/share/fonts -zx --wildcards "*.otf"
+ - apt-get install --no-install-recommends -o dir::cache::archives="$APT_CACHE_DIR" -y fonts-noto-color-emoji fonts-symbola
- fc-cache
- end_section install_fonts
@@ -63,7 +197,7 @@
- export HTML=target/$(basename $SOURCE_FILE .Rmd).html
- export PDF=target/$(basename $SOURCE_FILE .Rmd).pdf
- ./ci/html2pdf $HTML $PDF 1
+ - gs -sDEVICE=pdfwrite -dNOPAUSE -dBATCH -sPAPERSIZE=a1 -dFIXEDMEDIA -dPDFFitPage -sOutputFile=target/$(basename $SOURCE_FILE .Rmd)_a1.pdf $PDF
- inkscape -b white --pdf-poppler -o $SVG $PDF || true
- scour -i $SVG -o target/skeleton_optimized.svg --enable-viewboxing --enable-id-stripping --shorten-ids --indent=none
- end_section render
-
diff --git a/ci/html2pdf b/ci/html2pdf
index 322adcd..864f372 100755
--- a/ci/html2pdf
+++ b/ci/html2pdf
@@ -1,7 +1,103 @@
#!/bin/sh -eu
in=$1
-R -e "pagedown::chrome_print('$in',
+# Optional second argument: explicit output file. Made absolute here because we
+# may cd into the input's directory below. Further arguments are ignored.
+out_arg=""
+if [ $# -ge 2 ]; then
+ out_arg=", output='$(realpath -m "$2")'"
+fi
+
+doc=$in
+backup_file=""
+poster_lang=en
+
+# For local files (not URLs): temporarily inject CSS that forces exact A0
+# dimensions and prevents Chrome's print shrinking, and work from the file's
+# directory so relative links (e.g. skeleton_files/) resolve.
+if [ -f "$in" ]; then
+ abs_in=$(realpath "$in")
+ work_dir=$(dirname "$abs_in")
+
+ # Language the poster is written in, from <html lang="…"> (posterdown copies
+ # it from the Rmd `lang:` field). Used for the hyphenation check below.
+ poster_lang=$(sed -n 's/.*<html[^>]*[[:space:]]lang="\([A-Za-z-]*\)".*/\1/p' "$in" | head -n1)
+ poster_lang=${poster_lang:-en}
+
+ backup_file="${abs_in}.backup"
+ cp "$abs_in" "$backup_file"
+ # Restore the original file when we exit, also on failure.
+ trap '[ -n "$backup_file" ] && mv "$backup_file" "$abs_in"' EXIT
+
+ sed -i '/<\/head>/i\
+<style>\
+@page { size: 841mm 1189mm !important; margin: 0mm 0mm 20mm 0mm !important; }\
+html { width: 841mm !important; height: 1189mm !important; transform-origin: 0 0; }\
+body { width: 841mm !important; height: 1189mm !important; margin: 0 !important; padding: 0 !important; overflow: hidden !important; line-height: 1.2 !important; }\
+.poster { width: 841mm !important; height: 1189mm !important; }\
+* { line-height: 1.2 !important; }\
+p, div, span, li, ul, ol, td, th, a, strong, em, blockquote, caption, h1, h2, h3, h4, h5, h6 { line-height: 1.2 !important; }\
+</style>' "$abs_in"
+
+ cd "$work_dir"
+ doc=$(basename "$abs_in")
+fi
+
+# --- Automatic hyphenation ---------------------------------------------------
+# The template HTML carries `hyphens: auto` plus an <html lang="…"> attribute.
+# Whether Chrome actually hyphenates depends on where it finds its hyphenation
+# dictionaries:
+#
+# * chrome-headless-shell (used in CI) BUNDLES the dictionaries in a
+# `hyphen-data` folder next to the binary and loads them automatically, so
+# hyphenation already works there without any help from us.
+# * full google-chrome (typical locally) ships the dictionaries as a
+# *component* living in the normal browser profile
+# (~/.config/google-chrome/hyphen-data). pagedown::chrome_print() launches
+# Chrome with a throwaway --user-data-dir that has no such component, so
+# hyphenation silently does nothing.
+#
+# For the local case we seed a persistent profile with that component and point
+# Chrome at it: extra_args are appended after pagedown's own --user-data-dir and
+# Chrome honours the last value of a repeated switch. We symlink the whole
+# hyphen-data tree (all languages), so whatever `lang:` the poster declares is
+# respected -- nothing here is English-specific.
+lang_base=${poster_lang%%-*}
+
+chrome_profile="${CHROME_PROFILE_DIR:-$HOME/.cache/pagedown-chrome-profile}"
+user_data_dir_arg=""
+for hyphen_src in \
+ "$HOME/.config/google-chrome/hyphen-data" \
+ "$HOME/.config/google-chrome-beta/hyphen-data" \
+ "$HOME/.config/chromium/hyphen-data"; do
+ [ -d "$hyphen_src" ] || continue
+ mkdir -p "$chrome_profile"
+ ln -sfn "$hyphen_src" "$chrome_profile/hyphen-data"
+ # Only override the profile when we have something to seed; otherwise leave
+ # pagedown's default (throwaway) profile so CI's bundled dictionaries load.
+ user_data_dir_arg=",'--user-data-dir=$chrome_profile'"
+ break
+done
+
+# Warn at build time if no dictionary for the poster's language is reachable,
+# either from the seeded profile or bundled next to the Chrome binary pagedown
+# will launch. Better a clear warning than a silently un-hyphenated poster.
+chrome_bin=$(R --slave -e 'cat(pagedown::find_chrome())' 2>/dev/null || true)
+bundled_dir="/nonexistent"
+[ -n "$chrome_bin" ] && bundled_dir="$(dirname "$(readlink -f "$chrome_bin")")/hyphen-data"
+have_dict=false
+for dict in "$chrome_profile"/hyphen-data/*/hyph-"$lang_base"*.hyb \
+ "$bundled_dir"/hyph-"$lang_base"*.hyb; do
+ # Unmatched globs stay literal, so the file test simply fails for them.
+ [ -e "$dict" ] && { have_dict=true; break; }
+done
+if $have_dict; then
+ echo "html2pdf: hyphenation enabled for lang='$poster_lang'." >&2
+else
+ echo "html2pdf: WARNING - no hyphenation dictionary for lang='$poster_lang'; the PDF will not be hyphenated." >&2
+fi
+
+R -e "pagedown::chrome_print('$doc'$out_arg,
options=list(
pageRanges='1',
paperWidth=33.11,
@@ -26,6 +122,6 @@
'--force-color-profile=srgb',
'--disable-lcd-text',
'--disable-font-subpixel-positioning',
- '--run-all-compositor-stages-before-draw'
+ '--run-all-compositor-stages-before-draw'$user_data_dir_arg
)
)"