diff --git a/.github/workflows/release-libpdfium.yml b/.github/workflows/release-libpdfium.yml
index ec2720c702..9ecd6fc1d0 100644
--- a/.github/workflows/release-libpdfium.yml
+++ b/.github/workflows/release-libpdfium.yml
@@ -15,13 +15,74 @@ on:
type: boolean
required: false
default: false
+ upstream_gate_override_reason:
+ description: >-
+ Publish while missing upstream PDFium commits older than 2 days: why.
+ The release notes list them; likely security fixes must be cherry-picked
+ (docs/embedpdf/upstream-sync.md)
+ required: false
+ default: ''
permissions:
contents: read
jobs:
+ # The release gates (docs/embedpdf/upstream-sync.md, "Release gates").
+ upstream-gate:
+ name: Upstream freshness and fork tags
+ runs-on: ubuntu-24.04
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ ref: ${{ inputs.ref || github.ref_name }}
+ fetch-depth: 0
+
+ - name: Fetch upstream PDFium
+ shell: bash
+ run: |
+ for attempt in 1 2 3 4 5; do
+ if git fetch --no-tags https://pdfium.googlesource.com/pdfium main:refs/remotes/upstream/main; then
+ exit 0
+ fi
+ sleep $((attempt * 30))
+ done
+ exit 1
+
+ # Enforced only when publishing: sync branches are behind upstream on
+ # purpose until they merge.
+ - name: Check upstream freshness
+ shell: bash
+ env:
+ ENFORCE: ${{ inputs.release == true }}
+ OVERRIDE_REASON: ${{ inputs.upstream_gate_override_reason }}
+ run: |
+ mkdir -p out/upstream-gate
+ args=(--notes out/upstream-gate/notes.md --json out/upstream-gate/upstream.json)
+ if [[ "$ENFORCE" == true ]]; then
+ args+=(--enforce)
+ fi
+ python3 scripts/embedpdf-runtime/check-upstream-freshness.py "${args[@]}" \
+ --override-reason "$OVERRIDE_REASON"
+
+ - name: Check fork tags
+ shell: bash
+ run: python3 scripts/embedpdf-runtime/check-fork-tags.py --strict
+
+ - uses: actions/upload-artifact@v6
+ with:
+ name: upstream-gate
+ path: out/upstream-gate/
+
+ tsan-soak:
+ name: ThreadSanitizer soak
+ needs: upstream-gate
+ uses: ./.github/workflows/pdfium-tsan-thread-soak.yml
+ with:
+ ref: ${{ inputs.ref || github.ref_name }}
+
source-tests:
name: Source tests (Linux x64)
+ needs: upstream-gate
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
@@ -51,6 +112,12 @@ jobs:
shell: bash
run: scripts/embedpdf-runtime/test-target.sh linux-x64
+ - name: Run embedder tests in layer mode
+ shell: bash
+ run: |
+ python3 testing/tools/run_layer_mode.py out/embedpdf-runtime-tests-linux-x64 \
+ --out out/embedpdf-runtime-test-results/linux-x64/layer-mode
+
- name: Upload PDFium test results
if: always()
uses: actions/upload-artifact@v6
@@ -168,6 +235,11 @@ jobs:
- name: Build target
shell: bash
run: scripts/embedpdf-runtime/build-target.sh "${{ matrix.target }}"
+ # The native server runs this target on several threads in one process.
+ - name: Check writable globals
+ if: matrix.target == 'linux-x64'
+ shell: bash
+ run: python3 scripts/embedpdf-runtime/check-globals.py out/embedpdf-runtime/linux-x64/libembedpdf.so
- name: Package target
shell: bash
run: scripts/embedpdf-runtime/package-target.sh "${{ matrix.target }}"
@@ -179,7 +251,7 @@ jobs:
release:
name: Publish release
runs-on: ubuntu-24.04
- needs: build
+ needs: [upstream-gate, tsan-soak, source-tests, build]
if: github.event_name == 'workflow_dispatch' && inputs.release == true
permissions:
contents: write
@@ -195,16 +267,24 @@ jobs:
run: |
mkdir -p out/embedpdf-runtime-artifacts
find artifacts -name '*.tar.gz' -exec cp {} out/embedpdf-runtime-artifacts/ \;
+ # The built ref's commit, which can differ from the commit the workflow
+ # was dispatched from.
+ - name: Name the release
+ id: name
+ shell: bash
+ run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Write manifest
shell: bash
run: |
- tag="runtime-$(git rev-parse HEAD)"
+ tag="runtime-${{ steps.name.outputs.sha }}"
export PDF_RUNTIME_ARTIFACT_DIR="$PWD/out/embedpdf-runtime-artifacts"
export PDF_RUNTIME_RELEASE_BASE_URL="https://github.com/${{ github.repository }}/releases/download/$tag"
+ export PDF_RUNTIME_UPSTREAM_FILE="$PWD/artifacts/upstream-gate/upstream.json"
scripts/embedpdf-runtime/write-manifest.sh "$PDF_RUNTIME_ARTIFACT_DIR/pdf-runtime-build.generated.json"
- uses: ncipollo/release-action@v1
with:
- tag: runtime-${{ github.sha }}
- name: EmbedPDF PDF Runtime ${{ github.sha }}
+ tag: runtime-${{ steps.name.outputs.sha }}
+ name: EmbedPDF PDF Runtime ${{ steps.name.outputs.sha }}
+ bodyFile: artifacts/upstream-gate/notes.md
artifacts: out/embedpdf-runtime-artifacts/*
allowUpdates: true
diff --git a/.github/workflows/upstream-mirror.yml b/.github/workflows/upstream-mirror.yml
index 1f92f716e2..2dc4a645fa 100644
--- a/.github/workflows/upstream-mirror.yml
+++ b/.github/workflows/upstream-mirror.yml
@@ -35,23 +35,4 @@ jobs:
- name: Report what embedpdf/main hasn't merged
shell: bash
- run: |
- set -euo pipefail
- base=$(git merge-base HEAD upstream/main)
- count=$(git rev-list --count "$base..upstream/main")
- oldest=$(git log --reverse --format=%ci "$base..upstream/main" | head -1)
- {
- echo "## Upstream PDFium not in embedpdf/main"
- echo
- echo "- Base: \`$(git log -1 --format='%h %ad %s' --date=short "$base")\`"
- echo "- Unmerged upstream commits: **$count**${oldest:+, the oldest from $oldest}"
- echo
- echo "### Likely security fixes"
- echo
- } >> "$GITHUB_STEP_SUMMARY"
- git log --format='%h%x09%ad%x09%s' --date=short "$base..upstream/main" |
- grep -iE 'overflow|crash|out[- ]of[- ]bounds|use[- ]after[- ]free|heap|stack[- ]buffer|null deref|fuzz|security|sanitiz' |
- while IFS=$'\t' read -r hash date subject; do
- echo "- \`$hash\` $date $subject" >> "$GITHUB_STEP_SUMMARY"
- echo "::warning title=Likely upstream security fix::$hash $subject"
- done || true
+ run: python3 scripts/embedpdf-runtime/check-upstream-freshness.py
diff --git a/docs/embedpdf/upstream-sync.md b/docs/embedpdf/upstream-sync.md
index 5699e03a4b..a8bc04fe2e 100644
--- a/docs/embedpdf/upstream-sync.md
+++ b/docs/embedpdf/upstream-sync.md
@@ -75,6 +75,26 @@ All commands run from the fork's root.
`BUILD.gn.orig`, so only the merge is committed.
7. **Open a PR into `embedpdf/main`** with the report.
+## Release gates
+
+`release-libpdfium.yml` runs these before anything is published, on every run:
+
+- **Upstream freshness** (`check-upstream-freshness.py`): lists the upstream
+ commits the ref misses, likely security fixes marked. With `release` on it
+ fails when the oldest one landed more than 2 days ago. The release notes carry
+ the list, and the manifest the upstream commit the release is based on.
+- **Fork tags** (`check-fork-tags.py --strict`).
+- **Layer mode** on the Linux test build (`run_layer_mode.py`).
+- **Writable globals** on the linux-x64 library (`check-globals.py`).
+- **ThreadSanitizer soak** (`pdfium-tsan-thread-soak.yml`).
+
+**The override.** When a sync can't land before a release, set
+`upstream_gate_override_reason`. The release then publishes with a warning, and
+its notes give the reason and every upstream commit it misses. Every likely
+security fix it holds back must be cherry-picked into the release first, with
+`git cherry-pick -x` (the gate checks for the `cherry picked from commit` line).
+The next release tries the full sync again.
+
## Never silently
- Take upstream's side of a tagged block without a line in the report.
diff --git a/scripts/embedpdf-runtime/check-globals.py b/scripts/embedpdf-runtime/check-globals.py
index 66f0b281b8..53b9024ae9 100755
--- a/scripts/embedpdf-runtime/check-globals.py
+++ b/scripts/embedpdf-runtime/check-globals.py
@@ -51,8 +51,9 @@
THREAD_LOCAL_SECTIONS = ('.tdata', '.tbss', '__DATA,__thread_vars')
# `
O [] [.hidden] `; ELF has the size.
-SYMBOL = re.compile(r'^[0-9a-f]+\s.*?\sO\s+(\S+)\s+(?:[0-9a-f]{8,}\s+)?'
- r'(?:\.hidden\s+)?(.+)$')
+# ELF thread-local symbols (STT_TLS) have no `O` flag: ` l .tbss ...`.
+SYMBOL = re.compile(r'^[0-9a-f]+\s.*?\s(?:O\s+(\S+)|(\.tbss|\.tdata))\s+'
+ r'(?:[0-9a-f]{8,}\s+)?(?:\.hidden\s+)?(.+)$')
def normalize(name, macho):
@@ -76,7 +77,10 @@ def read_symbols(library):
m = SYMBOL.match(line)
if not m:
continue
- section, name = m.group(1), normalize(m.group(2).strip(), macho)
+ section = m.group(1) or m.group(2)
+ name = normalize(m.group(3).strip(), macho)
+ if name == section: # the section's own symbol
+ continue
if section in SHARED_SECTIONS:
shared.add(name)
elif section in THREAD_LOCAL_SECTIONS:
diff --git a/scripts/embedpdf-runtime/check-upstream-freshness.py b/scripts/embedpdf-runtime/check-upstream-freshness.py
new file mode 100755
index 0000000000..86f25c855a
--- /dev/null
+++ b/scripts/embedpdf-runtime/check-upstream-freshness.py
@@ -0,0 +1,161 @@
+#!/usr/bin/env python3
+# Copyright 2026 CloudPDF LTD
+# SPDX-License-Identifier: Apache-2.0
+
+"""Check that HEAD misses no upstream PDFium commit older than 2 days.
+
+The release gate of docs/embedpdf/upstream-sync.md: a release may not miss an
+upstream commit that was more than 2 days old when the release started. It
+lists the upstream commits HEAD doesn't contain (`git log ^HEAD`),
+marks the ones that look like security fixes, and finds the upstream commit
+HEAD is based on (the merge base), which the release manifest records.
+
+It fails, with --enforce, when the oldest missing commit is older than
+--max-age-hours. With --override-reason it warns instead, but a missing commit
+that looks like a security fix must still be in HEAD as a cherry-pick: a commit
+since the base whose message says `(cherry picked from commit )`
+(`git cherry-pick -x`). Without --enforce it only reports, as the mirror job and
+release=false runs do.
+
+Commit times are committer dates: when the commit landed upstream.
+
+Usage:
+ check-upstream-freshness.py [--upstream REF] [--max-age-hours N] [--enforce]
+ [--override-reason TEXT] [--notes FILE]
+ [--json FILE]
+
+ --upstream upstream main, fetched. Default: upstream/main.
+ --notes write the report as Markdown (release notes, run summary).
+ --json write {sha, date, subject} of the upstream base, plus the
+ missing count and the override, for the release manifest.
+
+In GitHub Actions the report also goes to the run summary, and each likely
+security fix becomes a warning.
+"""
+
+import argparse
+import json
+import os
+import re
+import subprocess
+import sys
+import time
+
+SOURCE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
+
+# Subjects that look like security fixes; the mirror job runs this script too.
+# Not "fuzz" (fuzzer build changes, fuzzy image matching: fixes a fuzzer found
+# say crash or overflow) nor a bare "heap" (heap allocators).
+SECURITY = re.compile(
+ r'overflow|crash|out[- ]of[- ]bounds|\boob\b|use[- ]after[- ]free|\buaf\b|'
+ r'(?:heap|stack)[- ]buffer|null(?:ptr)? deref|security|sanitiz|\bcve\b',
+ re.IGNORECASE)
+CHERRY_PICK = re.compile(r'\(cherry picked from commit ([0-9a-f]{40})\)')
+
+
+def git(*args):
+ return subprocess.run(['git', '-C', SOURCE_DIR, *args],
+ check=True,
+ capture_output=True,
+ text=True,
+ errors='replace').stdout
+
+
+def commits(*rev_args):
+ """Returns [(sha, unix time, ISO date, subject)], newest first."""
+ out = git('log', '--format=%H%x09%ct%x09%cI%x09%s', *rev_args)
+ result = []
+ for line in out.splitlines():
+ sha, stamp, date, subject = line.split('\t', 3)
+ result.append((sha, int(stamp), date, subject))
+ return result
+
+
+def cherry_picked(base):
+ return set(CHERRY_PICK.findall(git('log', '--format=%B', f'{base}..HEAD')))
+
+
+def age_text(seconds):
+ hours = seconds / 3600
+ return f'{hours / 24:.1f} days' if hours >= 48 else f'{hours:.0f} hours'
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description=__doc__.split('\n\n')[0],
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument('--upstream', default='upstream/main')
+ parser.add_argument('--max-age-hours', type=float, default=48)
+ parser.add_argument('--enforce', action='store_true')
+ parser.add_argument('--override-reason', default='')
+ parser.add_argument('--notes')
+ parser.add_argument('--json')
+ args = parser.parse_args()
+
+ now = time.time()
+ base = git('merge-base', 'HEAD', args.upstream).strip()
+ base_sha, _, base_date, base_subject = commits('-1', base)[0]
+ missing = commits(args.upstream, '^HEAD')
+ security = [c for c in missing if SECURITY.search(c[3])]
+ picked = cherry_picked(base)
+ oldest = missing[-1] if missing else None
+ stale = bool(oldest) and now - oldest[1] > args.max_age_hours * 3600
+ override = args.override_reason.strip()
+ unpicked = [c for c in security if c[0] not in picked]
+
+ lines = [
+ '## Upstream PDFium',
+ '',
+ f'- Based on upstream `{base_sha[:9]}` ({base_date[:10]}) {base_subject}',
+ ]
+ if oldest:
+ lines.append(f'- Missing {len(missing)} upstream commits; the oldest landed '
+ f'{age_text(now - oldest[1])} ago (limit: '
+ f'{age_text(args.max_age_hours * 3600)}).')
+ else:
+ lines.append('- Missing no upstream commit.')
+ if stale and override:
+ lines.append(f'- **Gate overridden:** {override}')
+ if missing:
+ lines += ['', '### Missing upstream commits', '']
+ for commit in missing:
+ sha, _, date, subject = commit
+ mark = ''
+ if commit in security:
+ mark = (' (likely security fix; cherry-picked)'
+ if sha in picked else ' (**likely security fix**)')
+ lines.append(f'- `{sha[:9]}` {date[:10]} {subject}{mark}')
+ report = '\n'.join(lines) + '\n'
+
+ print(report)
+ if os.environ.get('GITHUB_STEP_SUMMARY'):
+ with open(os.environ['GITHUB_STEP_SUMMARY'], 'a', encoding='utf-8') as f:
+ f.write(report)
+ if os.environ.get('GITHUB_ACTIONS'):
+ for sha, _, _, subject in security:
+ print(f'::warning title=Likely upstream security fix::{sha[:9]} {subject}')
+ if args.notes:
+ with open(args.notes, 'w', encoding='utf-8') as f:
+ f.write(report)
+ if args.json:
+ with open(args.json, 'w', encoding='utf-8') as f:
+ json.dump({'sha': base_sha, 'date': base_date, 'subject': base_subject,
+ 'missing': len(missing), 'override': override if stale else ''},
+ f, indent=2)
+ f.write('\n')
+
+ if not args.enforce or not stale:
+ return
+ if not override:
+ sys.exit(f'error: missing upstream commits older than '
+ f'{age_text(args.max_age_hours * 3600)}: sync first '
+ '(docs/embedpdf/upstream-sync.md), or pass an override reason.')
+ if unpicked:
+ sys.exit('error: an override still needs every likely security fix it '
+ 'holds back, cherry-picked with -x: ' +
+ ', '.join(c[0][:9] for c in unpicked))
+ print('warning: release gate overridden: ' + override)
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/embedpdf-runtime/globals-allowlist.txt b/scripts/embedpdf-runtime/globals-allowlist.txt
index 1104e5f5d4..0a8d56a54f 100644
--- a/scripts/embedpdf-runtime/globals-allowlist.txt
+++ b/scripts/embedpdf-runtime/globals-allowlist.txt
@@ -46,6 +46,7 @@ set-once | (anonymous namespace)::AddStandardFont(CPDF_Document*)::encoding | Up
set-once | guard variable for (anonymous namespace)::AddStandardFont(CPDF_Document*)::encoding | Its initialization guard.
set-once | fxcodec::(anonymous namespace)::Basis()::basis | The JPEG writer's DCT basis: a const table built once (thread-safe static initialization) and only read after.
set-once | guard variable for fxcodec::(anonymous namespace)::Basis()::basis | Its initialization guard.
+set-once | CStretchEngine::Expand1BppRow(*)::kBitsToBytes | Upstream: a table built at compile time (constinit) and only read; not const, so it sits in writable data.
# --- Third-party code, shared by design.
third-party | std::__Cr::* | libc++: locale and facet state behind its own locks.
@@ -77,7 +78,8 @@ third-party | known_tables | HarfBuzz subsetter: a constant table.
third-party | langs | HarfBuzz: the language list, behind atomics.
third-party | endchar_str | HarfBuzz CFF: a constant.
third-party | only_cleanup_pass_is_decoded | openjpeg: a one-time warning flag, set under a mutex.
-third-party | Cr_z_arm_cpu_enable_* | zlib: CPU features, detected once.
+third-party | Cr_z_*_cpu_enable_* | zlib: CPU features, detected once (under cpu_check_inited_once).
+third-party | cpu_check_inited_once | zlib: the pthread_once flag of its CPU check.
third-party | cpu_info_ | libyuv: CPU features, detected on first use; read and written with relaxed atomics, and every thread detects the same value.
third-party | SkFibonacci47 | Skia path ops: a constant table.
third-party | SkMatrix::I()::identity | Skia path ops: a constant.
@@ -86,3 +88,6 @@ third-party | SkPathData::PeekEmptySingleton()::gEmpty | Skia path ops: a once-b
third-party | guard variable for SkPath*::Peek* | Skia path ops: initialization guards.
third-party | next_pathdata_unique_id()::nextID | Skia path ops: an atomic counter.
third-party | fast_float::detail::rounds_to_nearest()::fmin | fast_float: a volatile constant.
+third-party | __dso_handle | The C runtime (ELF): this library's handle for atexit, set when it loads.
+third-party | DW.ref.__gxx_personality_v0 | The C++ runtime (ELF): the exception personality's address, set when the library loads.
+third-party | completed.0 | The C runtime (ELF, crtstuff): set once, when the library's destructors run at unload.
diff --git a/scripts/embedpdf-runtime/write-manifest.sh b/scripts/embedpdf-runtime/write-manifest.sh
index f53eb7c211..dee9a1ccf3 100755
--- a/scripts/embedpdf-runtime/write-manifest.sh
+++ b/scripts/embedpdf-runtime/write-manifest.sh
@@ -7,6 +7,9 @@ set -euo pipefail
SOURCE_DIR="${PDF_RUNTIME_SOURCE_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)}"
ARTIFACT_DIR="${PDF_RUNTIME_ARTIFACT_DIR:-$SOURCE_DIR/out/embedpdf-runtime-artifacts}"
RELEASE_BASE_URL="${PDF_RUNTIME_RELEASE_BASE_URL:-}"
+# The upstream PDFium commit the release is based on: the --json output of
+# check-upstream-freshness.py, which the release gate writes.
+UPSTREAM_FILE="${PDF_RUNTIME_UPSTREAM_FILE:-}"
OUT_FILE="${1:-$ARTIFACT_DIR/pdf-runtime-build.generated.json}"
SHA="$(git -C "$SOURCE_DIR" rev-parse HEAD)"
@@ -14,13 +17,19 @@ if [[ -z "$RELEASE_BASE_URL" ]]; then
echo "PDF_RUNTIME_RELEASE_BASE_URL is required" >&2
exit 1
fi
+if [[ -z "$UPSTREAM_FILE" || ! -f "$UPSTREAM_FILE" ]]; then
+ echo "PDF_RUNTIME_UPSTREAM_FILE is required (check-upstream-freshness.py --json)" >&2
+ exit 1
+fi
-node - <<'NODE' "$ARTIFACT_DIR" "$RELEASE_BASE_URL" "$OUT_FILE" "$SHA"
+node - <<'NODE' "$ARTIFACT_DIR" "$RELEASE_BASE_URL" "$OUT_FILE" "$SHA" "$UPSTREAM_FILE"
const fs = require('node:fs');
const path = require('node:path');
const crypto = require('node:crypto');
-const [artifactDir, releaseBaseUrl, outFile, sha] = process.argv.slice(2);
+const [artifactDir, releaseBaseUrl, outFile, sha, upstreamFile] = process.argv.slice(2);
+const gate = JSON.parse(fs.readFileSync(upstreamFile, 'utf8'));
+const upstream = { sha: gate.sha, date: gate.date };
const artifacts = {};
for (const file of fs.readdirSync(artifactDir)) {
@@ -37,7 +46,7 @@ for (const file of fs.readdirSync(artifactDir)) {
fs.writeFileSync(
outFile,
- JSON.stringify({ fork: 'embedpdf/runtime', sha, artifacts }, null, 2) + '\n',
+ JSON.stringify({ fork: 'embedpdf/runtime', sha, upstream, artifacts }, null, 2) + '\n',
);
NODE
diff --git a/testing/embedpdf_layer_mode_expectations.txt b/testing/embedpdf_layer_mode_expectations.txt
index a97488f8f0..c5b8177ff8 100644
--- a/testing/embedpdf_layer_mode_expectations.txt
+++ b/testing/embedpdf_layer_mode_expectations.txt
@@ -15,11 +15,15 @@ FPDFFormFill*.* Drives the form-fill environment.
CPDFSDKBAAnnotTest.* Drives the form-fill environment.
CPDFSDKAnnotIteratorTest.* Drives the form-fill environment.
FPDFAnnotEmbedderTest.FocusableAnnotSubtypes Drives the form-fill environment.
+FPDFAnnotEmbedderTest.FocusableAnnotRendering Drives the form-fill environment.
FPDFProgressiveRenderEmbedderTest.RenderFormWithColorScheme Drives the form-fill environment.
+FPDFProgressiveRenderEmbedderTest.RenderFormsWithPause Drives the form-fill environment.
FPDFAnnotEmbedderTest.GetFontSize* Needs the form-fill page view (FPDF_FORMHANDLE APIs).
FPDFAnnotEmbedderTest.GetFontColor Needs the form-fill page view (FPDF_FORMHANDLE APIs).
FPDFAnnotEmbedderTest.IsChecked* Needs the form-fill page view (FPDF_FORMHANDLE APIs).
FPDFAnnotEmbedderTest.GetFormFieldExportValue* Needs the form-fill page view (FPDF_FORMHANDLE APIs).
+FPDFFlattenEmbedderTest.Bug861842 Pixel test of a form; layer mode doesn't draw with FPDF_FFLDraw.
+FPDFFlattenEmbedderTest.Bug889099 Pixel test of a form; layer mode doesn't draw with FPDF_FFLDraw.
FPDFFlattenEmbedderTest.Bug890322 Pixel test of a form; layer mode doesn't draw with FPDF_FFLDraw.
FPDFFlattenEmbedderTest.Bug896366 Pixel test of a form; layer mode doesn't draw with FPDF_FFLDraw.
diff --git a/testing/embedpdf_layer_mode_writers.txt b/testing/embedpdf_layer_mode_writers.txt
index 14fa1a378d..aedb9a6406 100644
--- a/testing/embedpdf_layer_mode_writers.txt
+++ b/testing/embedpdf_layer_mode_writers.txt
@@ -268,6 +268,7 @@ FPDFTransformEmbedderTest.SetCropBox 1
FPDFTransformEmbedderTest.SetMediaBox 1
FPDFTransformEmbedderTest.TransFormWithClip 9
FPDFTransformEmbedderTest.TransFormWithClipAndSave 5
+FPDFTransformEmbedderTest.TransFormWithClipAndSaveWithLocale 5
PDFEditImgTest.GetAndSetMatrixForFormWithImage 3
RichTextParityTest.DecorationsFixtureMatchesAcrobat 2
RichTextParityTest.LinesFixtureMatchesAcrobat 2
diff --git a/testing/tools/run_layer_mode.py b/testing/tools/run_layer_mode.py
index bc7e73706d..3295b0b5d6 100755
--- a/testing/tools/run_layer_mode.py
+++ b/testing/tools/run_layer_mode.py
@@ -199,7 +199,9 @@ def main():
ran = [r[0] for r in results]
for pattern in expectations:
matched = [t for t in ran if fnmatch.fnmatchcase(t, pattern)]
- if matched and not any(t in layer_only for t in matched):
+ # A test that fails the plain run too (a platform's goldens) says nothing.
+ if matched and not any(t in layer_only or t in plain_failed
+ for t in matched):
warnings.append(f'expected to fail in layer mode, but passes: {pattern}')
if args.record_writers: