handover-to-pdf.sh 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265
  1. #!/usr/bin/env bash
  2. #
  3. # handover-to-pdf.sh
  4. # ------------------
  5. # Renders a client-handover Markdown report (LIVRAISON.md / HANDOVER.md)
  6. # into a branded HTML and (when a converter is available) a PDF using
  7. # ZenQuality brand styling.
  8. #
  9. # Inputs:
  10. # $1 Path to the source Markdown file (required)
  11. #
  12. # Optional environment variables:
  13. # PROJECT_NAME Displayed on the cover and as PDF page header.
  14. # Defaults to the source filename.
  15. # CLIENT_NAME Displayed on the cover. Defaults to "—".
  16. # PROJECT_PERIOD Displayed on the cover (e.g. "01/01/2026 → 31/03/2026").
  17. # Defaults to "—".
  18. # PROJECT_URL Displayed on the cover. Defaults to "—".
  19. # LANG "fr" (default) or "en". Drives cover labels.
  20. # COVER_TITLE Defaults to PROJECT_NAME.
  21. # COVER_SUBTITLE Defaults to "Compte rendu de livraison" (fr) /
  22. # "Project handover recap" (en).
  23. # EYEBROW Eyebrow line above the title. Defaults to
  24. # "Livraison" / "Handover".
  25. # LOGO_URL Logo URL or local path. Defaults to a remote
  26. # ZenQuality logo (no offline fallback).
  27. # BRANDING_DIR Override branding-asset directory. Defaults to the
  28. # resources/branding/ folder next to this script.
  29. #
  30. # Behaviour:
  31. # 1. Convert the Markdown body to HTML.
  32. # 2. Wrap it in the ZenQuality template (cover + branded body).
  33. # 3. Convert that HTML into a PDF using the first available engine:
  34. # weasyprint > wkhtmltopdf > chromium > headless Chrome
  35. # 4. Always keep the .html file next to the .md.
  36. # 5. If no PDF engine is available, exit with code 2 and a clear
  37. # message — never fail silently.
  38. #
  39. # Exit codes:
  40. # 0 HTML and PDF written successfully.
  41. # 1 Fatal error (bad arguments, missing files, conversion error).
  42. # 2 HTML written but no PDF engine available — manual print needed.
  43. set -euo pipefail
  44. # ---------------------------- CLI ----------------------------------
  45. if [ "$#" -lt 1 ]; then
  46. echo "usage: handover-to-pdf.sh <markdown-file>" >&2
  47. exit 1
  48. fi
  49. SRC_MD="$1"
  50. if [ ! -f "$SRC_MD" ]; then
  51. echo "error: markdown file not found: $SRC_MD" >&2
  52. exit 1
  53. fi
  54. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  55. DEFAULT_BRANDING_DIR="$SCRIPT_DIR/../resources/branding"
  56. BRANDING_DIR="${BRANDING_DIR:-$DEFAULT_BRANDING_DIR}"
  57. if [ ! -f "$BRANDING_DIR/zenquality.css" ] || [ ! -f "$BRANDING_DIR/zenquality-template.html" ]; then
  58. echo "error: branding assets missing under $BRANDING_DIR" >&2
  59. echo " expected: zenquality.css + zenquality-template.html" >&2
  60. exit 1
  61. fi
  62. OUT_DIR="$(cd "$(dirname "$SRC_MD")" && pwd)"
  63. BASE="$(basename "$SRC_MD" .md)"
  64. OUT_HTML="$OUT_DIR/$BASE.html"
  65. OUT_PDF="$OUT_DIR/$BASE.pdf"
  66. LANG_CODE="${LANG:-fr}"
  67. case "$LANG_CODE" in
  68. en|EN|en_*|en-*)
  69. LANG_CODE="en"
  70. EYEBROW="${EYEBROW:-Project handover}"
  71. DEFAULT_SUBTITLE="Project handover recap"
  72. LABEL_CLIENT="Client"
  73. LABEL_PROJECT="Project"
  74. LABEL_DATE="Issued"
  75. LABEL_PERIOD="Period"
  76. LABEL_URL="Website"
  77. LABEL_PREPARED_BY="prepared for the client"
  78. ;;
  79. *)
  80. LANG_CODE="fr"
  81. EYEBROW="${EYEBROW:-Livraison}"
  82. DEFAULT_SUBTITLE="Compte rendu de livraison"
  83. LABEL_CLIENT="Client"
  84. LABEL_PROJECT="Projet"
  85. LABEL_DATE="Date d'émission"
  86. LABEL_PERIOD="Période"
  87. LABEL_URL="Site"
  88. LABEL_PREPARED_BY="préparé pour le client"
  89. ;;
  90. esac
  91. PROJECT_NAME_RESOLVED="${PROJECT_NAME:-$BASE}"
  92. CLIENT_NAME_RESOLVED="${CLIENT_NAME:-—}"
  93. PROJECT_PERIOD_RESOLVED="${PROJECT_PERIOD:-—}"
  94. PROJECT_URL_RESOLVED="${PROJECT_URL:-—}"
  95. COVER_TITLE_RESOLVED="${COVER_TITLE:-$PROJECT_NAME_RESOLVED}"
  96. COVER_SUBTITLE_RESOLVED="${COVER_SUBTITLE:-$DEFAULT_SUBTITLE}"
  97. LOGO_URL_RESOLVED="${LOGO_URL:-https://zenquality.fr/logo-horizontal.svg}"
  98. if command -v date >/dev/null 2>&1; then
  99. if [ "$LANG_CODE" = "fr" ]; then
  100. DATE_HUMAN="$(LC_ALL=fr_FR.UTF-8 date "+%d %B %Y" 2>/dev/null || date "+%Y-%m-%d")"
  101. else
  102. DATE_HUMAN="$(LC_ALL=en_US.UTF-8 date "+%d %B %Y" 2>/dev/null || date "+%Y-%m-%d")"
  103. fi
  104. else
  105. DATE_HUMAN="$(date "+%Y-%m-%d")"
  106. fi
  107. # ---------------------------- MD -> HTML ---------------------------
  108. md_to_html_body() {
  109. local src="$1"
  110. if command -v pandoc >/dev/null 2>&1; then
  111. pandoc --from=gfm --to=html5 --no-highlight "$src"
  112. return
  113. fi
  114. if command -v python3 >/dev/null 2>&1 && python3 -c "import markdown" >/dev/null 2>&1; then
  115. python3 -c "
  116. import sys, markdown
  117. src = open(sys.argv[1], encoding='utf-8').read()
  118. print(markdown.markdown(
  119. src,
  120. extensions=['extra', 'tables', 'sane_lists', 'toc'],
  121. ))" "$src"
  122. return
  123. fi
  124. if command -v npx >/dev/null 2>&1; then
  125. npx --yes marked < "$src"
  126. return
  127. fi
  128. echo "error: no Markdown converter available (need pandoc, python3+markdown, or npx)" >&2
  129. exit 1
  130. }
  131. BODY_HTML="$(md_to_html_body "$SRC_MD")"
  132. # ---------------------------- WRAP HTML ----------------------------
  133. CSS_CONTENT="$(cat "$BRANDING_DIR/zenquality.css")"
  134. render_template() {
  135. # Read template path from $1, output the substituted HTML on stdout.
  136. # Substitution variables are pulled from HQ_* environment variables.
  137. HQ_TEMPLATE_PATH="$1" python3 <<'PY'
  138. import os, sys
  139. path = os.environ["HQ_TEMPLATE_PATH"]
  140. with open(path, encoding="utf-8") as f:
  141. template = f.read()
  142. mapping = {
  143. "{{LANG}}": os.environ.get("HQ_LANG", "fr"),
  144. "{{TITLE}}": os.environ.get("HQ_TITLE", ""),
  145. "{{CSS}}": os.environ.get("HQ_CSS", ""),
  146. "{{LOGO_URL}}": os.environ.get("HQ_LOGO_URL", ""),
  147. "{{EYEBROW}}": os.environ.get("HQ_EYEBROW", ""),
  148. "{{COVER_TITLE}}": os.environ.get("HQ_COVER_TITLE", ""),
  149. "{{COVER_SUBTITLE}}": os.environ.get("HQ_COVER_SUBTITLE", ""),
  150. "{{CLIENT_NAME}}": os.environ.get("HQ_CLIENT_NAME", "—"),
  151. "{{PROJECT_NAME}}": os.environ.get("HQ_PROJECT_NAME", ""),
  152. "{{DATE_HUMAN}}": os.environ.get("HQ_DATE_HUMAN", ""),
  153. "{{PROJECT_PERIOD}}": os.environ.get("HQ_PROJECT_PERIOD", "—"),
  154. "{{PROJECT_URL}}": os.environ.get("HQ_PROJECT_URL", "—"),
  155. "{{LABEL_CLIENT}}": os.environ.get("HQ_LABEL_CLIENT", ""),
  156. "{{LABEL_PROJECT}}": os.environ.get("HQ_LABEL_PROJECT", ""),
  157. "{{LABEL_DATE}}": os.environ.get("HQ_LABEL_DATE", ""),
  158. "{{LABEL_PERIOD}}": os.environ.get("HQ_LABEL_PERIOD", ""),
  159. "{{LABEL_URL}}": os.environ.get("HQ_LABEL_URL", ""),
  160. "{{LABEL_PREPARED_BY}}": os.environ.get("HQ_LABEL_PREPARED_BY", ""),
  161. "{{CONTENT}}": os.environ.get("HQ_CONTENT", ""),
  162. }
  163. for k, v in mapping.items():
  164. template = template.replace(k, v)
  165. sys.stdout.write(template)
  166. PY
  167. }
  168. export HQ_LANG="$LANG_CODE"
  169. export HQ_TITLE="$COVER_TITLE_RESOLVED"
  170. export HQ_CSS="$CSS_CONTENT"
  171. export HQ_LOGO_URL="$LOGO_URL_RESOLVED"
  172. export HQ_EYEBROW="$EYEBROW"
  173. export HQ_COVER_TITLE="$COVER_TITLE_RESOLVED"
  174. export HQ_COVER_SUBTITLE="$COVER_SUBTITLE_RESOLVED"
  175. export HQ_CLIENT_NAME="$CLIENT_NAME_RESOLVED"
  176. export HQ_PROJECT_NAME="$PROJECT_NAME_RESOLVED"
  177. export HQ_DATE_HUMAN="$DATE_HUMAN"
  178. export HQ_PROJECT_PERIOD="$PROJECT_PERIOD_RESOLVED"
  179. export HQ_PROJECT_URL="$PROJECT_URL_RESOLVED"
  180. export HQ_LABEL_CLIENT="$LABEL_CLIENT"
  181. export HQ_LABEL_PROJECT="$LABEL_PROJECT"
  182. export HQ_LABEL_DATE="$LABEL_DATE"
  183. export HQ_LABEL_PERIOD="$LABEL_PERIOD"
  184. export HQ_LABEL_URL="$LABEL_URL"
  185. export HQ_LABEL_PREPARED_BY="$LABEL_PREPARED_BY"
  186. export HQ_CONTENT="$BODY_HTML"
  187. render_template "$BRANDING_DIR/zenquality-template.html" > "$OUT_HTML"
  188. echo "wrote: $OUT_HTML"
  189. # ---------------------------- HTML -> PDF --------------------------
  190. PDF_ENGINE=""
  191. PDF_REASON=""
  192. if command -v weasyprint >/dev/null 2>&1; then
  193. PDF_ENGINE="weasyprint"
  194. elif command -v wkhtmltopdf >/dev/null 2>&1; then
  195. PDF_ENGINE="wkhtmltopdf"
  196. elif command -v chromium >/dev/null 2>&1; then
  197. PDF_ENGINE="chromium"
  198. elif command -v chromium-browser >/dev/null 2>&1; then
  199. PDF_ENGINE="chromium-browser"
  200. elif command -v google-chrome >/dev/null 2>&1; then
  201. PDF_ENGINE="google-chrome"
  202. else
  203. PDF_REASON="no PDF engine found (looked for: weasyprint, wkhtmltopdf, chromium, google-chrome)"
  204. fi
  205. if [ -n "$PDF_ENGINE" ]; then
  206. case "$PDF_ENGINE" in
  207. weasyprint)
  208. weasyprint --base-url "$OUT_DIR/" "$OUT_HTML" "$OUT_PDF"
  209. ;;
  210. wkhtmltopdf)
  211. wkhtmltopdf --enable-local-file-access \
  212. --margin-top 0 --margin-bottom 0 \
  213. --margin-left 0 --margin-right 0 \
  214. --print-media-type \
  215. "$OUT_HTML" "$OUT_PDF"
  216. ;;
  217. chromium|chromium-browser|google-chrome)
  218. "$PDF_ENGINE" --headless --disable-gpu --no-sandbox \
  219. --no-pdf-header-footer \
  220. --print-to-pdf="$OUT_PDF" \
  221. --print-to-pdf-no-header \
  222. "file://$OUT_HTML"
  223. ;;
  224. esac
  225. echo "wrote: $OUT_PDF (engine: $PDF_ENGINE)"
  226. exit 0
  227. fi
  228. cat <<EOF >&2
  229. note: HTML written, but no PDF engine is available.
  230. reason: $PDF_REASON
  231. To generate $OUT_PDF, install one of:
  232. - weasyprint pip install --user weasyprint
  233. - wkhtmltopdf apt install wkhtmltopdf (or download from wkhtmltopdf.org)
  234. - chromium apt install chromium-browser
  235. Or open $OUT_HTML in a browser and use "Print → Save as PDF".
  236. EOF
  237. exit 2