nigig-org/tools/test-pdf-coverage.sh
andodeki 8701f5df51
Some checks failed
email.yml / feat(pdf): image embedding and header/footer stamping — Phase 4 complete (push) Failing after 0s
repo hygiene / hygiene (push) Has been cancelled
PDF engine / engine (push) Has been cancelled
PDF engine / makepad-integration (push) Has been cancelled
PDF engine / fuzz (push) Has been cancelled
feat(pdf): image embedding and header/footer stamping — Phase 4 complete
The last gap in Phase 4: dart-pdf's header_footer_test, image_stamp_test
and image_pdf_test had no counterpart here.

What was missing is worth stating precisely, because it is the shape of
bug ADR 0017 exists to catch. ContentWriter::draw_image has emitted
`q w 0 0 h x y cm /Name Do Q` since Phase 2, and was tested. But nothing
in the stack could *create* the image XObject that /Name resolves to. So
every Do operator ever written named a resource that did not exist, no
document could contain a raster image, and nothing anywhere returned an
error. The writing half was present, the reading half faithfully
reported the content stream, and the image was simply never there.

stamp.rs adds: image XObject embedding, header/footer banners with
left/centre/right alignment, image stamp content, and stream
composition. A JPEG is embedded as-is with /DCTDecode — PDF's image
model is the same DCT data the file already holds, so re-encoding would
lose quality for nothing — and its geometry is read from its own SOF
marker rather than trusted from the caller, because a /Width that
disagrees with the codestream renders as diagonal garbage in every
viewer. Raw samples embed as Flate.

Embedding an image then adding the page that draws it exposed a live
defect in PdfDocBuilder. add_object derived its number from
`3 + 2 * pages.len()`, so every add_page after an add_object silently
shifted a number already handed out. Embedding an image and then adding
its page — the natural order, since the page's content stream has to
name the image — produced a page whose /XObject entry pointed at the
page object itself:

  3 0 obj <</Type /Page ... /XObject <</Im0 3 0 R>>>>

The file parsed. The reference resolved. The resource was the page.

This is the same positional-numbering defect already fixed once for
fonts, one layer out — the comment above first_extra_object_number
describes the font version, where /ToUnicode pointed at the descriptor
and /FontFile2 at the Type0 wrapper. Both come from deriving object
numbers from collections that are still growing. Fixed at the root: the
page count is frozen when the first extra number is issued, and pages
added afterwards are allocated past the fixed block instead of
colliding with it. Non-contiguous page numbers are legal — /Kids is an
explicit array — and 952 tests confirm nothing depended on the order.

The integration tests parse the generated file back with PdfDocument and
assert the image appears in `page.xobjects` with subtype Image, that its
/Width and /Height match the SOF marker, and that the header and footer
baselines are at opposite ends of the page. Reading the resource back is
the assertion that matters: a substring check for "/Im0 Do" passed
throughout the entire period when no image could be embedded at all.

Verified by mutation, five injected defects, each confirmed red:

  numbering fix reverted        4 fail
  JPEG width/height transposed  5 fail
  header positioned from bottom 3 fail
  sample-count check removed    1 fail
  attach_image_to_page a no-op  5 fail

One test needed correcting rather than the code: three assertions
grepped the output for operators, which are Flate-compressed by default,
so they were asserting against compressed bytes. They now disable
compression explicitly — the structure is identical either way, and the
alternative was a test of miniz_oxide.

Engine suite 920 -> 952. Coverage 86.16% -> 86.40%; stamp.rs at 94.64%
with a floor at 90.

Phase 4 is complete and the plan records it, including the numbering
defect, since a status table that lists only features would not have
told the next reader why the object numbers look the way they do.
2026-08-16 22:27:32 +00:00

215 lines
7.5 KiB
Bash
Executable file

