Skip to main content

latex-pdf

Convert a markdown resume to a polished PDF via a config-driven LaTeX pipeline (replaces wkhtmltopdf-based /pdf for resumes)

Stars
19
Source
reggiechan74/JobOps
Updated
2026-05-29
Slug
reggiechan74--JobOps--latex-pdf
View on GitHubRaw SKILL.md

// install — copy + paste into any project

mkdir -p .claude/skills && curl -fsSL https://raw.githubusercontent.com/reggiechan74/JobOps/HEAD/plugins/jobops/skills/latex-pdf/SKILL.md -o .claude/skills/latex-pdf.md

Drops the SKILL.md into .claude/skills/latex-pdf.md. Works with Claude Code, Cursor, and any agent that loads SKILL.md files from .claude/skills/.

Configuration

Read .jobops/config.json. If missing, stop with:

JOBOPS NOT CONFIGURED Run /jobops:setup to initialize your workspace.

Use config.directories.<key> for all file paths in this skill.

Templates

This skill resolves the LaTeX config and preamble via:

{config.templates.base_dir}/{config.templates.active.latex_config}/latex/

Templates referenced: config.json, preamble.base.tex.template, preamble.resume.tex.template, preamble.coverletter.tex.template, preamble.document.tex.template, and omers-filter.lua (the Tier 2 pandoc filter). The final .tex is assembled as base + doctype delta + body: preamble.base.tex.template holds everything shared (fonts, palette, de-numbering, helper macros, section style, \role/\subrole, table helpers); each preamble.<doctype>.tex.template is a small delta (margins live in the base via tokens; the delta sets paragraph spacing, lists, tables, and \begin{document}…__BODY__…\end{document}).

If config.templates.active.latex_config is unset (older config), fall back to ${CLAUDE_PLUGIN_ROOT}/templates/latex/ and warn the user to re-run /jobops:setup so their workspace owns a copy they can customize.

Markdown authoring contract

The OMERS Lua filter maps a specific markdown shape onto the gold-standard LaTeX. The buildresume and coverletter skills MUST emit markdown in this shape; the filter is tolerant but parity depends on it.

Resume (output_type: resume_*):

