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/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
   )
 )"