#!/usr/bin/env bash
# LLVM source-coverage for the PDF engine crates, with an enforced floor.
#
# Why a floor and not a report: coverage that is only ever *printed* drifts
# down. Every silently-empty bug this stack has shipped lived in a file
# nobody was watching - `image.rs` sat at 14% while the JPEG decoder was a
# stub returning black rectangles, and no build ever said so.
#
# The floor is deliberately set a little under today's measurement, so
# ordinary refactoring does not trip it but a real regression does. It is
# not a target: raising a number here is only meaningful when the tests
# that raised it assert values. See
# `REVIEWS/adr/0017-pdf-declared-versus-delivered.md`.
#
# Usage:
# ./tools/test-pdf-coverage.sh # enforce the floor
# PDF_COVERAGE_REPORT_ONLY=1 ./tools/test-pdf-coverage.sh
#
# Runs entirely inside one temporary directory: its own rustup, cargo home
# and target dir, all removed on exit. It does not touch ~/.cargo, the
# workspace target directory or any source file.
set -Eeuo pipefail
IFS=$'\n\t'
ROOT="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
TOOLCHAIN="${RUST_TOOLCHAIN:-1.97.1}"
# The whole-stack floor. Measured at 83.9%; set below that so a routine
# change does not fail the build, while a real loss of coverage does.
TOTAL_FLOOR="${PDF_COVERAGE_TOTAL_FLOOR:-80}"
# Per-file floors for the files that have actually harboured the bugs.
#
# A single whole-stack number hides exactly the failure this is meant to
# catch: `image.rs` can fall from 33% to 5% while the total moves by less
# than a point, because the total is dominated by the files that were
# already well covered. Each entry is "path:floor", set a few points under
# the current measurement.
#
# These are floors on files that were once badly covered, not a list of
# files that matter. A file missing from here is still counted in the total.
PER_FILE_FLOORS="${PDF_COVERAGE_PER_FILE_FLOORS:-\
pdf-graphics/src/image.rs:42
pdf-graphics/src/jpeg.rs:74
pdf-graphics/src/font.rs:40
pdf-graphics/src/cmap.rs:30
pdf-graphics/src/content_writer.rs:50
pdf-graphics/src/graphics_state.rs:60
pdf-cos/src/filter.rs:70
pdf-cos/src/ccitt.rs:88
pdf-cos/src/jbig2.rs:90
pdf-cos/src/jpx.rs:88
pdf-graphics/src/stamp.rs:90
pdf-document/src/page.rs:88
pdf-document/src/document.rs:72
pdf-document/src/destinations.rs:94
pdf-document/src/annotations.rs:70}"
WORK="$(mktemp -d "${TMPDIR:-/tmp}/pdf-coverage.XXXXXXXX")"
cleanup() {
local status=$?
if [ "${KEEP_TEST_ENV:-0}" = "1" ]; then
printf 'kept coverage environment at %s\n' "$WORK" >&2
else
chmod -R u+w "$WORK" 2>/dev/null || true
rm -rf "$WORK"
fi
exit "$status"
}
trap cleanup EXIT HUP INT TERM
export RUSTUP_HOME="$WORK/rustup"
export CARGO_HOME="$WORK/cargo"
export CARGO_TARGET_DIR="$WORK/target"
export PATH="$CARGO_HOME/bin:$PATH"
export LLVM_PROFILE_FILE="$WORK/profiles/%p-%m.profraw"
export RUSTFLAGS="-C instrument-coverage -C codegen-units=1 -C opt-level=0"
mkdir -p "$WORK/profiles"
printf 'installing Rust %s with llvm-tools\n' "$TOOLCHAIN"
curl --fail --silent --show-error --location https://sh.rustup.rs \
-o "$WORK/rustup-init"
chmod 700 "$WORK/rustup-init"
"$WORK/rustup-init" -y --profile minimal --default-toolchain "$TOOLCHAIN" \
--component llvm-tools-preview --no-modify-path >/dev/null
# The three engine crates are wired by relative path dependencies, so they
# are copied together into a standalone workspace. pdf-makepad is excluded:
# it needs GUI system libraries and is not part of the engine's coverage.
SRC="$ROOT/crates/apps/pdf"
mkdir -p "$WORK/pdf"
cp -a "$SRC/pdf-cos" "$SRC/pdf-document" "$SRC/pdf-graphics" "$SRC/tests" \
"$WORK/pdf/"
# The fuzz directory is its own workspace and breaks the copied one.
rm -rf "$WORK/pdf/pdf-cos/fuzz"
cat > "$WORK/pdf/Cargo.toml" <<'TOML'
[workspace]
members = ["pdf-cos", "pdf-document", "pdf-graphics"]
resolver = "2"
TOML
printf 'running the engine test suites under instrumentation\n'
cargo test --manifest-path "$WORK/pdf/Cargo.toml" --workspace --all-targets \
>"$WORK/test.log" 2>&1 || { cat "$WORK/test.log"; exit 1; }
grep -E 'test result' "$WORK/test.log" | tail -20
HOST="$(rustc -vV | sed -n 's/^host: //p')"
LLVM_BIN="$RUSTUP_HOME/toolchains/$TOOLCHAIN-$HOST/lib/rustlib/$HOST/bin"
"$LLVM_BIN/llvm-profdata" merge -sparse "$WORK/profiles"/*.profraw \
-o "$WORK/coverage.profdata"
# Every instrumented test binary contributes; a single one under-reports
# badly, because each crate's tests cover the crates beneath it.
mapfile -t BINARIES < <(
find "$CARGO_TARGET_DIR/debug/deps" -maxdepth 1 -type f -executable \
! -name '*.d' ! -name '*.so' | sort
)
OBJECTS=()
for bin in "${BINARIES[@]}"; do
OBJECTS+=(-object "$bin")
done
if [ "${#OBJECTS[@]}" -eq 0 ]; then
echo 'no instrumented binaries were produced' >&2
exit 1
fi
# Only the crates' own `src/` trees count. The work directory is named
# `pdf-coverage.*`, so it must not appear in this pattern: matching it once
# excluded every source file and reported a confident 0.00%.
IGNORE='(/cargo/registry|/cargo/git|/rustc/|/pdf/[^/]+/tests/|/pdf/tests/)'
printf '\n=== per-file coverage ===\n'
"$LLVM_BIN/llvm-cov" report "${OBJECTS[@]}" \
-instr-profile="$WORK/coverage.profdata" \
-ignore-filename-regex="$IGNORE" \
| tee "$WORK/report.txt"
# `llvm-cov export` gives a machine-readable total rather than a parsed
# table, so the gate does not depend on column alignment.
"$LLVM_BIN/llvm-cov" export "${OBJECTS[@]}" \
-instr-profile="$WORK/coverage.profdata" \
-ignore-filename-regex="$IGNORE" \
-summary-only > "$WORK/coverage.json"
TOTAL="$(python3 - "$WORK/coverage.json" <<'PY'
import json, sys
with open(sys.argv[1]) as fh:
data = json.load(fh)
totals = data["data"][0]["totals"]["lines"]
print(f'{totals["percent"]:.2f}')
PY
)"
printf '\ntotal line coverage: %s%% (floor %s%%)\n' "$TOTAL" "$TOTAL_FLOOR"
if [ "${PDF_COVERAGE_REPORT_ONLY:-0}" = "1" ]; then
echo 'report-only mode: the floor was not enforced'
exit 0
fi
python3 - "$WORK/coverage.json" "$TOTAL_FLOOR" "$PER_FILE_FLOORS" <<'PY'
import json
import sys
path, total_floor, per_file = sys.argv[1], float(sys.argv[2]), sys.argv[3]
with open(path) as fh:
data = json.load(fh)
failures = []
total = data["data"][0]["totals"]["lines"]["percent"]
if total < total_floor:
failures.append(
f" total {total:.2f}% is below the floor of {total_floor:.2f}%"
)
# Match on suffix: the report carries absolute paths inside the throwaway
# work directory, which differ every run.
measured = {}
for entry in data["data"][0]["files"]:
measured[entry["filename"]] = entry["summary"]["lines"]["percent"]
for line in per_file.split():
if not line.strip():
continue
name, _, floor = line.rpartition(":")
floor = float(floor)
matches = [v for k, v in measured.items() if k.endswith(name)]
if not matches:
failures.append(
f" {name} has a floor but was not measured - was it renamed or "
"deleted? A floor on a file that no longer exists silently "
"protects nothing."
)
continue
got = matches[0]
if got < floor:
failures.append(f" {name} {got:.2f}% is below its floor of {floor:.2f}%")
if failures:
print("coverage floors not met:", file=sys.stderr)
print("\n".join(failures), file=sys.stderr)
print(
"\nEither the change removed tested code, or it added untested code.\n"
"Lowering a floor is a reviewable edit to tools/test-pdf-coverage.sh,\n"
"not something to do quietly.",
file=sys.stderr,
)
sys.exit(1)
print(f"all coverage floors met (total {total:.2f}%)")
PY