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
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.
215 lines
7.5 KiB
Bash
Executable file
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
|