| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315 |
- #!/usr/bin/env bash
- #
- # handover-to-pdf.sh
- # ------------------
- # Renders a client-handover Markdown report (LIVRAISON.md / HANDOVER.md)
- # into a branded HTML and (when a converter is available) a PDF using
- # ZenQuality brand styling.
- #
- # Inputs:
- # $1 Path to the source Markdown file (required)
- #
- # Optional environment variables:
- # PROJECT_NAME Displayed on the cover and as PDF page header.
- # Defaults to the source filename.
- # CLIENT_NAME Displayed on the cover. Defaults to "—".
- # PROJECT_PERIOD Displayed on the cover (e.g. "01/01/2026 → 31/03/2026").
- # Defaults to "—".
- # PROJECT_URL Displayed on the cover. Defaults to "—".
- # LANG "fr" (default) or "en". Drives cover labels.
- # COVER_TITLE Defaults to PROJECT_NAME.
- # COVER_SUBTITLE Defaults to "Compte rendu de livraison" (fr) /
- # "Project handover recap" (en).
- # EYEBROW Eyebrow line above the title. Defaults to
- # "Livraison" / "Handover".
- # LOGO_URL Logo URL or local path. Defaults to a remote
- # ZenQuality logo (no offline fallback).
- # BRANDING_DIR Override branding-asset directory. Defaults to the
- # resources/branding/ folder next to this script.
- #
- # Behaviour:
- # 1. Convert the Markdown body to HTML.
- # 2. Wrap it in the ZenQuality template (cover + branded body).
- # 3. Convert that HTML into a PDF using the first available engine:
- # weasyprint > wkhtmltopdf > chromium > headless Chrome
- # 4. Always keep the .html file next to the .md.
- # 5. If no PDF engine is available, exit with code 2 and a clear
- # message — never fail silently.
- #
- # Exit codes:
- # 0 HTML and PDF written successfully.
- # 1 Fatal error (bad arguments, missing files, conversion error).
- # 2 HTML written but no PDF engine available — manual print needed.
- set -euo pipefail
- # ---------------------------- CLI ----------------------------------
- if [ "$#" -lt 1 ]; then
- echo "usage: handover-to-pdf.sh <markdown-file>" >&2
- exit 1
- fi
- SRC_MD="$1"
- if [ ! -f "$SRC_MD" ]; then
- echo "error: markdown file not found: $SRC_MD" >&2
- exit 1
- fi
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
- DEFAULT_BRANDING_DIR="$SCRIPT_DIR/../resources/branding"
- BRANDING_DIR="${BRANDING_DIR:-$DEFAULT_BRANDING_DIR}"
- if [ ! -f "$BRANDING_DIR/zenquality.css" ] || [ ! -f "$BRANDING_DIR/zenquality-template.html" ]; then
- echo "error: branding assets missing under $BRANDING_DIR" >&2
- echo " expected: zenquality.css + zenquality-template.html" >&2
- exit 1
- fi
- OUT_DIR="$(cd "$(dirname "$SRC_MD")" && pwd)"
- BASE="$(basename "$SRC_MD" .md)"
- OUT_HTML="$OUT_DIR/$BASE.html"
- OUT_PDF="$OUT_DIR/$BASE.pdf"
- LANG_CODE="${LANG:-fr}"
- case "$LANG_CODE" in
- en|EN|en_*|en-*)
- LANG_CODE="en"
- EYEBROW="${EYEBROW:-Project handover}"
- DEFAULT_SUBTITLE="Project handover recap"
- LABEL_CLIENT="Client"
- LABEL_PROJECT="Project"
- LABEL_DATE="Issued"
- LABEL_PERIOD="Period"
- LABEL_URL="Website"
- LABEL_PREPARED_BY="prepared for the client"
- ;;
- *)
- LANG_CODE="fr"
- EYEBROW="${EYEBROW:-Livraison}"
- DEFAULT_SUBTITLE="Compte rendu de livraison"
- LABEL_CLIENT="Client"
- LABEL_PROJECT="Projet"
- LABEL_DATE="Date d'émission"
- LABEL_PERIOD="Période"
- LABEL_URL="Site"
- LABEL_PREPARED_BY="préparé pour le client"
- ;;
- esac
- PROJECT_NAME_RESOLVED="${PROJECT_NAME:-$BASE}"
- CLIENT_NAME_RESOLVED="${CLIENT_NAME:-—}"
- PROJECT_PERIOD_RESOLVED="${PROJECT_PERIOD:-—}"
- PROJECT_URL_RESOLVED="${PROJECT_URL:-—}"
- COVER_TITLE_RESOLVED="${COVER_TITLE:-$PROJECT_NAME_RESOLVED}"
- COVER_SUBTITLE_RESOLVED="${COVER_SUBTITLE:-$DEFAULT_SUBTITLE}"
- LOGO_URL_RESOLVED="${LOGO_URL:-https://zenquality.fr/assets/logo-horizontal-1024.png}"
- if command -v date >/dev/null 2>&1; then
- if [ "$LANG_CODE" = "fr" ]; then
- DATE_HUMAN="$(LC_ALL=fr_FR.UTF-8 date "+%d %B %Y" 2>/dev/null || date "+%Y-%m-%d")"
- else
- DATE_HUMAN="$(LC_ALL=en_US.UTF-8 date "+%d %B %Y" 2>/dev/null || date "+%Y-%m-%d")"
- fi
- else
- DATE_HUMAN="$(date "+%Y-%m-%d")"
- fi
- # ---------------------------- MD -> HTML ---------------------------
- md_to_html_body() {
- local src="$1"
- if command -v pandoc >/dev/null 2>&1; then
- pandoc --from=gfm+gfm_auto_identifiers --to=html5 --no-highlight "$src"
- return
- fi
- if command -v python3 >/dev/null 2>&1 && python3 -c "import markdown" >/dev/null 2>&1; then
- python3 -c "
- import sys, markdown
- src = open(sys.argv[1], encoding='utf-8').read()
- print(markdown.markdown(
- src,
- extensions=['extra', 'tables', 'sane_lists', 'toc'],
- ))" "$src"
- return
- fi
- if command -v npx >/dev/null 2>&1; then
- # marked CLI 16.x ignores stdin and dumps its own cli.js source —
- # always pass the file via -i to get correct output.
- npx --yes marked --gfm -i "$src"
- return
- fi
- echo "error: no Markdown converter available (need pandoc, python3+markdown, or npx)" >&2
- exit 1
- }
- BODY_HTML="$(md_to_html_body "$SRC_MD")"
- # Tag anchors whose visible text equals their href (auto-linked bare URLs)
- # so the print stylesheet skips the "(href)" pseudo-element duplication.
- # Without this, "[https://x.com/](https://x.com/)" or a bare URL renders as
- # "https://x.com/ (https://x.com/)" and the trailing duplicate wraps onto
- # the next line, overlapping the following block.
- tag_bare_url_links() {
- # Pass HTML via env var so the heredoc can be the python script.
- HQ_RAW_HTML="$1" python3 <<'PY'
- import os, sys, re, html as html_lib
- src = os.environ.get("HQ_RAW_HTML", "")
- def normalize(u: str) -> str:
- return html_lib.unescape(u).strip().rstrip('/').lower()
- # Match <a ...href="X"...>TEXT</a> with no nested tags inside the anchor.
- ANCHOR_RE = re.compile(
- r'<a\b([^>]*?)\bhref="([^"]+)"([^>]*)>([^<]*)</a>',
- flags=re.IGNORECASE,
- )
- def repl(m: re.Match) -> str:
- pre_attrs, href, post_attrs, text = m.groups()
- if normalize(href) != normalize(text):
- return m.group(0)
- attrs = (pre_attrs or "") + (post_attrs or "")
- class_re = re.compile(r'\bclass="([^"]*)"', flags=re.IGNORECASE)
- cm = class_re.search(attrs)
- if cm:
- existing = cm.group(1)
- if "bare-url" in existing.split():
- new_attrs = attrs
- else:
- new_attrs = class_re.sub(
- f'class="{existing} bare-url"', attrs, count=1
- )
- else:
- new_attrs = attrs.rstrip() + ' class="bare-url"'
- return f'<a{new_attrs} href="{href}">{text}</a>'
- sys.stdout.write(ANCHOR_RE.sub(repl, src))
- PY
- }
- if command -v python3 >/dev/null 2>&1; then
- BODY_HTML="$(tag_bare_url_links "$BODY_HTML")"
- fi
- # ---------------------------- WRAP HTML ----------------------------
- CSS_CONTENT="$(cat "$BRANDING_DIR/zenquality.css")"
- render_template() {
- # Read template path from $1, output the substituted HTML on stdout.
- # Substitution variables are pulled from HQ_* environment variables.
- HQ_TEMPLATE_PATH="$1" python3 <<'PY'
- import os, sys
- path = os.environ["HQ_TEMPLATE_PATH"]
- with open(path, encoding="utf-8") as f:
- template = f.read()
- mapping = {
- "{{LANG}}": os.environ.get("HQ_LANG", "fr"),
- "{{TITLE}}": os.environ.get("HQ_TITLE", ""),
- "{{CSS}}": os.environ.get("HQ_CSS", ""),
- "{{LOGO_URL}}": os.environ.get("HQ_LOGO_URL", ""),
- "{{EYEBROW}}": os.environ.get("HQ_EYEBROW", ""),
- "{{COVER_TITLE}}": os.environ.get("HQ_COVER_TITLE", ""),
- "{{COVER_SUBTITLE}}": os.environ.get("HQ_COVER_SUBTITLE", ""),
- "{{CLIENT_NAME}}": os.environ.get("HQ_CLIENT_NAME", "—"),
- "{{PROJECT_NAME}}": os.environ.get("HQ_PROJECT_NAME", ""),
- "{{DATE_HUMAN}}": os.environ.get("HQ_DATE_HUMAN", ""),
- "{{PROJECT_PERIOD}}": os.environ.get("HQ_PROJECT_PERIOD", "—"),
- "{{PROJECT_URL}}": os.environ.get("HQ_PROJECT_URL", "—"),
- "{{LABEL_CLIENT}}": os.environ.get("HQ_LABEL_CLIENT", ""),
- "{{LABEL_PROJECT}}": os.environ.get("HQ_LABEL_PROJECT", ""),
- "{{LABEL_DATE}}": os.environ.get("HQ_LABEL_DATE", ""),
- "{{LABEL_PERIOD}}": os.environ.get("HQ_LABEL_PERIOD", ""),
- "{{LABEL_URL}}": os.environ.get("HQ_LABEL_URL", ""),
- "{{LABEL_PREPARED_BY}}": os.environ.get("HQ_LABEL_PREPARED_BY", ""),
- "{{CONTENT}}": os.environ.get("HQ_CONTENT", ""),
- }
- for k, v in mapping.items():
- template = template.replace(k, v)
- sys.stdout.write(template)
- PY
- }
- export HQ_LANG="$LANG_CODE"
- export HQ_TITLE="$COVER_TITLE_RESOLVED"
- export HQ_CSS="$CSS_CONTENT"
- export HQ_LOGO_URL="$LOGO_URL_RESOLVED"
- export HQ_EYEBROW="$EYEBROW"
- export HQ_COVER_TITLE="$COVER_TITLE_RESOLVED"
- export HQ_COVER_SUBTITLE="$COVER_SUBTITLE_RESOLVED"
- export HQ_CLIENT_NAME="$CLIENT_NAME_RESOLVED"
- export HQ_PROJECT_NAME="$PROJECT_NAME_RESOLVED"
- export HQ_DATE_HUMAN="$DATE_HUMAN"
- export HQ_PROJECT_PERIOD="$PROJECT_PERIOD_RESOLVED"
- export HQ_PROJECT_URL="$PROJECT_URL_RESOLVED"
- export HQ_LABEL_CLIENT="$LABEL_CLIENT"
- export HQ_LABEL_PROJECT="$LABEL_PROJECT"
- export HQ_LABEL_DATE="$LABEL_DATE"
- export HQ_LABEL_PERIOD="$LABEL_PERIOD"
- export HQ_LABEL_URL="$LABEL_URL"
- export HQ_LABEL_PREPARED_BY="$LABEL_PREPARED_BY"
- export HQ_CONTENT="$BODY_HTML"
- render_template "$BRANDING_DIR/zenquality-template.html" > "$OUT_HTML"
- echo "wrote: $OUT_HTML"
- # ---------------------------- HTML -> PDF --------------------------
- PDF_ENGINE=""
- PDF_REASON=""
- if command -v weasyprint >/dev/null 2>&1; then
- PDF_ENGINE="weasyprint"
- elif command -v wkhtmltopdf >/dev/null 2>&1; then
- PDF_ENGINE="wkhtmltopdf"
- elif command -v chromium >/dev/null 2>&1; then
- PDF_ENGINE="chromium"
- elif command -v chromium-browser >/dev/null 2>&1; then
- PDF_ENGINE="chromium-browser"
- elif command -v google-chrome >/dev/null 2>&1; then
- PDF_ENGINE="google-chrome"
- else
- PDF_REASON="no PDF engine found (looked for: weasyprint, wkhtmltopdf, chromium, google-chrome)"
- fi
- if [ -n "$PDF_ENGINE" ]; then
- case "$PDF_ENGINE" in
- weasyprint)
- weasyprint --base-url "$OUT_DIR/" "$OUT_HTML" "$OUT_PDF"
- ;;
- wkhtmltopdf)
- wkhtmltopdf --enable-local-file-access \
- --margin-top 0 --margin-bottom 0 \
- --margin-left 0 --margin-right 0 \
- --print-media-type \
- "$OUT_HTML" "$OUT_PDF"
- ;;
- chromium|chromium-browser|google-chrome)
- "$PDF_ENGINE" --headless --disable-gpu --no-sandbox \
- --no-pdf-header-footer \
- --print-to-pdf="$OUT_PDF" \
- --print-to-pdf-no-header \
- "file://$OUT_HTML"
- ;;
- esac
- echo "wrote: $OUT_PDF (engine: $PDF_ENGINE)"
- exit 0
- fi
- cat <<EOF >&2
- note: HTML written, but no PDF engine is available.
- reason: $PDF_REASON
- To generate $OUT_PDF, install one of:
- - weasyprint pip install --user weasyprint
- - wkhtmltopdf apt install wkhtmltopdf (or download from wkhtmltopdf.org)
- - chromium apt install chromium-browser
- Or open $OUT_HTML in a browser and use "Print → Save as PDF".
- EOF
- exit 2
|