handover-to-pdf.sh 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315
  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/assets/logo-horizontal-1024.png}"
  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. # marked CLI 16.x ignores stdin and dumps its own cli.js source —
  126. # always pass the file via -i to get correct output.
  127. npx --yes marked --gfm -i "$src"
  128. return
  129. fi
  130. echo "error: no Markdown converter available (need pandoc, python3+markdown, or npx)" >&2
  131. exit 1
  132. }
  133. BODY_HTML="$(md_to_html_body "$SRC_MD")"
  134. # Tag anchors whose visible text equals their href (auto-linked bare URLs)
  135. # so the print stylesheet skips the "(href)" pseudo-element duplication.
  136. # Without this, "[https://x.com/](https://x.com/)" or a bare URL renders as
  137. # "https://x.com/ (https://x.com/)" and the trailing duplicate wraps onto
  138. # the next line, overlapping the following block.
  139. tag_bare_url_links() {
  140. # Pass HTML via env var so the heredoc can be the python script.
  141. HQ_RAW_HTML="$1" python3 <<'PY'
  142. import os, sys, re, html as html_lib
  143. src = os.environ.get("HQ_RAW_HTML", "")
  144. def normalize(u: str) -> str:
  145. return html_lib.unescape(u).strip().rstrip('/').lower()
  146. # Match <a ...href="X"...>TEXT</a> with no nested tags inside the anchor.
  147. ANCHOR_RE = re.compile(
  148. r'<a\b([^>]*?)\bhref="([^"]+)"([^>]*)>([^<]*)</a>',
  149. flags=re.IGNORECASE,
  150. )
  151. def repl(m: re.Match) -> str:
  152. pre_attrs, href, post_attrs, text = m.groups()
  153. if normalize(href) != normalize(text):
  154. return m.group(0)
  155. attrs = (pre_attrs or "") + (post_attrs or "")
  156. class_re = re.compile(r'\bclass="([^"]*)"', flags=re.IGNORECASE)
  157. cm = class_re.search(attrs)
  158. if cm:
  159. existing = cm.group(1)
  160. if "bare-url" in existing.split():
  161. new_attrs = attrs
  162. else:
  163. new_attrs = class_re.sub(
  164. f'class="{existing} bare-url"', attrs, count=1
  165. )
  166. else:
  167. new_attrs = attrs.rstrip() + ' class="bare-url"'
  168. return f'<a{new_attrs} href="{href}">{text}</a>'
  169. sys.stdout.write(ANCHOR_RE.sub(repl, src))
  170. PY
  171. }
  172. if command -v python3 >/dev/null 2>&1; then
  173. BODY_HTML="$(tag_bare_url_links "$BODY_HTML")"
  174. fi
  175. # ---------------------------- WRAP HTML ----------------------------
  176. CSS_CONTENT="$(cat "$BRANDING_DIR/zenquality.css")"
  177. render_template() {
  178. # Read template path from $1, output the substituted HTML on stdout.
  179. # Substitution variables are pulled from HQ_* environment variables.
  180. HQ_TEMPLATE_PATH="$1" python3 <<'PY'
  181. import os, sys
  182. path = os.environ["HQ_TEMPLATE_PATH"]
  183. with open(path, encoding="utf-8") as f:
  184. template = f.read()
  185. mapping = {
  186. "{{LANG}}": os.environ.get("HQ_LANG", "fr"),
  187. "{{TITLE}}": os.environ.get("HQ_TITLE", ""),
  188. "{{CSS}}": os.environ.get("HQ_CSS", ""),
  189. "{{LOGO_URL}}": os.environ.get("HQ_LOGO_URL", ""),
  190. "{{EYEBROW}}": os.environ.get("HQ_EYEBROW", ""),
  191. "{{COVER_TITLE}}": os.environ.get("HQ_COVER_TITLE", ""),
  192. "{{COVER_SUBTITLE}}": os.environ.get("HQ_COVER_SUBTITLE", ""),
  193. "{{CLIENT_NAME}}": os.environ.get("HQ_CLIENT_NAME", "—"),
  194. "{{PROJECT_NAME}}": os.environ.get("HQ_PROJECT_NAME", ""),
  195. "{{DATE_HUMAN}}": os.environ.get("HQ_DATE_HUMAN", ""),
  196. "{{PROJECT_PERIOD}}": os.environ.get("HQ_PROJECT_PERIOD", "—"),
  197. "{{PROJECT_URL}}": os.environ.get("HQ_PROJECT_URL", "—"),
  198. "{{LABEL_CLIENT}}": os.environ.get("HQ_LABEL_CLIENT", ""),
  199. "{{LABEL_PROJECT}}": os.environ.get("HQ_LABEL_PROJECT", ""),
  200. "{{LABEL_DATE}}": os.environ.get("HQ_LABEL_DATE", ""),
  201. "{{LABEL_PERIOD}}": os.environ.get("HQ_LABEL_PERIOD", ""),
  202. "{{LABEL_URL}}": os.environ.get("HQ_LABEL_URL", ""),
  203. "{{LABEL_PREPARED_BY}}": os.environ.get("HQ_LABEL_PREPARED_BY", ""),
  204. "{{CONTENT}}": os.environ.get("HQ_CONTENT", ""),
  205. }
  206. for k, v in mapping.items():
  207. template = template.replace(k, v)
  208. sys.stdout.write(template)
  209. PY
  210. }
  211. export HQ_LANG="$LANG_CODE"
  212. export HQ_TITLE="$COVER_TITLE_RESOLVED"
  213. export HQ_CSS="$CSS_CONTENT"
  214. export HQ_LOGO_URL="$LOGO_URL_RESOLVED"
  215. export HQ_EYEBROW="$EYEBROW"
  216. export HQ_COVER_TITLE="$COVER_TITLE_RESOLVED"
  217. export HQ_COVER_SUBTITLE="$COVER_SUBTITLE_RESOLVED"
  218. export HQ_CLIENT_NAME="$CLIENT_NAME_RESOLVED"
  219. export HQ_PROJECT_NAME="$PROJECT_NAME_RESOLVED"
  220. export HQ_DATE_HUMAN="$DATE_HUMAN"
  221. export HQ_PROJECT_PERIOD="$PROJECT_PERIOD_RESOLVED"
  222. export HQ_PROJECT_URL="$PROJECT_URL_RESOLVED"
  223. export HQ_LABEL_CLIENT="$LABEL_CLIENT"
  224. export HQ_LABEL_PROJECT="$LABEL_PROJECT"
  225. export HQ_LABEL_DATE="$LABEL_DATE"
  226. export HQ_LABEL_PERIOD="$LABEL_PERIOD"
  227. export HQ_LABEL_URL="$LABEL_URL"
  228. export HQ_LABEL_PREPARED_BY="$LABEL_PREPARED_BY"
  229. export HQ_CONTENT="$BODY_HTML"
  230. render_template "$BRANDING_DIR/zenquality-template.html" > "$OUT_HTML"
  231. echo "wrote: $OUT_HTML"
  232. # ---------------------------- HTML -> PDF --------------------------
  233. PDF_ENGINE=""
  234. PDF_REASON=""
  235. if command -v weasyprint >/dev/null 2>&1; then
  236. PDF_ENGINE="weasyprint"
  237. elif command -v wkhtmltopdf >/dev/null 2>&1; then
  238. PDF_ENGINE="wkhtmltopdf"
  239. elif command -v chromium >/dev/null 2>&1; then
  240. PDF_ENGINE="chromium"
  241. elif command -v chromium-browser >/dev/null 2>&1; then
  242. PDF_ENGINE="chromium-browser"
  243. elif command -v google-chrome >/dev/null 2>&1; then
  244. PDF_ENGINE="google-chrome"
  245. else
  246. PDF_REASON="no PDF engine found (looked for: weasyprint, wkhtmltopdf, chromium, google-chrome)"
  247. fi
  248. if [ -n "$PDF_ENGINE" ]; then
  249. case "$PDF_ENGINE" in
  250. weasyprint)
  251. weasyprint --base-url "$OUT_DIR/" "$OUT_HTML" "$OUT_PDF"
  252. ;;
  253. wkhtmltopdf)
  254. wkhtmltopdf --enable-local-file-access \
  255. --margin-top 0 --margin-bottom 0 \
  256. --margin-left 0 --margin-right 0 \
  257. --print-media-type \
  258. "$OUT_HTML" "$OUT_PDF"
  259. ;;
  260. chromium|chromium-browser|google-chrome)
  261. "$PDF_ENGINE" --headless --disable-gpu --no-sandbox \
  262. --no-pdf-header-footer \
  263. --print-to-pdf="$OUT_PDF" \
  264. --print-to-pdf-no-header \
  265. "file://$OUT_HTML"
  266. ;;
  267. esac
  268. echo "wrote: $OUT_PDF (engine: $PDF_ENGINE)"
  269. exit 0
  270. fi
  271. cat <<EOF >&2
  272. note: HTML written, but no PDF engine is available.
  273. reason: $PDF_REASON
  274. To generate $OUT_PDF, install one of:
  275. - weasyprint pip install --user weasyprint
  276. - wkhtmltopdf apt install wkhtmltopdf (or download from wkhtmltopdf.org)
  277. - chromium apt install chromium-browser
  278. Or open $OUT_HTML in a browser and use "Print → Save as PDF".
  279. EOF
  280. exit 2