# Name, PostNoms
**One-line tagline**
City, ST • (555) 555-5555 • [email](mailto:…) • [linkedin.com/in/…](https://…) • [github.com/…](https://…)

## SECTION NAME

### **COMPANY** (optional parenthetical)
*Title | Location | Dates*

- Achievement bullet with a metric.
  • The first # (name) plus the tagline and contact lines become the centered header block — not a section.
  • Each ## becomes a navy ruled \section.
  • Each ### **COMPANY** immediately followed by an italic *…* subline becomes \role{COMPANY}{Dates} + \subrole{Title — Location}. The subline is split on |; the last segment is the right-aligned dates, the rest is the subrole. If there is no |, the whole italic line becomes the subrole with no dates.
  • Markdown thematic breaks (---) are dropped — they would render as a distracting standalone centered rule. Use them freely as source separators; the navy \section headings are the only visible dividers.

Cover letter (output_type: cover_letter):

# Name, PostNoms
City, ST | (555) 555-5555 | [email](mailto:…) | LinkedIn: [linkedin.com/in/…](https://…)

Month DD, YYYY

Recipient Name\
Recipient Title\
Recipient Org

Dear FirstName:

Body paragraph…

## Requirements Alignment

| Requirement | Evidence |
|---|---|
| … | … |

On X: evidence paragraph…

Sincerely,

![](signature.png){width=2in}

Name, PostNoms
  • The first # plus the contact line become the left letterhead (name + navy rule + contact). The date line that follows is NOT consumed into the header.
  • Contact line fields. The contact line carries up to four distinct fields: {location} | {phone} | {email} | LinkedIn: {linkedin}. Phone is its own field — never concatenate it onto another field (the fused (555) 555-0123name@example.com artifact came from a header that had no phone slot). Join fields with | (space-pipe-space) and omit any empty field cleanly — no orphan separators, no doubled | |. If the candidate has no phone on record, drop the field and the adjacent separator so the line reads City, ST | email | LinkedIn: …. The filter renders the contact line verbatim, so the separation must be correct in the markdown.
  • Use a trailing \ for hard line breaks in the recipient block.
  • The 2-column requirements table renders with a navy header row, white bold text, zebra striping, and a 29/71 column split.
  • Keep the signature image width in the markdown ({width=2in} ≈ 5 cm); keepaspectratio in the base preamble prevents distortion regardless.

Application Path Resolution

This skill writes a PDF into a per-application folder.

  1. Parse {Company}_{Role}_{YYYYMMDD} from the markdown filename OR from the parent folder name (the existing buildresume / coverletter flows produce files inside {applications_root}/{slug}/{sub}/, so the parent's parent is usually the slug).
  2. Output dir is per-doctype: {applications_root}/{slug}/resume/ for the resume doctype; {applications_root}/{slug}/cover-letter/ for the coverletter doctype.
  3. If the slug cannot be parsed, write the PDF next to the source markdown and warn the user that application-folder placement was skipped.

Arguments

  • $1: Source markdown file (required, absolute or relative to workspace).
  • $2: Theme name override (optional). Available themes: navy-serif, modern, classic, minimal. Unknown name: stop with "Theme '' is not defined in latex/config.json. Available: ".
  • $3: Target page count (optional). One of 1, 2, 3, or auto (default auto). When 1/2/3, the skill iteratively tunes font_size_pt and list_itemsep_pt (resume) or parskip_em (coverletter) toward the target, up to 3 iterations. When auto, the skill compiles once with the doctype's defaults.

Flags:

  • --doctype=resume|coverletter — override doctype autodetection. Rarely needed.

Step 1: Dependency check

missing=()
command -v pandoc  >/dev/null || missing+=("pandoc")
command -v xelatex >/dev/null || missing+=("xelatex (texlive-xetex)")
command -v fc-list >/dev/null || missing+=("fc-list (fontconfig)")
command -v jq      >/dev/null || missing+=("jq")
if [ ${#missing[@]} -gt 0 ]; then
  echo "MISSING DEPENDENCIES: ${missing[*]}"
  echo "Install on Debian/Ubuntu:"
  echo "  sudo apt-get install -y texlive-xetex texlive-fonts-extra pandoc poppler-utils fontconfig jq"
  echo "On macOS (Homebrew):"
  echo "  brew install --cask mactex-no-gui && brew install pandoc poppler jq"
  exit 1
fi

# Optional: pdfinfo (from poppler-utils) — used for page count + page-target iteration.
# If missing, the skill still produces the PDF but cannot enforce $3 page-count targeting.
if ! command -v pdfinfo >/dev/null; then
  echo "WARN: pdfinfo not found (from poppler-utils). Page-count targeting via \$3 will be skipped."
  echo "      Install with: sudo apt-get install -y poppler-utils  (or brew install poppler)"
fi

Stop on missing deps; do NOT proceed.

Step 1.5: Expand $1 into a list of source files

The skill accepts $1 as a file, a glob, or a directory (matching the old /pdf shape). Expand to a list of files; iterate Steps 2-11 once per file.

input="$1"
if [ -d "$input" ]; then
  # Directory: list *.md non-recursively
  shopt -s nullglob
  sources=( "$input"/*.md )
  shopt -u nullglob
elif [[ "$input" == *[\*\?]* ]]; then
  # Glob expression: expand
  shopt -s nullglob
  # shellcheck disable=SC2206
  sources=( $input )
  shopt -u nullglob
elif [ -f "$input" ]; then
  sources=( "$input" )
else
  echo "ERROR: '$input' is not a file, glob, or directory."
  exit 1
fi

if [ ${#sources[@]} -eq 0 ]; then
  echo "ERROR: no markdown files matched '$input'."
  exit 1
fi

echo "Will process ${#sources[@]} file(s):"
printf "  - %s\n" "${sources[@]}"

Steps 2 through 11 below execute for EACH $src in ${sources[@]}. Wrap them in:

for src in "${sources[@]}"; do
  echo ""
  echo "=== Processing: $src ==="
  # ... Steps 2-11 below, using $src in place of any $1 reference ...
done

Inside the loop, $src is the current source file. References to $1 in Step 2's front-matter check and Step 6's pandoc input use $src instead.

Step 2: Detect doctype

Determine the doctype in this order (first match wins):

  1. --doctype=<value> flag on the command line.
  2. YAML front-matter output_type field in $src (the file's first 30 lines suffice):
    • output_type ∈ {resume_final, resume_step1, resume_step2, resume_provenance} → doctype resume
    • output_type ∈ {cover_letter, coverletter} → doctype coverletter
    • (any other value or absent → fall through to next check)
  3. Parent-directory name: if the file's immediate parent dir is cover-lettercoverletter; if resumeresume.
  4. Filename heuristic:
    • contains cover_lettercoverletter
    • contains step1, step2, step3, or resumeresume
    • otherwise → fall through
  5. Default fallback: document.
# Pull --doctype= flag if present in any positional arg
doctype=""
for a in "$@"; do
  case "$a" in --doctype=*) doctype="${a#--doctype=}";; esac
done

if [ -z "$doctype" ]; then
  fm_type=$(awk '/^---$/{n++;next} n==1 && /^output_type:/{print $2; exit}' "$src" | tr -d '"' || true)
  case "$fm_type" in
    resume_final|resume_step1|resume_step2|resume_provenance) doctype="resume";;
    cover_letter|coverletter)                                  doctype="coverletter";;
  esac
fi

if [ -z "$doctype" ]; then
  case "$(basename "$(dirname "$src")")" in
    cover-letter) doctype="coverletter";;
    resume)       doctype="resume";;
  esac
fi

if [ -z "$doctype" ]; then
  case "$(basename "$src")" in
    *cover_letter*)                          doctype="coverletter";;
    *step1*|*step2*|*step3*|*resume*)        doctype="resume";;
  esac
fi

# Final fallback: document
doctype="${doctype:-document}"

case "$doctype" in
  resume|coverletter|document) ;;
  *) echo "Doctype '$doctype' not supported (Phase 3: resume, coverletter, document)"; exit 1;;
esac
echo "Detected doctype: $doctype"

Step 3: Load and validate LaTeX config

Read the LaTeX config:

# Resolve config path from .jobops/config.json
base_dir=$(jq -r '.templates.base_dir' .jobops/config.json)
latex_dir_name=$(jq -r '.templates.active.latex_config // "default"' .jobops/config.json)
latex_dir="${base_dir}/${latex_dir_name}/latex"

cfg="${latex_dir}/config.json"
if [ ! -f "$cfg" ]; then
  echo "WARN: ${cfg} not found; falling back to plugin-bundled defaults."
  echo "      Run /jobops:setup to install workspace copies you can customize."
  latex_dir="${CLAUDE_PLUGIN_ROOT}/templates/latex"
  cfg="${latex_dir}/config.json"
fi

Validate:

jq -e '
  .default_theme           and
  (.themes | type == "object")    and
  (.doctypes | type == "object")  and
  (.themes[.default_theme] // empty) and
  (.themes | to_entries | all(.value.accent_rgb | length == 3 and all(. >= 0 and . <= 255))) and
  (.themes | to_entries | all(.value.muted_rgb  | length == 3 and all(. >= 0 and . <= 255)))
' "$cfg" >/dev/null || { echo "INVALID latex/config.json"; exit 1; }

On failure, print the offending key path and stop.

Step 4: Resolve effective settings

Doctype is already resolved (Step 2). Determine theme: $2 override → doctypes[doctype].themedefault_theme. If the named theme is not in themes, stop with the "available themes" message.

# Theme resolution
theme_arg="${2:-}"
if [ -n "$theme_arg" ]; then
  THEME_NAME="$theme_arg"
elif jq -e --arg d "$doctype" '.doctypes[$d].theme' "$cfg" >/dev/null; then
  THEME_NAME=$(jq -r --arg d "$doctype" '.doctypes[$d].theme' "$cfg")
else
  THEME_NAME=$(jq -r '.default_theme' "$cfg")
fi
if ! jq -e --arg t "$THEME_NAME" '.themes[$t]' "$cfg" >/dev/null; then
  avail=$(jq -r '.themes | keys | join(", ")' "$cfg")
  echo "Theme '$THEME_NAME' is not defined in latex/config.json. Available: $avail"
  exit 1
fi

# Theme-level values (from .themes[$THEME_NAME])
FONT_SIZE_PT=$(jq -r --arg t "$THEME_NAME" '.themes[$t].font_size_pt'         "$cfg")
LINE_SPREAD=$( jq -r --arg t "$THEME_NAME" '.themes[$t].line_spread'          "$cfg")
MAIN_FONT=$(   jq -r --arg t "$THEME_NAME" '.themes[$t].main_font'            "$cfg")
HEADING_FONT=$(jq -r --arg t "$THEME_NAME" '.themes[$t].heading_font'         "$cfg")
HEADING_FONT_FALLBACK=$(jq -r --arg t "$THEME_NAME" '.themes[$t].heading_font_fallback' "$cfg")
ACCENT_R=$(jq -r --arg t "$THEME_NAME" '.themes[$t].accent_rgb[0]' "$cfg")
ACCENT_G=$(jq -r --arg t "$THEME_NAME" '.themes[$t].accent_rgb[1]' "$cfg")
ACCENT_B=$(jq -r --arg t "$THEME_NAME" '.themes[$t].accent_rgb[2]' "$cfg")
MUTED_R=$( jq -r --arg t "$THEME_NAME" '.themes[$t].muted_rgb[0]'  "$cfg")
MUTED_G=$( jq -r --arg t "$THEME_NAME" '.themes[$t].muted_rgb[1]'  "$cfg")
MUTED_B=$( jq -r --arg t "$THEME_NAME" '.themes[$t].muted_rgb[2]'  "$cfg")

# Doctype-level values (from .doctypes[$doctype])
MARGIN_TOP=$(   jq -r --arg d "$doctype" '.doctypes[$d].margins_in.top'    "$cfg")
MARGIN_BOTTOM=$(jq -r --arg d "$doctype" '.doctypes[$d].margins_in.bottom' "$cfg")
MARGIN_LEFT=$(  jq -r --arg d "$doctype" '.doctypes[$d].margins_in.left'   "$cfg")
MARGIN_RIGHT=$( jq -r --arg d "$doctype" '.doctypes[$d].margins_in.right'  "$cfg")
SECTION_LETTERSPACE=$(jq -r --arg d "$doctype" '.doctypes[$d].section_letterspace // "0"' "$cfg")

# Doctype-specific
if [ "$doctype" = "resume" ]; then
  LIST_ITEMSEP_PT=$(jq -r '.doctypes.resume.list_itemsep_pt' "$cfg")
elif [ "$doctype" = "coverletter" ]; then
  PARSKIP_EM=$(jq -r '.doctypes.coverletter.parskip_em' "$cfg")
elif [ "$doctype" = "document" ]; then
  PARSKIP_EM=$(jq -r '.doctypes.document.parskip_em' "$cfg")
fi

Step 5: Font availability check

fallback_msg=""
if ! fc-list | grep -Fiq "$MAIN_FONT"; then
  echo "ERROR: main_font '$MAIN_FONT' is not installed. Install texlive-fonts-extra or change the theme."
  exit 1
fi
if ! fc-list | grep -Fiq "$HEADING_FONT"; then
  fallback_msg="heading_font '$HEADING_FONT' not found; falling back to '$HEADING_FONT_FALLBACK' at compile time."
  echo "WARN: $fallback_msg"
fi

Step 6: Pandoc body generation

# (src is set by the per-file loop wrapper from Step 1.5)
basename=$(basename "$src" .md)
work=$(mktemp -d)

# Copy sibling image assets (signature, logos) so relative \includegraphics paths
# resolve inside the temp compile dir. Without this, xelatex can't read the
# missing image's dimensions and (because the base preamble sets
# keepaspectratio) degrades into a Package-graphics "Division by 0" hard failure
# instead of a clean "file not found". One copy suffices — $work persists across
# the recompile/page-target iteration loop.
srcdir=$(dirname "$src")
find "$srcdir" -maxdepth 1 -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.jpeg' \) \
  -exec cp {} "$work/" \; 2>/dev/null || true

# The OMERS Lua filter maps the markdown onto the hand-built LaTeX vocabulary
# (header block, \section, \role/\subrole, navy+zebra tables). It needs to know
# the doctype; it is pass-through for `document`.
pandoc "$src" -t latex -o "${work}/body.tex" --no-highlight --wrap=none \
  --lua-filter="${latex_dir}/omers-filter.lua" -M doctype="$doctype"

Step 7: Generate preamble + assemble

Copy the template and run a single sed pipeline. Use a delimiter unlikely to appear in values (|):

base_file="preamble.base.tex.template"
delta_file="preamble.${doctype}.tex.template"
for f in "$base_file" "$delta_file"; do
  if [ ! -f "${latex_dir}/${f}" ]; then
    echo "ERROR: ${latex_dir}/${f} not found."
    exit 1
  fi
done
# Assemble base + doctype delta into the working .tex, then substitute tokens.
cat "${latex_dir}/${base_file}" "${latex_dir}/${delta_file}" > "${work}/${basename}.tex"
tex="${work}/${basename}.tex"

sed -i \
  -e "s|__FONT_SIZE_PT__|${FONT_SIZE_PT}|g" \
  -e "s|__MARGIN_TOP__|${MARGIN_TOP}|g" \
  -e "s|__MARGIN_BOTTOM__|${MARGIN_BOTTOM}|g" \
  -e "s|__MARGIN_LEFT__|${MARGIN_LEFT}|g" \
  -e "s|__MARGIN_RIGHT__|${MARGIN_RIGHT}|g" \
  -e "s|__ACCENT_R__|${ACCENT_R}|g" \
  -e "s|__ACCENT_G__|${ACCENT_G}|g" \
  -e "s|__ACCENT_B__|${ACCENT_B}|g" \
  -e "s|__MUTED_R__|${MUTED_R}|g" \
  -e "s|__MUTED_G__|${MUTED_G}|g" \
  -e "s|__MUTED_B__|${MUTED_B}|g" \
  -e "s|__MAIN_FONT__|${MAIN_FONT}|g" \
  -e "s|__HEADING_FONT_FALLBACK__|${HEADING_FONT_FALLBACK}|g" \
  -e "s|__HEADING_FONT__|${HEADING_FONT}|g" \
  -e "s|__SECTION_LETTERSPACE__|${SECTION_LETTERSPACE}|g" \
  -e "s|__LIST_ITEMSEP_PT__|${LIST_ITEMSEP_PT:-2.3}|g" \
  -e "s|__PARSKIP_EM__|${PARSKIP_EM:-0.4}|g" \
  -e "s|__LINE_SPREAD__|${LINE_SPREAD}|g" \
  "$tex"

# Substitute __BODY__ from the file (sed's r command + d on the marker):
sed -i -e "/__BODY__/{r ${work}/body.tex" -e "d;}" "$tex"

Step 8: Compile with optional page-count targeting

pages_target="${3:-auto}"
case "$pages_target" in 1|2|3|auto) ;; *) echo "Bad \$3 '$pages_target'; expected 1|2|3|auto"; exit 1;; esac

compile_once() {
  ( cd "$work" && xelatex -interaction=nonstopmode "${basename}.tex" >/dev/null \
                && xelatex -interaction=nonstopmode "${basename}.tex" >/dev/null )
}

regen_preamble() {
  # Re-assemble base + delta from scratch so iterative tuning starts from the
  # templates, not from an already-substituted file.
  cat "${latex_dir}/${base_file}" "${latex_dir}/${delta_file}" > "${work}/${basename}.tex"
  sed -i \
    -e "s|__FONT_SIZE_PT__|${FONT_SIZE_PT}|g" \
    -e "s|__MARGIN_TOP__|${MARGIN_TOP}|g" \
    -e "s|__MARGIN_BOTTOM__|${MARGIN_BOTTOM}|g" \
    -e "s|__MARGIN_LEFT__|${MARGIN_LEFT}|g" \
    -e "s|__MARGIN_RIGHT__|${MARGIN_RIGHT}|g" \
    -e "s|__ACCENT_R__|${ACCENT_R}|g" -e "s|__ACCENT_G__|${ACCENT_G}|g" -e "s|__ACCENT_B__|${ACCENT_B}|g" \
    -e "s|__MUTED_R__|${MUTED_R}|g"   -e "s|__MUTED_G__|${MUTED_G}|g"   -e "s|__MUTED_B__|${MUTED_B}|g" \
    -e "s|__MAIN_FONT__|${MAIN_FONT}|g" \
    -e "s|__HEADING_FONT_FALLBACK__|${HEADING_FONT_FALLBACK}|g" \
    -e "s|__HEADING_FONT__|${HEADING_FONT}|g" \
    -e "s|__SECTION_LETTERSPACE__|${SECTION_LETTERSPACE}|g" \
    -e "s|__LIST_ITEMSEP_PT__|${LIST_ITEMSEP_PT:-2.3}|g" \
    -e "s|__PARSKIP_EM__|${PARSKIP_EM:-0.4}|g" \
    -e "s|__LINE_SPREAD__|${LINE_SPREAD}|g" \
    "${work}/${basename}.tex"
  sed -i -e "/__BODY__/{r ${work}/body.tex" -e "d;}" "${work}/${basename}.tex"
}

# Corrected last-page fill: % of page height down to the lowest inked row,
# EXCLUDING the bottom ~7% footer band where the centered page number lives
# (otherwise a nearly blank page reports ~97% full and hides an orphan).
# Echoes an integer 0-100, or empty if the tooling is unavailable.
measure_fill() {
  command -v pdftoppm >/dev/null && command -v python3 >/dev/null || { echo ""; return; }
  local pg="$1"
  rm -f "${work}"/fillchk-*.png 2>/dev/null
  pdftoppm -png -r 100 -f "$pg" -l "$pg" "${work}/${basename}.pdf" "${work}/fillchk" 2>/dev/null
  local img; img=$(find "${work}" -maxdepth 1 -name 'fillchk-*.png' | head -1)
  [ -n "$img" ] || { echo ""; return; }
  python3 - "$img" <<'PY'
import sys
from PIL import Image
im = Image.open(sys.argv[1]).convert("L")
w, h = im.size
px = im.load()
footer_cut = int(h * 0.93)   # ignore bottom 7% (page-number band)
last_ink_row = 0
for y in range(footer_cut):
    for x in range(w):
        if px[x, y] < 240:
            last_ink_row = y
            break
print(last_ink_row * 100 // h)
PY
}

compile_once || { echo "xelatex compile failed; see ${work}/${basename}.log"; exit 1; }
pages=$(pdfinfo "${work}/${basename}.pdf" 2>/dev/null | awk '/^Pages:/{print $2}')
echo "Initial compile: ${pages} pages (target=${pages_target})"

# Auto mode: detect a near-empty trailing (orphan) page and re-target one fewer
# page using the SAME tuning loop below, with a 10pt font floor so text never
# shrinks into illegibility. The explicit 1|2|3 path is unchanged (FONT_FLOOR
# stays empty there, so the floor clamp is a no-op).
auto_retarget=0
FONT_FLOOR=""
if [ "$pages_target" = "auto" ] && [ -n "$pages" ] && [ "$pages" -gt 1 ]; then
  orphan_fill=$(measure_fill "$pages")
  if [ -n "$orphan_fill" ] && [ "$orphan_fill" -lt 35 ]; then
    echo "Auto: trailing page only ${orphan_fill}% full (orphan) — re-targeting $((pages - 1)) pages (font floor 10pt)."
    pages_target=$((pages - 1))
    FONT_FLOOR=10.0
    auto_retarget=1
  fi
fi

if [ "$pages_target" != "auto" ] && [ -n "$pages" ] && [ "$pages" != "$pages_target" ]; then
  for iter in 1 2 3; do
    if [ "$pages" -gt "$pages_target" ]; then
      # too many pages: tighten. FONT_FLOOR (set only for auto orphan re-target)
      # clamps the font size so it never shrinks below legibility; empty/0 = no clamp.
      FONT_SIZE_PT=$(awk -v v="$FONT_SIZE_PT" -v f="${FONT_FLOOR:-0}" 'BEGIN{nv=v-0.25; if(f>0 && nv<f) nv=f; printf "%.2f", nv}')
      LIST_ITEMSEP_PT=$(awk -v v="${LIST_ITEMSEP_PT:-2.3}" 'BEGIN{printf "%.2f", v-0.3}')
      PARSKIP_EM=$(awk -v v="${PARSKIP_EM:-0.4}" 'BEGIN{printf "%.2f", v-0.05}')
    else
      # too few pages: loosen
      FONT_SIZE_PT=$(awk -v v="$FONT_SIZE_PT" 'BEGIN{printf "%.2f", v+0.25}')
      LIST_ITEMSEP_PT=$(awk -v v="${LIST_ITEMSEP_PT:-2.3}" 'BEGIN{printf "%.2f", v+0.3}')
      PARSKIP_EM=$(awk -v v="${PARSKIP_EM:-0.4}" 'BEGIN{printf "%.2f", v+0.05}')
    fi
    echo "Iter ${iter}: tuning font_size_pt→${FONT_SIZE_PT}, list_itemsep_pt→${LIST_ITEMSEP_PT}, parskip_em→${PARSKIP_EM}"
    regen_preamble
    compile_once || { echo "xelatex compile failed during iter ${iter}; see ${work}/${basename}.log"; exit 1; }
    pages=$(pdfinfo "${work}/${basename}.pdf" 2>/dev/null | awk '/^Pages:/{print $2}')
    echo "Iter ${iter} result: ${pages} pages"
    [ "$pages" = "$pages_target" ] && break
  done
  if [ "$pages" != "$pages_target" ]; then
    if [ "$auto_retarget" = "1" ]; then
      echo "WARN: could not clear the orphan page within the 10pt font floor. Final: ${pages} pages."
      echo "      Pass an explicit page target (1|2|3) and choose which content to cut."
    else
      echo "WARN: could not hit target after 3 iterations. Final: ${pages} pages."
    fi
  fi
fi

On any xelatex failure, leave $work in place and tell the user the log path.

Step 9: Resolve output dir and copy artifacts

# Try to detect slug from parent folder structure (buildresume layout):
parent=$(dirname "$src")
grandparent=$(dirname "$parent")
slug=$(basename "$grandparent")
apps_root=$(jq -r '.directories.applications_root' .jobops/config.json)

case "$doctype" in
  resume)       sub="resume";;
  coverletter)  sub="cover-letter";;
  "document")   sub="";;
esac

if [ "$doctype" = "document" ]; then
  # Documents always write next to source — no app-slug subfolder.
  outdir="$parent"
elif [[ "$slug" =~ ^[A-Za-z0-9]+_[A-Za-z0-9]+_[0-9]{8}$ ]] \
     && [[ "$grandparent" == "${apps_root}/"* ]]; then
  outdir="${grandparent}/${sub}"
else
  outdir="$parent"
  echo "WARN: could not resolve application slug; writing PDF next to source ($outdir)."
fi
mkdir -p "$outdir"

cp "${work}/${basename}.pdf" "${outdir}/${basename}.pdf"
cp "${work}/${basename}.tex" "${outdir}/${basename}.tex"   # advanced override artifact

Step 10: Page/fill report

pages="?"; fill="unavailable"
if command -v pdfinfo >/dev/null; then
  pages=$(pdfinfo "${outdir}/${basename}.pdf" | awk '/^Pages:/{print $2}')
fi
if command -v pdftoppm >/dev/null && command -v python3 >/dev/null; then
  png="${work}/lastpage.png"
  pdftoppm -png -r 100 -f "$pages" -l "$pages" "${outdir}/${basename}.pdf" "${work}/lastpage" 2>/dev/null
  # pdftoppm appends -N for page N; find the produced file:
  produced=$(find "${work}" -maxdepth 1 -name 'lastpage-*.png' | head -1)
  if [ -n "$produced" ]; then
    fill=$(python3 - "$produced" <<'PY'
import sys
from PIL import Image
im = Image.open(sys.argv[1]).convert("L")
w, h = im.size
px = im.load()
footer_cut = int(h * 0.93)   # ignore bottom 7% (page-number band) so an orphan
                             # page doesn't report ~97% full off the page number
last_ink_row = 0
for y in range(footer_cut):
    for x in range(w):
        if px[x, y] < 240:
            last_ink_row = y
            break
print(f"{last_ink_row*100//h}%")
PY
)
  fi
fi
echo "REPORT: pages=${pages}, last_page_fill≈${fill}"

(If Pillow is not installed, the python step will fail silently; that's acceptable for Phase 1 — the report just stays "unavailable" and the user sees the install hint.)

Step 11: Final report

Print:

latex-pdf complete.
  Source:   $src
  Theme:    $THEME_NAME
  Doctype:  ${doctype}
  Output:   ${outdir}/${basename}.pdf
  TeX:      ${outdir}/${basename}.tex   (advanced-override artifact)
  Pages:    ${pages}
  Fill:     ${fill}
  Fallbacks: ${fallback_msg:-none}

Done. Do not delete $work — it's a mktemp -d directory the OS reclaims, and keeping it lets the user inspect the .log on failures.