Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,28 @@ jobs:
set -eu
npm install -g tasks-axi
tasks-axi --version
- name: Require Ghostscript for the PDF conformance gate
run: |
set -eu
# tests/fm-pdf-output.test.sh is the only proof that the PDF gate
# works. Without Ghostscript it would self-skip and CI would stay
# green while nothing was checked, which is the exact class of failure
# that gate exists to prevent.
command -v gs >/dev/null || {
sudo apt-get update -qq
sudo apt-get install -y -qq ghostscript
}
command -v gs >/dev/null || {
echo "::error::ghostscript is required for the PDF conformance gate proof"
exit 1
}
gs --version
- name: Run portable serial remainder
run: |
set -eu
mkdir -p "$RUNNER_TEMP/fm-test"
bin/fm-test-run.sh --lane portable-serial \
--fail-on-gate-skip 'ghostscript not found' \
--json "$RUNNER_TEMP/fm-test/fm-test-timing-portable-serial.json"
- name: Upload portable serial timing artifact
if: always()
Expand Down
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,7 @@ Do not surface automatic fixes, retries, routine progress, or internal supervisi
Batch non-urgent updates into the next natural reply.
Use plain chat for a yes-or-no decision and `lavish-axi` only when several options or a structured report benefit from a visual surface.
Whenever a PR is mentioned, include its full `https://...` URL before any shorthand reference.
Generate a PDF deliverable only through `bin/fm-pdf-finish.sh`, which refuses to publish a file a real reader cannot read, because a browser-printed document looks correct on screen and fails at the recipient (docs/pdf-output.md).
Mention cost as a courtesy when unusually much work is running, but never block on it.

## 10. Backlog contract
Expand Down
170 changes: 170 additions & 0 deletions bin/fm-pdf-finish.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
# Mandatory last step of any PDF generation path: produce the deliverable from
# one or more rendered parts, then refuse to publish it unless it passes the
# conformance gate.
#
# Why this exists rather than a merge command called by hand: browser-printed
# documents were being assembled with poppler's `pdfunite`, which writes a
# trailer whose declared cross-reference size does not match the objects it
# actually wrote. The result opens in some viewers and fails in others, and it
# looks perfect on screen right up to the moment the recipient cannot open it.
# docs/pdf-output.md carries the evidence and the exact diagnosis.
#
# The fix is at the source: Ghostscript's pdfwrite device rebuilds the document
# and emits a conforming cross-reference table, so it both merges the parts and
# normalizes them in one pass. Ghostscript is then run a second time, as a
# reader, to check the result - a producer's own claim about its output is not
# evidence, so the file is read back before it is trusted.
#
# Publication is atomic and gated: the work happens on a temporary file beside
# the destination and is moved into place only after bin/fm-pdf-verify.sh
# passes. A failed gate leaves any previous <out> untouched and creates no new
# one, so a non-conforming file has no path to a recipient.
#
# Usage: fm-pdf-finish.sh [--pages <n>] [--quiet] <out.pdf> <in.pdf> [<in.pdf>...]
# --pages <n> require the finished document to have exactly <n> pages; use it
# whenever the expected length is known, so a silently dropped
# page fails the gate instead of shipping.
# --quiet print nothing on success.
#
# Inputs are concatenated in the order given. Passing a single input is the
# normal "normalize one rendered file" case.
#
# Exit: 0 published; 1 the gate rejected the result (nothing published);
# 2 usage error; 3 the gate could not run (nothing published).
set -u

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VERIFY="$SCRIPT_DIR/fm-pdf-verify.sh"
PDF_LIB="$SCRIPT_DIR/fm-pdf-lib.sh"
# shellcheck source=bin/fm-pdf-lib.sh
# shellcheck disable=SC1091
. "$PDF_LIB" || {
echo "fm-pdf-finish: CANNOT VERIFY - helper library missing at $PDF_LIB" >&2
exit 3
}

