Build scripts, patches and GitHub Actions workflows that produce the two native libraries used by a private PDF editor for macOS and iPadOS:
PDFium.xcframework: Google's PDFium, optionally with the Apache-2.0 EmbedPDF extension patch series (redaction, form fields, signatures and more).QPDF.xcframework: qpdf built statically with native crypto (no GnuTLS, no OpenSSL).
The app repository is private. This repository is public so that the standard GitHub-hosted macOS runners are free, and so that nothing is built on the author's Mac (which is shared with an AI system that has a hard memory ceiling). Nothing personal is stored here.
A release tag looks like v<major>.<minor>.<patch>-pdfium<NNNN>, for example v0.1.0-pdfium8100; the four-digit number must equal the PDFIUM_BRANCH pin in versions.env. A -stock suffix means no EmbedPDF patches; a -macos-only suffix means only the macOS slice; either one marks a pre-release. scripts/release-tag.sh parses all four shapes (plain, -stock, -macos-only, -stock-macos-only), but release.yml builds only two of them: the plain tag (the full release) and -stock-macos-only. It refuses -stock alone and -macos-only alone.
| Asset | Contents |
|---|---|
PDFium.xcframework.zip |
static libpdfium.a per slice, public headers, a PDFium.h umbrella header and a module map |
QPDF.xcframework.zip |
static libqpdf.a (libjpeg-turbo merged in) per slice, headers |
LICENSES.zip |
every third-party license notice for what is linked |
checksums.txt |
SHA-256 of each zip, in shasum -a 256 format |
The app repository pins a tag and these checksums in its Engines.lock and downloads with gh release download.
versions.env holds every version this repository builds from (PDFium branch, EmbedPDF commit and the upstream commit it last merged, qpdf, libjpeg-turbo, depot_tools, deployment targets). Scripts read it with load_versions from scripts/common.sh; a variable already set in the environment wins, so one run can override one pin.
Refresh rule: the PDFium branch moves to a newer chromium/NNNN branch at least every 90 days, and sooner when a Chrome security release lists a PDFium fix.
- Standard GitHub-hosted runners only (no larger runners, no self-hosted runners). The Actions spending limit of the owning account stays at $0, so a mistake fails instead of billing.
- Every third-party action is pinned to a full commit SHA. Workflows default to
permissions: contents: read; only the release job hascontents: write.scripts/check-workflows.shenforces this and runs in CI. - Commits carry the GitHub noreply address only.
scripts/check-commit-identity.shenforces this and runs in CI. - No document of the author is ever used here. Test fixtures are tiny files generated by
smoke/pdfium/make-fixtures.pywith invented text.
Every script has a self-test in scripts/tests/<name>-test.sh that needs no network (fake git, gn, ninja and gclient, tiny stub archives). Run them all with:
for t in scripts/tests/*-test.sh; do echo "== $t"; bash "$t"; done
Tests that need macOS tools (xcodebuild, lipo, libtool) skip themselves on Linux.
Scripts and patch files authored here: Apache-2.0 (see LICENSE). Third-party code keeps its own license; see NOTICE.
Builds run on the standard arm64 macOS runner label xcode-27: macOS 27 with Xcode 27.0 (build 27A266a, the same Xcode as the author's Mac), selected in the workflows with /Applications/Xcode_27.0.app. As of October 2026 xcode-27 is a public preview label. The workflows read the runner label from the repository variable MACOS_RUNNER and fall back to xcode-27. If the label disappears, set MACOS_RUNNER to macos-26 and change XCODE_APP in the workflows to the newest Xcode that image lists; the deployment target stays 27.0 and PDFium needs no macOS 27 symbols, but record what you changed. Never use a -large or -xlarge label (they are paid); scripts/check-workflows.sh rejects them.
All compilation happens in CI. Do not run scripts/pdfium-checkout.sh or scripts/pdfium-build.sh on a personal Mac: the checkout is several GB and the build takes 30 minutes or more.
The XCFramework has one library identifier, macos-arm64, holding libpdfium.a (a complete static archive: PDFium plus its bundled third-party code), Headers/module.modulemap and Headers/PDFium/ (every fpdf*.h and a generated PDFium.h umbrella header). Swift imports the module with -I <slice>/Headers and import PDFium; C and C++ code uses the same search path and #include "PDFium/PDFium.h". A consumer links -lc++ and the frameworks AppKit, CoreFoundation, CoreGraphics, CoreText and Foundation. smoke/pdfium/run-smoke.sh <PDFium.xcframework> shows the exact link line.
Push a tag. The tag shapes, the all-or-nothing gate, the dry run and what to do after a failed run are described under Releases below.
patches/embedpdf/ holds seven patch files derived from https://github.com/embedpdf/runtime (Apache-2.0 additions by CloudPDF LTD on top of PDFium, BSD): the epdf_* extension API (redaction, form fields, signatures, digests, checkpoints, fonts, measurements and more) and the core changes it needs. The series is the difference between EMBEDPDF_UPSTREAM_BASE (the upstream PDFium commit the fork last merged) and EMBEDPDF_COMMIT, split by area, with a provenance header on every file. SOURCE.txt records the pins and a checksum per patch; EXCLUDED.txt lists the fork files that are deliberately not carried (its CI files, build patches, scripts, NOTICE).
scripts/pdfium-patches-export.shregenerates the series from the pins;--checkfails when the committed series differs from what the pins produce (CI runs it).scripts/pdfium-patches-control.shproves the export is correct: the series must apply to its own base without a conflict and reproduce the fork's tree.scripts/pdfium-patches-apply.shappliespatches/pdfium-binaries/and thenpatches/embedpdf/to a PDFium checkout, all or nothing, and writesdist/patch-report.txt. A conflict means upstream PDFium changed code the fork also changed; the report names the files and hunks. Nothing is applied in that case and the build fails.scripts/pdfium-tests.shbuilds and runs the fork's own tests (redaction, forms, signatures are the gate).
To move the pins: change PDFIUM_BRANCH, EMBEDPDF_COMMIT and EMBEDPDF_UPSTREAM_BASE in versions.env together. The base is the upstream PDFium parent of the fork's newest upstream sync merge into the commit, a merge whose message reads either "Update PDFium" or "Merge commit '' into embedpdf/sync/"; neither the export script's guards (no public/epdf_redact.h, an ancestor of EMBEDPDF_COMMIT) nor the control experiment can tell a newest base from an older one, because any older upstream ancestor passes both. Cross-check it by hand instead: the message of the fork's newest sync pull request ("merge upstream PDFium up to ") and its newest sync record, docs/embedpdf/syncs/<date>-<sha9>.md in the fork, must name the same base. Then run the export and the control, commit the new series, and read the patch report of the next CI run. docs/patch-reports/ keeps the reports of past runs (file names and counts only).
PDFium.xcframework holds static libraries for three slices, all built from the same pinned PDFium branch
and (for releases) the same EmbedPDF patch series:
| Slice identifier | Built by | Used for |
|---|---|---|
macos-arm64 |
scripts/pdfium-build.sh macos-arm64 |
the Mac app and pdfe |
ios-arm64 |
scripts/pdfium-build.sh ios-arm64 |
iPad and iPhone hardware |
ios-arm64-simulator |
scripts/pdfium-build.sh iossim-arm64 |
the iOS simulator on Apple silicon |
The iOS slices need pdfium-binaries' iOS patch (patches/pdfium-binaries/, MIT, provenance in its header).
The libraries are static, so nothing is embedded in an app: the app target links the XCFramework.
scripts/verify-xcframework.sh checks a framework's slices, architecture, headers, platform and minimum OS.
smoke/pdfium/ios-app is an iPad-only app that links the framework; CI runs its tests on an iPad simulator.
QPDF.xcframework holds static libqpdf.a for macos-arm64, ios-arm64 and ios-arm64-simulator, with
libjpeg-turbo merged in, zlib taken from the SDK (-lz) and qpdf's headers under Headers/qpdf/. There is
no module map: only the C++ shim in the app repo includes these headers.
Every external name of that libjpeg-turbo copy carries the prefix qpdfjt_ (jpeg_std_error is
qpdfjt_jpeg_std_error in the archive). PDFium has its own libjpeg, and a plain copy collides with it in an
app that links both (CI run 39083399705: both defined _jpeg_natural_order, and the link failed with a
duplicate symbol). scripts/qpdf-build.sh builds libjpeg-turbo once as is to list its names,
scripts/qpdf-jpeg-prefix.sh header turns them into #define lines, libjpeg-turbo and qpdf are compiled
with that header force-included, and scripts/qpdf-jpeg-prefix.sh check stops the build if the merged
archive still defines or references a libjpeg name without the prefix. Code that calls libjpeg itself
needs its own libjpeg, not this copy.
qpdf is built with native crypto only. USE_IMPLICIT_CRYPTO=OFF and REQUIRE_CRYPTO_NATIVE=ON keep GnuTLS
(LGPL) and OpenSSL out even when the build machine has them; scripts/qpdf-config-guard.sh checks the
generated qpdf-config.h right after configure and scripts/check-no-tls-symbols.sh scans every finished
archive with nm. The source tarballs are pinned by SHA-256 in sources.sha256
(scripts/pin-source-checksums.sh rewrites it when QPDF_VERSION or LIBJPEG_TURBO_VERSION changes).
A release has exactly four assets: PDFium.xcframework.zip, QPDF.xcframework.zip, LICENSES.zip and
checksums.txt (SHA-256, the format shasum -a 256 -c reads). Tags look like v0.1.0-pdfium8100; the number
after pdfium must equal the pinned PDFIUM_BRANCH. -stock-macos-only tags make a pre-release with the
stock macOS slice only (PDFium.xcframework.zip and checksums.txt).
Releases are all-or-nothing. scripts/release-publish.sh checks the tag, the asset set, every checksum, every
slice of both XCFrameworks and the notices before it creates a draft release, compares GitHub's own checksum
of each uploaded file with the local one, and only then publishes. One bad slice, one wrong checksum, and
nothing is published. To rehearse: Actions tab, release, "Run workflow", enter a tag, leave "dry run" ticked.
A real release (dry run off) only runs from the tag itself: push the tag, or use "Run workflow" with the tag
selected under "Use workflow from" (not main). A real run started from any other ref fails in the first job,
so a build of main can never be published under an older tag. To retry a failed tag run, use "Re-run failed
jobs" on that run.
The gate refuses to start when a release or a draft release already exists for the tag, and touches nothing when it refuses. A published release is never replaced or deleted by the workflow. A run that fails after it created its draft deletes that draft. If a draft is left behind anyway (publishing it failed, or deleting it did), look at it on the Releases page, delete it by hand, and only then re-run.
The build repo moves to a newer PDFium release at least every 90 days, and sooner when a post on the Chrome
Releases blog lists a PDFium security fix. refresh-check.yml runs scripts/refresh-check.sh every Monday. The
run fails, the reason is in the job summary and GitHub emails the repository owner, when a newer chromium/NNNN
branch exists and the pin is 90 or more days old, or when a Chrome Releases blog post dated after the pin
mentions a security fix in PDF or PDFium. Every post counts, not only the stable desktop notes: the blog also
carries ChromeOS and LTS notes, and a PDFium fix in any of them means the pinned PDFium is behind a fix. The
check reads the blog's feed page by page back to the day the pin was adopted, and it fails instead of reporting
"no refresh due" when it cannot read the feed, its dates or the git history (it needs a full clone,
fetch-depth: 0). A security reason clears only when the pin moves to a newer branch; rebuilding the same
branch does not clear it.
To refresh:
- Edit
PDFIUM_BRANCH(and, if needed,EMBEDPDF_COMMIT,EMBEDPDF_UPSTREAM_BASE,DEPOT_TOOLS_COMMIT) inversions.env. - When
EMBEDPDF_COMMITmoved, copyLICENSEandNOTICEfrom the root of the EmbedPDF repository at the new commit tolegal/embedpdf/LICENSEandlegal/embedpdf/NOTICE, and write the new commit id tolegal/embedpdf/COMMIT.scripts/licenses.shrefuses to assembleLICENSES.zipwhileCOMMITnames another commit thanEMBEDPDF_COMMIT, so the release would fail at that step. - Re-derive the patch series (
scripts/pdfium-patches-export.sh), readdist/patch-report.txtfrom the CI run, and tag a release.
The app repo then moves its Engines.lock to the new tag.
GitHub switches off scheduled workflows in a public repository after 60 days without repository activity, and
this repository is quiet between refreshes, so the weekly run can stop before the 90-day rule can fire. The run
does not fail when that happens, it just stops, and GitHub's documentation promises no notice. To see it, run gh workflow list --all (a plain
gh workflow list hides disabled workflows); the refresh-check row shows the state active, or
disabled_inactivity once GitHub has switched it off. To turn it back on, run
gh workflow enable refresh-check.yml, then start one run by hand ("Run workflow" on the refresh-check
workflow) to see the result. There is deliberately no keepalive job in this repository: it would need a write
permission, which scripts/check-workflows.sh does not allow outside the release job.