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
-