usage() {
cat >&2 <<'EOF'
Usage: fm-pdf-finish.sh [--pages <n>] [--quiet] <out.pdf> <in.pdf> [<in.pdf>...]

Merge and normalize the input PDFs into <out.pdf> through a conforming
producer, verify the result with a real PDF reader, and publish it only if the
verification passes.

--pages <n> require exactly <n> pages in the finished document
--quiet suppress the success line

Exit codes: 0 published, 1 rejected, 2 usage, 3 could not verify.
EOF
}

fm_pdf_parse_options fm-pdf-finish usage "$@"
parse_rc=$?
case "$parse_rc" in
0) ;;
10) exit 0 ;;
*) exit "$parse_rc" ;;
esac
set -- "${FM_PDF_ARGV[@]+"${FM_PDF_ARGV[@]}"}"
EXPECT_PAGES=$FM_PDF_EXPECT_PAGES
QUIET=$FM_PDF_QUIET

[ "$#" -ge 2 ] || { usage; exit 2; }

OUT=$1
shift

for src in "$@"; do
[ -f "$src" ] || { echo "fm-pdf-finish: input not found: $src" >&2; exit 2; }
[ -s "$src" ] || { echo "fm-pdf-finish: input is empty: $src" >&2; exit 2; }
done

[ -x "$VERIFY" ] || { echo "fm-pdf-finish: CANNOT VERIFY - gate script missing at $VERIFY" >&2; exit 3; }

GS_BIN=$(fm_pdf_gs_bin)
command -v "$GS_BIN" >/dev/null 2>&1 || {
echo "fm-pdf-finish: no PDF producer found (looked for '$GS_BIN'; install ghostscript)" >&2
exit 3
}

OUT_DIR=$(dirname "$OUT")
[ -d "$OUT_DIR" ] || { echo "fm-pdf-finish: output directory does not exist: $OUT_DIR" >&2; exit 2; }

# The template ends at the X's: BSD/macOS mktemp rejects a suffix after them.
# The producer and the gate both go by content, not by file extension.
TMP_OUT=$(mktemp "$OUT_DIR/.fm-pdf-finish.XXXXXX") || {
echo "fm-pdf-finish: could not create a temporary file in $OUT_DIR" >&2
exit 3
}
trap 'rm -f "$TMP_OUT"' EXIT

# Absolute input paths: Ghostscript reads a leading `-` as an option.
ABS_INPUTS=()
for src in "$@"; do
case "$src" in
/*) ABS_INPUTS+=("$src") ;;
*) ABS_INPUTS+=("$PWD/$src") ;;
esac
done

# /prepress keeps images and fonts at delivery quality; the size reduction this
# step produces comes from rebuilding the document, not from discarding content.
if ! gs_out=$("$GS_BIN" \
-dBATCH -dNOPAUSE -dQUIET -dSAFER \
-sDEVICE=pdfwrite \
-dPDFSETTINGS=/prepress \
-dCompatibilityLevel=1.7 \
-dEmbedAllFonts=true \
-dSubsetFonts=true \
-dDetectDuplicateImages=true \
-sOutputFile="$TMP_OUT" \
"${ABS_INPUTS[@]}" 2>&1); then
echo "fm-pdf-finish: the PDF producer failed - nothing published" >&2
printf '%s\n' "$gs_out" >&2
exit 1
fi

VERIFY_ARGS=()
[ -n "$EXPECT_PAGES" ] && VERIFY_ARGS+=(--pages "$EXPECT_PAGES")

# The gate runs exactly once, here, on the temporary file, and its verdict is
# the only one. The result is captured rather than re-derived after publication:
# a second reader pass would double the cost of every generation and, being the
# last command, would hand its own status to the caller - reporting "nothing
# published" about a document that is already live at <out>.
gate_out=$("$VERIFY" "${VERIFY_ARGS[@]+"${VERIFY_ARGS[@]}"}" "$TMP_OUT" 2>&1)
verdict=$?

if [ "$verdict" -ne 0 ]; then
# The gate refused, so nothing reaches <out>. A previous <out> stays as it was.
printf '%s\n' "$gate_out" >&2
if [ "$verdict" -eq 3 ]; then
echo "fm-pdf-finish: CANNOT VERIFY the finished document - nothing published to $OUT" >&2
else
echo "fm-pdf-finish: the finished document was rejected - nothing published to $OUT" >&2
fi
exit "$verdict"
fi

mv -f "$TMP_OUT" "$OUT" || {
echo "fm-pdf-finish: could not publish to $OUT" >&2
exit 3
}
trap - EXIT

chmod 644 "$OUT" 2>/dev/null || true

if [ "$QUIET" -eq 0 ]; then
# The page count is the one the reader itself counted during the gate run.
pages=$(printf '%s\n' "$gate_out" \
| sed -n 's/.*(\([0-9][0-9]*\) pages, conforming)$/\1/p' \
| tail -n 1)
if [ -n "$pages" ]; then
echo "fm-pdf-finish: published $OUT ($pages pages, conforming)"
else
echo "fm-pdf-finish: published $OUT (conforming)"
fi
fi

exit 0
60 changes: 60 additions & 0 deletions bin/fm-pdf-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# fm-pdf-lib.sh - single owner of the option surface the PDF scripts share.
#
# bin/fm-pdf-verify.sh and bin/fm-pdf-finish.sh accept the same --pages and
# --quiet options with the same integer validation, and both resolve the same
# Ghostscript binary through FM_PDF_GS. Two copies of that drift apart the first
# time only one of them is corrected, so the parsing lives here and both scripts
# source it. Each script keeps its own usage text, its own message prefix, and
# its own exit codes, because their contracts and their audiences differ.
#
# No side effects on source. set -u safe.

FM_PDF_EXPECT_PAGES=""
FM_PDF_QUIET=0
FM_PDF_ARGV=()

# fm_pdf_gs_bin - the Ghostscript binary this repo drives, honoring FM_PDF_GS.
fm_pdf_gs_bin() {
printf '%s\n' "${FM_PDF_GS:-gs}"
}

# fm_pdf_parse_options <prefix> <usage-fn> "$@" - parse the shared options.
#
# Sets FM_PDF_EXPECT_PAGES, FM_PDF_QUIET, and FM_PDF_ARGV to the operands that
# follow the options. Returns 0 to continue, 2 on a usage error the caller must
# exit with, and 10 when --help was handled and the caller should exit 0.
# shellcheck disable=SC2034 # The three globals are read by the sourcing script.
fm_pdf_parse_options() {
local prefix=$1 usage_fn=$2
shift 2
FM_PDF_EXPECT_PAGES=""
FM_PDF_QUIET=0
FM_PDF_ARGV=()
while [ "$#" -gt 0 ]; do
case "$1" in
--pages)
[ "$#" -ge 2 ] || { echo "$prefix: --pages needs a value" >&2; "$usage_fn"; return 2; }
FM_PDF_EXPECT_PAGES=$2
case "$FM_PDF_EXPECT_PAGES" in
''|*[!0-9]*)
echo "$prefix: --pages must be a positive integer, got '$FM_PDF_EXPECT_PAGES'" >&2
return 2
;;
esac
[ "$FM_PDF_EXPECT_PAGES" -gt 0 ] || {
echo "$prefix: --pages must be a positive integer" >&2
return 2
}
shift 2
;;
--quiet) FM_PDF_QUIET=1; shift ;;
-h|--help) "$usage_fn"; return 10 ;;
--) shift; break ;;
-*) echo "$prefix: unknown option '$1'" >&2; "$usage_fn"; return 2 ;;
*) break ;;
esac
done
FM_PDF_ARGV=("$@")
return 0
}
Loading
Loading