@openpresentation/opf 0.18.3 → 0.18.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/browser.js CHANGED
@@ -17,7 +17,7 @@ export { CHART_TYPES, DEFAULT_VARIABLE_DATE_FORMAT, ENGINE_DEFAULT_CHART_TYPES,
17
17
  export { catalogDisplayKinds, catalogSchemaNames } from './chunk-TRDXRRQK.js';
18
18
  export { CATALOG_GROUP_PATTERN, CATALOG_REFERENCE_PATTERN, OPFCatalogsOptionError, OPFUnresolvedReferenceError, catalogGroupSource, catalogKinds, catalogRecords, catalogReferenceSites, parseReference, resolveReference, unresolvedReference } from './chunk-SPFV27O5.js';
19
19
  export { audience, chartType, colorScheme, fontScheme, language, layout, narrative, presentation, purpose, schemaEntries, schemaNames, schemas, socialPlatform, theme, tone } from './chunk-2PEWUUBR.js';
20
- export { FONT_POLICY, applyFontPolicyDecisions, fontAvailabilityDiagnostics, fontPolicyFor } from './chunk-WXSPETQ6.js';
20
+ export { FONT_POLICY, applyFontPolicyDecisions, fontAvailabilityDiagnostics, fontPolicyFor } from './chunk-F7PLNWHQ.js';
21
21
 
22
22
  // src/browser.ts
23
23
  var nodeOnly = (name, message) => new OPFApiError(`\`${name}\` ${message}`, "node-only", { details: { function: name, build: "browser" } });
@@ -44,6 +44,11 @@ var fontPolicySource = {
44
44
  "replacement": {
45
45
  "family": "Noto Sans Thai",
46
46
  "compatibility": "visual",
47
+ "sizeAdjust": 0.75,
48
+ "lineAscent": 0.83,
49
+ "lineAscentMixed": 0.86,
50
+ "lineAscentBasis": "opf#361 native probe, PowerPoint 365 16.0 build 20430 (RR-38 setup, 2880 px; bottom edge of the last row with 35 percent of the darkest row's ink): 0.833 / 0.833 / 0.833 em at 18 / 24 / 32 pt, with a Latin run 0.856 / 0.866 / 0.862 (replaces the 0.85 read from 1280 px PNGs). Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
51
+ "sizeAdjustBasis": "opf#361 rule: the smallest factor (0.01 steps, up) at which no line of the FF-44 script corpus or of the scheme's Latin overhangs over 3 percent natively, regular and bold (Angsana New 5.06 advances over Noto Sans Thai's, fontkit, read in place). Exact 0.7401; worst line thai-digits +1.6 percent; Latin slack 42.9 percent (Latin is drawn by the replacement at the composed size). Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
47
52
  "measured": {
48
53
  "replacement": "Noto Sans Thai",
49
54
  "meanAbsWidthDelta": 0.7476,
@@ -275,9 +280,9 @@ var fontPolicySource = {
275
280
  "family": "Noto Naskh Arabic",
276
281
  "compatibility": "visual",
277
282
  "sizeAdjust": 0.64,
278
- "lineAscent": 0.7,
279
- "lineAscentMixed": 0.78,
280
- "lineAscentBasis": "RR-38 native PowerPoint 365 probe (probe-arabic.pptx, 18, 24 and 32 pt, 100 percent line spacing, zero insets, top anchor, rtl paragraph): the baseline of an Arabic Typesetting-only line (the most common ink-bottom row of the line) sits 0.68, 0.71 and 0.72 em below the box top (regular) and 0.68, 0.72 and 0.73 em (bold), against Arabic Typesetting's hhea ascent of 0.701 em; a line that also holds an Aptos run (Arabic, 'PowerPoint 365', Arabic) sits 0.77, 0.78 and 0.78 em below it. Core places the baseline one em below the line top, so the preview drew Arabic lines 0.22 to 0.30 em lower.",
283
+ "lineAscent": 0.74,
284
+ "lineAscentMixed": 0.8,
285
+ "lineAscentBasis": "opf#361 native probe, PowerPoint 365 16.0 build 20430 (RR-38 setup, 2880 px; bottom edge of the last row with 35 percent of the darkest row's ink): 0.741 / 0.736 / 0.740 em at 18 / 24 / 32 pt, with a Latin run 0.801 / 0.810 / 0.800. Corrects RR-38's 0.70 / 0.78 (an earlier estimator; RR-38's own 2560 px PNGs re-read 0.743 / 0.745 / 0.746 and 0.79 to 0.80 with this rule). Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
281
286
  "sizeAdjustBasis": "RR-38: sum of Arabic Typesetting 6.85 advances over sum of Noto Naskh Arabic 400 advances (fontkit, each sample's OpenType language). 0.643 on the 12 Arabic-script corpus samples of opf-render test/fixtures/script-corpora.json (per-sample range 0.57 to 0.86); 0.598 on 29 Arabic strings of the RR-05b native deck (running Arabic text); 0.614 pooled; Noto Naskh Arabic 700 gives 0.62, 0.58 and 0.59, so bold runs draw about 3 percent long. Ink height of alef, lam, meem and heh is 0.71 of Noto Naskh Arabic's. 0.64 balances the two: lines come out about 4 percent longer than PowerPoint's and glyphs about 10 percent smaller. Native check (RR-38 probe): PowerPoint's BoundWidth of the 45-character probe line is 216.9, 289.0 and 386.1 pt at 18, 24 and 32 pt, 1.8 to 2.0 percent above the fontkit advance of the installed font (213.0, 284.0, 378.7).",
282
287
  "measured": {
283
288
  "replacement": "Noto Naskh Arabic",
@@ -1100,6 +1105,11 @@ var fontPolicySource = {
1100
1105
  "replacement": {
1101
1106
  "family": "Noto Sans Thai",
1102
1107
  "compatibility": "visual",
1108
+ "sizeAdjust": 0.68,
1109
+ "lineAscent": 0.82,
1110
+ "lineAscentMixed": 0.85,
1111
+ "lineAscentBasis": "opf#361 native probe, PowerPoint 365 16.0 build 20430 (RR-38 setup, 2880 px; bottom edge of the last row with 35 percent of the darkest row's ink): 0.815 / 0.819 / 0.813 em at 18 / 24 / 32 pt, with a Latin run 0.856 / 0.852 / 0.841. Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
1112
+ "sizeAdjustBasis": "opf#361 rule: the smallest factor (0.01 steps, up) at which no line of the FF-44 script corpus or of the scheme's Latin overhangs over 3 percent natively, regular and bold (DilleniaUPC 5.05 advances over Noto Sans Thai's, fontkit, read in place). Exact 0.6704; worst line thai-digits +1.6 percent; Latin slack 41.5 percent (Latin is drawn by the replacement at the composed size). Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
1103
1113
  "measured": {
1104
1114
  "replacement": "Noto Sans Thai",
1105
1115
  "meanAbsWidthDelta": 0.7077,
@@ -1905,6 +1915,8 @@ var fontPolicySource = {
1905
1915
  "replacement": {
1906
1916
  "family": "Noto Sans KR",
1907
1917
  "compatibility": "visual",
1918
+ "sizeAdjust": 1.07,
1919
+ "sizeAdjustBasis": "opf#361 rule: the smallest factor (0.01 steps, up) at which no line of the FF-44 script corpus or of the scheme's Latin overhangs over 3 percent natively, regular and bold (Malgun Gothic 6.69 advances over Noto Sans KR's, fontkit, read in place). Exact 1.0557 on precomposed Hangul; worst line kore-sentence +1.6 percent at 1.07 (Hangul); latn-numerals +2.7 percent (Latin, at any factor); Latin slack 1.6 percent (Latin is drawn by the replacement at the composed size). Evidence: docs/evidence/ff-46-sizeadjust-native-20261010. Decomposed conjoining jamo (U+1100-11FF, U+A960-A97F, U+D7B0-D7FF; sample kore-jamo, 1.0669) is a documented limitation outside the row decision: PowerPoint draws it unshaped. No lineAscent: the native baseline is 1.06 em, below core's 1.0.",
1908
1920
  "measured": {
1909
1921
  "replacement": "Noto Sans KR",
1910
1922
  "meanAbsWidthDelta": 0.0176,
@@ -2195,6 +2207,11 @@ var fontPolicySource = {
2195
2207
  "replacement": {
2196
2208
  "family": "Noto Sans Devanagari",
2197
2209
  "compatibility": "visual",
2210
+ "sizeAdjust": 1.07,
2211
+ "lineAscent": 0.97,
2212
+ "lineAscentMixed": 0.94,
2213
+ "lineAscentBasis": "opf#361 native probe, PowerPoint 365 16.0 build 20430 (RR-38 setup, 2880 px; bottom edge of the last row with 35 percent of the darkest row's ink): Devanagari stem bottoms 0.981 / 0.972 / 0.969 em at 18 / 24 / 32 pt, with a Latin run 0.930 / 0.935 / 0.935. Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
2214
+ "sizeAdjustBasis": "opf#361 rule: the smallest factor (0.01 steps, up) at which no line of the FF-44 script corpus or of the scheme's Latin overhangs over 3 percent natively, regular and bold (Nirmala UI 1.46 advances over Noto Sans Devanagari's, fontkit, read in place). Exact 1.0695; worst line deva-matras +2.9 percent; Latin slack 6.0 percent (Latin is drawn by the replacement at the composed size). Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
2198
2215
  "measured": {
2199
2216
  "replacement": "Noto Sans Devanagari",
2200
2217
  "meanAbsWidthDelta": 0.0638,
@@ -2901,6 +2918,11 @@ var fontPolicySource = {
2901
2918
  "replacement": {
2902
2919
  "family": "Noto Naskh Arabic",
2903
2920
  "compatibility": "visual",
2921
+ "sizeAdjust": 0.89,
2922
+ "lineAscent": 0.76,
2923
+ "lineAscentMixed": 0.81,
2924
+ "lineAscentBasis": "opf#361 native probe, PowerPoint 365 16.0 build 20430 (RR-38 setup, 2880 px; bottom edge of the last row with 35 percent of the darkest row's ink): 0.759 / 0.764 / 0.760 em at 18 / 24 / 32 pt, with a Latin run 0.819 / 0.810 / 0.810 (replaces the 0.78 read from 1280 px PNGs). Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
2925
+ "sizeAdjustBasis": "opf#361 rule: the smallest factor (0.01 steps, up) at which no line of the FF-44 script corpus or of the scheme's Latin overhangs over 3 percent natively, regular and bold (Sakkal Majalla 7.00 advances over Noto Naskh Arabic's, fontkit, read in place). Exact 0.8866; worst line arab-fa-digits +2.6 percent; Latin slack 35.1 percent (Latin is drawn by the replacement at the composed size). Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
2904
2926
  "measured": {
2905
2927
  "replacement": "Noto Naskh Arabic",
2906
2928
  "meanAbsWidthDelta": 0.5423,
@@ -18,4 +18,4 @@ import './chunk-MBU42S5Y.js';
18
18
  import './chunk-TRDXRRQK.js';
19
19
  import './chunk-SPFV27O5.js';
20
20
  import './chunk-2PEWUUBR.js';
21
- import './chunk-WXSPETQ6.js';
21
+ import './chunk-F7PLNWHQ.js';
package/dist/docs.js CHANGED
@@ -82,7 +82,7 @@ var docsData = Object.freeze([
82
82
  "slug": "ecosystem-development",
83
83
  "file": "docs/ecosystem-development.md",
84
84
  "title": "Local ecosystem development",
85
- "markdown": "# Local ecosystem development\n\nUse Node 24 for the current source and published packages. Keep `opf`, `opf-render`, `opf-pptx`, `opf-editor`, and `pptx-gallery` in the same parent directory. Install each repository's dependencies normally, then run these commands from `opf`:\n\n```sh\npnpm build\nnode scripts/link-ecosystem.mjs\npnpm test:ecosystem\npnpm test:gallery\n```\n\nThe link command replaces the installed `@openpresentation/opf` package in sibling `node_modules` with a link to this checkout and builds the toolkit packages. It also links the renderer into editor/converter consumers and the converter into the editor. It does not save machine-specific paths in package manifests or lockfiles. Reinstalling dependencies can replace the links; rerun the command afterwards. Use `--packages-only` to omit the gallery checkout.\n\nOn Windows, directory junctions work without granting file-symlink privileges. The linker refuses a package parent that resolves outside the sibling checkout's `node_modules`, and replaces existing links without following them into source. npm/pnpm orchestration invokes the package manager's JavaScript entrypoint with the selected Node runtime instead of running a batch shim through a shell. Paths with spaces and shell metacharacters remain literal arguments. The supported npm-installed and npm-exec package-manager layouts are discovered from `PATH` or the matching `npm_execpath`; a missing manager returns an explicit installation error.\n\nThe core packed-install smoke check also uses this Windows invocation. The following portability results record the historical September 9 integration, before the current Node 24 requirement; current acceptance is linked from the [compatibility matrix](compatibility-matrix.md). Node 20/24 local evidence on the `codex/windows-test-harness-20260909` branch: all 414 core tests plus composition/pagination/data/rich-text/list suites pass, and actual local tarballs install into fresh temporary projects and pass 519 packed-entry checks. New isolated tests execute real npm builds, replace existing junctions, retain literal arguments, and reject an external `node_modules` parent without modifying its package. The then-current Windows/macOS CI repeated the core packed installation on both runtimes. These are local unpublished tarballs, not republished core 0.7.0 or proof of native rendering fidelity.\n\nAfter integrating reviewed layout PR #43, the combined source passes all 420 core tests on local Windows Node 24. Exact combined-source CI and review are recorded on PR #44.\n\nCoordinated CI `34384776504` and `34385059710` caught an older isolated-link fixture copying the linker without its new helper, causing `ERR_MODULE_NOT_FOUND` before package tests ran. The fixture now copies both files, passes directly on Windows Node 20/24, and runs in the Windows/macOS matrix as well as coordinated CI. This failure was fixed rather than waived; renewed combined-source CI was required at that checkpoint.\n\nThe current published compatible set is core 0.18.1, CLI 0.18.0, renderer 0.18.0, PPTX 0.18.0 and editor 0.18.0 on Node 24 (the lockstep 0.18, with core 0.18.1 because opf-v0.18.0 did not publish; the renderer's converters and font packages are optional peers a host installs, RR-63). Clean registry installs include shared composition and styled table rows without sibling links. `release-plan.json` records exact versions and immutable verification sources; `pnpm test:registry-ecosystem` and `pnpm test:registry-fidelity` exercise those installed packages. Source links are for coordinated development.\n\nExecute the installed-package browser harnesses after their corresponding build:\n\n```sh\npnpm test:packages\npnpm test:packed-browser\npnpm test:registry-ecosystem\npnpm test:packed-browser registry\n```\n\nThe renderer checkout supplies its locked Playwright test dependency. Install Chromium with `npm exec --prefix ../opf-render -- playwright install --with-deps chromium` on Linux. Local Windows runs use Edge; `OPF_BROWSER_CHANNEL` can explicitly select another installed Playwright channel. CI uses the matching official Playwright container pinned by digest, without installing OS packages during each run.\n\nEach build writes `artifacts/editor/packed-browser-manifest.json` with the mode, installed versions, consumer build ID, dependency-lock hash and exact font/HTML/JavaScript hashes. The runner rejects a different mode, stale consumer or changed asset. It serves only the verified bytes on loopback and rejects external requests and network writes. Rebuild before switching between candidate and registry modes. Reports include the browser and Node versions and are saved by mode/runtime; failures retain a screenshot.\n\n`node scripts/test-packed-browser-guards.mjs` verifies those four rejection cases against the current disposable harness and restores each changed fixture byte-for-byte. CI runs it after the registry browser checks.\n\nSeven suites exercise canvas, rich text, lists, creation, layout, block moves and styled tables. Real browser input covers divider resizing/cancellation/concurrent changes, block dragging, merged-cell typing/redo/undo, plain-to-rich conversion and bold formatting, and empty-cell typing/undo. Conversion and formatting currently create separate undo transactions. Harness DOM assertions also cover renderer agreement and preservation. These checks do not replace full application export/reimport, public deployment checks or native PowerPoint raster evidence.\n\nTo browse the gallery with the linked package:\n\n```sh\ncd ../pptx-gallery\nOPF_LOCAL_WORKSPACE=1 pnpm dev\n```\n\nLayout detail pages have an interactive composition example. The flag expands Turbopack's local root to include the sibling package; production builds use the gallery root.\n\n`pnpm test:ecosystem` validates the dynamic composition fixture, edits and undoes a composition, renders SVG/PNG/PDF, exports editable PPTX, checks OOXML text-box coordinates against the shared geometry, and imports the result back into schema-valid OPF. Artifacts are written to a temporary directory and its location is printed.\n\nFor tests that should read current source without modifying installed packages, use Node's local loader after building OPF:\n\n```sh\nnode --import ./scripts/register-local-opf.mjs ../opf-render/test/smoke.mjs\n```\n\nThe loader redirects only `@openpresentation/opf` imports to this checkout. Ordinary dependencies still resolve from the consuming repository.\n\nFor full gallery render coverage, run `pnpm test:gallery -- --render` (or invoke the script with `--render`). The test validates all 854 generated documents and can render them with the local SVG engine.\n\nBuild OPF before starting a linked gallery. Stop and restart the gallery around clean OPF rebuilds; removing the linked `dist` directory during compilation can leave Turbopack with stale missing-module errors.\n\n`pnpm test:pagination` verifies long-text and table pagination through SVG and editable PPTX, including exact source reconstruction, table row counts, and absence of extra exporter-created pages. It writes review artifacts under `artifacts/pagination/`.\n\n`pnpm test:fonts` verifies actual-font measurement across editor, SVG, pagination, and PPTX. It also runs the offline font-switch matrix (`scripts/test-font-switch-ecosystem.mjs`, FF-09): a seeded pairwise covering array of 58 decks over the 14 gallery dimensions, plus fixed content-type, block-replacement, per-slide override, CJK-in-Latin, theme and language switches, each switched A to B and back to A. A value class is a group of catalog values that take the same path through the engines, derived from the catalogs in the script: font schemes by language family, then by licensing and preview policy (Office metric, Office visual-only, monospace, open Google); one language per script family in the array and every other catalog script in a language chain; every layout family; the eight content blocks; every distinct chart export path of the non-deprecated chart types; header/footer, background (theme, solid, gradient, pattern, image) and image treatments by kind; and the first and last record of the metadata dimensions. Dimensions that a deck can carry several times (font scheme states, layouts, blocks, charts, backgrounds, images) take several values per deck. Every state is exported and checked with the FF-08 typeface inventory, the catalog's literal theme fonts, a package structure check, a preview re-render and a re-import. It pins the office font pack with visual substitution and asserts every substitution; known engine limitations, including chart types the preview approximates or the exporter writes as bar charts, are named expected failures in the script that fail with a \"limitation resolved\" message when they go away. It runs no browser and no Office. Its report is written to `artifacts/font-switch-matrix/report.json`. The pairwise decks run on four worker threads with a fixed partition (deck i on worker i mod 4), so every host verifies each deck after the same earlier decks; `OPF_MATRIX_WORKERS=1` runs them in one thread, `OPF_MATRIX_WORKERS=n` on n workers. See [font fidelity](font-fidelity.md) for loading and embedding local fonts and for current native PowerPoint limits.\n\n## Coordinated CI: the ecosystem lock\n\n`ecosystem.lock.json` records the four OpenPresentation commits (`opf`, `opf-render`, `opf-pptx`, `opf-editor`) that passed the coordinated ecosystem checks together, and the golden baseline (`OPF_GOLDEN_BASELINE`) the locked renderer renders the core examples against. `scripts/ecosystem-lock.schema.json` is its schema and `node scripts/ecosystem-lock.mjs validate` checks it with the same rules.\n\n- **Who writes it.** The SHAs are written by the roller, never by hand. A pull request that moves goldens may change `golden` (a reviewed golden decision, like the `OPF_GOLDEN_BASELINE` edits it replaces).\n- **Guard.** `node scripts/ecosystem-lock.mjs guard` checks every locked SHA against its repository's `main` through the GitHub REST compare API (the equivalent of `git merge-base --is-ancestor <sha> main`, no clone), and flags a pull request that changes a locked SHA from a branch other than the roller's (`ecosystem-roll/*`). The `ecosystem-core` job runs it as a warning; the repository variable `ECOSYSTEM_LOCK_GUARD=blocking` (or `--blocking`) makes a finding fail the job.\n- **Reading it.** CI never pins a sibling by hand. Each job runs the composite action `.github/actions/ecosystem-refs` with its own repository as `consumer`; the action reads `ecosystem.lock.json` from its own commit and returns the commit to check out for each repository (`opf`, `opf_render`, `opf_pptx`, `opf_editor`) and the golden baseline relative to the workspace (`golden`, for example `opf/scripts/fixtures/opf-examples-png.audience-ids.sha256.json`). Core's workflows use `./opf/.github/actions/ecosystem-refs` (the lock of the commit under test); the sibling repositories use `OpenPresentation/opf/.github/actions/ecosystem-refs@main` (the lock on core `main`). `export-golden: 'true'` exports `OPF_GOLDEN_BASELINE`; `golden-override` replaces the lock's golden with a workspace-relative baseline (a renderer pull request that moves pixels). Locally: `node scripts/ecosystem-lock.mjs resolve --consumer opf`.\n- **Depends-On.** For a change that needs an unmerged pull request of another ecosystem repository, add a line to the pull request body, for example `Depends-On: OpenPresentation/opf#264` (several pull requests may be listed, comma-separated or on several lines; the `https://github.com/OpenPresentation/<repository>/pull/<number>` form works too). On `pull_request` and `merge_group` events the action then checks that repository out at the named pull request instead of the lock: its test merge commit (`refs/pull/<n>/merge`) while it is open and mergeable, its head while it has conflicts, and its merge commit once it is merged (so a dependent pull request needs no edit after its dependency merges; re-run its checks). A closed, unmerged dependency fails the step; another organization's repository, a repository outside the four and a dependency on the pull request's own repository are reported and ignored; trailers inside fenced code blocks do not count. The body is read through the REST API, so after editing it re-run the checks. In the merge queue the pull request is the head pull request of the queue branch (`gh-readonly-queue/main/pr-<n>-<sha>`), so a breaking coordinated change is checked against its open sibling pull requests there too. In a batch of more than one pull request only that head pull request's `Depends-On` applies, so queue a coordinated change alone. `push` and scheduled runs always use the lock, so main is red for the sibling suites until the dependencies merge and the roll moves the lock. This replaces throwaway pin branches and repin pull requests. Check a body locally with `node scripts/ecosystem-lock.mjs depends-on --body-file body.md`. A renderer dependency that moves pixels brings its own golden: when opf-render comes from a `Depends-On` pull request and the step has no `golden-override` of its own, the action adopts the `golden-override` of that pull request's `.github/workflows/ci.yml` (the roller's rule: an `opf-render/test/golden/<name>` baseline or an `opf/scripts/fixtures/<name>.sha256.json` fixture, which must exist at the commit that is checked out) and logs `golden ... adopted from Depends-On OpenPresentation/opf-render#<n>`. `merge_group` runs keep the lock's golden, so the lock must still describe the locked renderer (FA-19).\n- **Roller.** `scripts/ecosystem-roll.mjs` (workflow \"Ecosystem lock roller\", `.github/workflows/ecosystem-roll.yml`) tries the four `main` branches together: it builds the candidate lock (the four `main` SHAs; the lock's golden, or opf-render main's `golden-override` when it sets one), force-moves `ecosystem-roll/main` to core `main`, commits the candidate there, and proposes it as a pull request only when the coordinated checks pass. It never writes `main`. `node scripts/ecosystem-roll.mjs plan --lock ecosystem.lock.json` shows the candidate without writing.\n - **Without the GitHub App (today).** The roller runs with `GITHUB_TOKEN`, whose pushes start no workflow and whose pull requests start no checks. So it dispatches `Coordinated public packages` and `OPF CI` on its branch (a `workflow_dispatch` made with `GITHUB_TOKEN` does start a run), waits for them, and proposes only a green candidate; those runs report the required checks on the branch head, which is the pull request head. The repository does not let GitHub Actions open pull requests (\"Allow GitHub Actions to create and approve pull requests\" is off), so a green roll ends with a compare link in the job summary and a maintainer opens the pull request. The workflow is dispatch-only: the hourly schedule stays commented out.\n - **With the App ([opf#298](https://github.com/OpenPresentation/opf/issues/298)).** Set the repository variable `ECOSYSTEM_APP_ID` and the secret `ECOSYSTEM_APP_PRIVATE_KEY`: the workflow's token step then runs and the roller uses the App token with no code change, opening the pull request itself (its normal checks decide). Then uncomment the schedule, and let each sibling send a `repository_dispatch` of type `ecosystem-main-updated` after a merge to `main`.\n- **No hand pins.** `scripts/ecosystem-lock.test.mjs` fails when a core workflow checks out an OpenPresentation repository at a hand-written SHA or selects a hand-written `OPF_GOLDEN_BASELINE`. The lock was generated from the last hand pins of `.github/workflows/ecosystem-ci.yml`; see [the CI study](programs/release-readiness/ci-cd.md), section 3.\n\n## Regenerating core's golden fixture\n\nA pull request that changes `examples/`, or the core code that composes them, moves the corpus digest and slide hashes in the fixture `ecosystem.lock.json` `golden` selects (`scripts/fixtures/opf-examples-png.*.sha256.json`; `scripts/fixtures/README.md` keeps the review record). opf-render's `regenerate-goldens` reads only the lock, so it cannot see an unmerged core pull request. The `regenerate-core-golden` workflow (`.github/workflows/regenerate-core-golden.yml`, helpers in `scripts/core-golden.mjs`, tests in `pnpm check:core-golden`) does it for a branch of this repository, with the method of RR-20 (opf#367): the locked renderer's `test/golden.mjs`, core linked, scale 0.25, `systemFonts: false`, on Linux in the Playwright image `ecosystem-ci.yml` pins, Node 24.\n\n```sh\ngh workflow run regenerate-core-golden.yml -f branch=<branch> # render, push one fixture commit, start CI\ngh workflow run regenerate-core-golden.yml -f branch=<branch> -f dry-run=true # render and report only\n```\n\n- **Inputs.** `branch` (required): a branch of this repository, never the default branch; a fork (`owner:branch`), a ref, a SHA or any other name is refused, and the branch is checked out from this repository only. `dry-run` (default false) renders and writes the report without pushing. `dispatch-ci` (default true) starts `OPF CI` and `Coordinated public packages` on the branch after the push (a push with `GITHUB_TOKEN` starts no workflow). Dispatch it on `main`: a `workflow_dispatch` sees only workflows on the default branch, and the run refuses any other ref.\n- **Step one, the reproducibility guard.** It renders main's examples with main's core and the locked renderer (the renderer commit in `ecosystem.lock.json`) and requires the generated fixture to be byte-identical to main's committed one. If it is not, the run fails and pushes nothing: the environment cannot be trusted to write a fixture (or main's fixture is stale), and the run log lists the slides that differ.\n- **Step two.** It renders the branch head the same way. The fixture is written to the path the branch's `ecosystem.lock.json` selects; the commit may touch only that one `scripts/fixtures/<name>.sha256.json` (any other change fails the run), its message lists every changed slide (deck, index, old and new hash) compared with main's fixture, and it is pushed to the branch without force by `github-actions[bot]`. The push is a second job that holds the write token and runs only scripts from `main`; the branch's code (install, build, render) runs in a job with read access only. A branch that moved while rendering, a branch whose lock locks another renderer than main (rebase), and a branch that lacks a change on `main` to `examples/`, `packages/`, `spec/`, `scripts/fixtures/`, the lock or the lockfile (rebase) fail without pushing. A fixture that already equals the render, or `dry-run`, pushes nothing.\n- **A renderer that is not merged yet (`renderer-ref`).** For one coordinated change whose renderer is still a branch (OPF 0.15 with FA-23), pass `-f renderer-ref=<opf-render branch or SHA> -f fixture-name=<name>`, and dispatch on the branch itself (`--ref <branch>`) until `main` carries this workflow version. The guard is unchanged (main with the locked renderer). The branch is rendered with the `renderer-ref` renderer into a new fixture, `scripts/fixtures/opf-examples-png.<name>.sha256.json` (never main's, which main's lock still selects); the push also points the branch's `ecosystem.lock.json` `golden` at it and appends the review record, with the renderer SHA, to `scripts/fixtures/README.md`, and the commit message names the renderer SHA. The \"branch lock renderer must equal main's\" check is relaxed for such a run, and the log says why.\n- **Job summary and artifact.** The summary lists the changed slides per deck, ready to paste into the pull request body (add the reason for each change), and every old and new hash; the `core-golden-report` artifact holds the same (`changes.json`, `summary.md`, `commit-message.txt`, the fixture) and the review sheets and PNGs of the slides that differ from main's fixture. Look at them before approving: the commit is evidence, not an approval.\n- **Merge rhythm.** Merges are sequential and each examples-touching merge changes main's fixture. After every such merge, the next examples-touching pull request rebases onto main and dispatches this workflow, then merges once CI on the new commit is green. Edit the pull request body with the list before merging.\n- **CI after the push.** A dispatched run reads `ecosystem.lock.json` only, so for a pull request whose body has `Depends-On:` lines close and reopen it (or push) to run its own `pull_request` checks.\n- **Limits.** The renderer is the locked one; a pull request that also needs an unmerged opf-render change cannot be regenerated here. A fixture in another repository (a lock `golden` that names opf-render) is refused.\n\n## Coordinated CI: the contract tier\n\n`Coordinated public packages` (`.github/workflows/ecosystem-ci.yml`) runs the three siblings' suites in two depths ([the CI study](programs/release-readiness/ci-cd.md), section 3, consumer-driven contracts). `scripts/ecosystem-scope.mjs` (`tierForEvent`) decides the depth and every sibling shard passes it as `--tier` to `scripts/test-package-ecosystem.mjs` (`scripts/package-ecosystem-plan.mjs` lists the commands).\n\n| Event | Depth | What a sibling runs |\n|---|---|---|\n| `pull_request` | `contract` | `npm run test:contract` (instead of `npm run test`) |\n| `merge_group`, the nightly `schedule` (01:37 UTC), `workflow_dispatch` | `full` | `npm run test` |\n| `pull_request` that changes `ecosystem.lock.json`, the workflow, the tiering scripts or the `ecosystem-refs` action, comes from an `ecosystem-roll/*` branch or has the label `ecosystem-full` or `full-ci` | `full` | `npm run test` |\n\n- **The contract suite.** Each sibling defines it in `test/suites.json`: `contractExclude` names the tests of `npm test` (`scripts/run-tests.mjs`) that the contract leaves out, each with a reason (tests of the package's own machinery that take no input from core). Every other test is in the contract, so a new test gates core pull requests until someone excludes it on purpose. `node scripts/run-tests.mjs --suite contract --list` prints it; the sibling's `check:runners` fails on an exclusion that names a missing file or has no reason. `test:contract` also keeps the cheap build steps that bundle core (the renderer's font licence check and browser bundle, the converter's browser bundle). Nothing else in a shard changes: typecheck, validate, `test:code`, `test:font-preparation`, `test:font-variants`, the browser steps and the golden gate (805 raster hashes, exact) run at both depths, and no tolerance moved.\n- **Nothing is skipped by the tier.** Only a pull request can run `contract`; a docs-only or contract-only change never lowers a `merge_group`, `main` or nightly run (`scripts/ecosystem-scope.test.mjs`). A sibling whose commit has no `test:contract` script (a lock that predates RR-53) runs its full `npm run test` with a warning.\n- **The merge queue and the nightly run (RR-57).** The queue's `merge_group` run is the full gate before main, and main is fast-forwarded to the exact commit it tested, so there is no push-to-main run (it repeated the same SHA; `scripts/release-train.mjs` reads the queue run's check runs on that commit). The nightly run is the safety net for anything that reaches main without the queue. A red nightly run fails the workflow run and the job `main-status` keeps an issue labelled `main-red` open for the red period (a comment for each further red run, closed by the next green one); poll it with `gh api 'repos/OpenPresentation/opf/issues?labels=main-red&state=open'`. Fix forward or revert. Add the label `ecosystem-full` (or `full-ci`) to a pull request that you know reaches past the contract (the font catalogs, the script packs, the PDF path) to run the full suites on it; the label takes effect on the next push or a re-run.\n- **Merge order.** A sibling pull request that adds or changes its contract merges first; the roller then moves the lock. Core's pull request tier reads the contract at the lock's commit, or at the pull request named by a `Depends-On:` line.\n"
85
+ "markdown": "# Local ecosystem development\n\nUse Node 24 for the current source and published packages. Keep `opf`, `opf-render`, `opf-pptx`, `opf-editor`, and `pptx-gallery` in the same parent directory. Install each repository's dependencies normally, then run these commands from `opf`:\n\n```sh\npnpm build\nnode scripts/link-ecosystem.mjs\npnpm test:ecosystem\npnpm test:gallery\n```\n\nThe link command replaces the installed `@openpresentation/opf` package in sibling `node_modules` with a link to this checkout and builds the toolkit packages. It also links the renderer into editor/converter consumers and the converter into the editor. It does not save machine-specific paths in package manifests or lockfiles. Reinstalling dependencies can replace the links; rerun the command afterwards. Use `--packages-only` to omit the gallery checkout.\n\nOn Windows, directory junctions work without granting file-symlink privileges. The linker refuses a package parent that resolves outside the sibling checkout's `node_modules`, and replaces existing links without following them into source. npm/pnpm orchestration invokes the package manager's JavaScript entrypoint with the selected Node runtime instead of running a batch shim through a shell. Paths with spaces and shell metacharacters remain literal arguments. The supported npm-installed and npm-exec package-manager layouts are discovered from `PATH` or the matching `npm_execpath`; a missing manager returns an explicit installation error.\n\nThe core packed-install smoke check also uses this Windows invocation. The following portability results record the historical September 9 integration, before the current Node 24 requirement; current acceptance is linked from the [compatibility matrix](compatibility-matrix.md). Node 20/24 local evidence on the `codex/windows-test-harness-20260909` branch: all 414 core tests plus composition/pagination/data/rich-text/list suites pass, and actual local tarballs install into fresh temporary projects and pass 519 packed-entry checks. New isolated tests execute real npm builds, replace existing junctions, retain literal arguments, and reject an external `node_modules` parent without modifying its package. The then-current Windows/macOS CI repeated the core packed installation on both runtimes. These are local unpublished tarballs, not republished core 0.7.0 or proof of native rendering fidelity.\n\nAfter integrating reviewed layout PR #43, the combined source passes all 420 core tests on local Windows Node 24. Exact combined-source CI and review are recorded on PR #44.\n\nCoordinated CI `34384776504` and `34385059710` caught an older isolated-link fixture copying the linker without its new helper, causing `ERR_MODULE_NOT_FOUND` before package tests ran. The fixture now copies both files, passes directly on Windows Node 20/24, and runs in the Windows/macOS matrix as well as coordinated CI. This failure was fixed rather than waived; renewed combined-source CI was required at that checkpoint.\n\nThe current published compatible set is core 0.18.1, CLI 0.18.0, renderer 0.18.0, PPTX 0.18.0 and editor 0.18.0 on Node 24 (the lockstep 0.18, with core 0.18.1 because opf-v0.18.0 did not publish; the renderer's converters and font packages are optional peers a host installs, RR-63). Clean registry installs include shared composition and styled table rows without sibling links. `release-plan.json` records exact versions and immutable verification sources; `pnpm test:registry-ecosystem` and `pnpm test:registry-fidelity` exercise those installed packages. Source links are for coordinated development.\n\nExecute the installed-package browser harnesses after their corresponding build:\n\n```sh\npnpm test:packages\npnpm test:packed-browser\npnpm test:registry-ecosystem\npnpm test:packed-browser registry\n```\n\nThe renderer checkout supplies its locked Playwright test dependency. Install Chromium with `npm exec --prefix ../opf-render -- playwright install --with-deps chromium` on Linux. Local Windows runs use Edge; `OPF_BROWSER_CHANNEL` can explicitly select another installed Playwright channel. CI uses the matching official Playwright container pinned by digest, without installing OS packages during each run.\n\nEach build writes `artifacts/editor/packed-browser-manifest.json` with the mode, installed versions, consumer build ID, dependency-lock hash and exact font/HTML/JavaScript hashes. The runner rejects a different mode, stale consumer or changed asset. It serves only the verified bytes on loopback and rejects external requests and network writes. Rebuild before switching between candidate and registry modes. Reports include the browser and Node versions and are saved by mode/runtime; failures retain a screenshot.\n\n`node scripts/test-packed-browser-guards.mjs` verifies those four rejection cases against the current disposable harness and restores each changed fixture byte-for-byte. CI runs it after the registry browser checks.\n\nSeven suites exercise canvas, rich text, lists, creation, layout, block moves and styled tables. Real browser input covers divider resizing/cancellation/concurrent changes, block dragging, merged-cell typing/redo/undo, plain-to-rich conversion and bold formatting, and empty-cell typing/undo. Conversion and formatting currently create separate undo transactions. Harness DOM assertions also cover renderer agreement and preservation. These checks do not replace full application export/reimport, public deployment checks or native PowerPoint raster evidence.\n\nTo browse the gallery with the linked package:\n\n```sh\ncd ../pptx-gallery\nOPF_LOCAL_WORKSPACE=1 pnpm dev\n```\n\nLayout detail pages have an interactive composition example. The flag expands Turbopack's local root to include the sibling package; production builds use the gallery root.\n\n`pnpm test:ecosystem` validates the dynamic composition fixture, edits and undoes a composition, renders SVG/PNG/PDF, exports editable PPTX, checks OOXML text-box coordinates against the shared geometry, and imports the result back into schema-valid OPF. Artifacts are written to a temporary directory and its location is printed.\n\nFor tests that should read current source without modifying installed packages, use Node's local loader after building OPF:\n\n```sh\nnode --import ./scripts/register-local-opf.mjs ../opf-render/test/smoke.mjs\n```\n\nThe loader redirects only `@openpresentation/opf` imports to this checkout. Ordinary dependencies still resolve from the consuming repository.\n\nFor full gallery render coverage, run `pnpm test:gallery -- --render` (or invoke the script with `--render`). The test validates all 854 generated documents and can render them with the local SVG engine.\n\nBuild OPF before starting a linked gallery. Stop and restart the gallery around clean OPF rebuilds; removing the linked `dist` directory during compilation can leave Turbopack with stale missing-module errors.\n\n`pnpm test:pagination` verifies long-text and table pagination through SVG and editable PPTX, including exact source reconstruction, table row counts, and absence of extra exporter-created pages. It writes review artifacts under `artifacts/pagination/`.\n\n`pnpm test:fonts` verifies actual-font measurement across editor, SVG, pagination, and PPTX. It also runs the offline font-switch matrix (`scripts/test-font-switch-ecosystem.mjs`, FF-09): a seeded pairwise covering array of 58 decks over the 14 gallery dimensions, plus fixed content-type, block-replacement, per-slide override, CJK-in-Latin, theme and language switches, each switched A to B and back to A. A value class is a group of catalog values that take the same path through the engines, derived from the catalogs in the script: font schemes by language family, then by licensing and preview policy (Office metric, Office visual-only, monospace, open Google); one language per script family in the array and every other catalog script in a language chain; every layout family; the eight content blocks; every distinct chart export path of the non-deprecated chart types; header/footer, background (theme, solid, gradient, pattern, image) and image treatments by kind; and the first and last record of the metadata dimensions. Dimensions that a deck can carry several times (font scheme states, layouts, blocks, charts, backgrounds, images) take several values per deck. Every state is exported and checked with the FF-08 typeface inventory, the catalog's literal theme fonts, a package structure check, a preview re-render and a re-import. It pins the office font pack with visual substitution and asserts every substitution; known engine limitations, including chart types the preview approximates or the exporter writes as bar charts, are named expected failures in the script that fail with a \"limitation resolved\" message when they go away. It runs no browser and no Office. Its report is written to `artifacts/font-switch-matrix/report.json`. The pairwise decks run on four worker threads with a fixed partition (deck i on worker i mod 4), so every host verifies each deck after the same earlier decks; `OPF_MATRIX_WORKERS=1` runs them in one thread, `OPF_MATRIX_WORKERS=n` on n workers. See [font fidelity](font-fidelity.md) for loading and embedding local fonts and for current native PowerPoint limits.\n\n## Coordinated CI: the ecosystem lock\n\n`ecosystem.lock.json` records the four OpenPresentation commits (`opf`, `opf-render`, `opf-pptx`, `opf-editor`) that passed the coordinated ecosystem checks together, and the golden baseline (`OPF_GOLDEN_BASELINE`) the locked renderer renders the core examples against. `scripts/ecosystem-lock.schema.json` is its schema and `node scripts/ecosystem-lock.mjs validate` checks it with the same rules.\n\n- **Who writes it.** The SHAs are written by the roller, never by hand. A pull request that moves goldens may change `golden` (a reviewed golden decision, like the `OPF_GOLDEN_BASELINE` edits it replaces).\n- **Guard.** `node scripts/ecosystem-lock.mjs guard` checks every locked SHA against its repository's `main` through the GitHub REST compare API (the equivalent of `git merge-base --is-ancestor <sha> main`, no clone), and flags a pull request that changes a locked SHA from a branch other than the roller's (`ecosystem-roll/*`). The `ecosystem-core` job runs it as a warning; the repository variable `ECOSYSTEM_LOCK_GUARD=blocking` (or `--blocking`) makes a finding fail the job.\n- **Reading it.** CI never pins a sibling by hand. Each job runs the composite action `.github/actions/ecosystem-refs` with its own repository as `consumer`; the action reads `ecosystem.lock.json` from its own commit and returns the commit to check out for each repository (`opf`, `opf_render`, `opf_pptx`, `opf_editor`) and the golden baseline relative to the workspace (`golden`, for example `opf/scripts/fixtures/opf-examples-png.audience-ids.sha256.json`). Core's workflows use `./opf/.github/actions/ecosystem-refs` (the lock of the commit under test); the sibling repositories use `OpenPresentation/opf/.github/actions/ecosystem-refs@main` (the lock on core `main`). `export-golden: 'true'` exports `OPF_GOLDEN_BASELINE`; `golden-override` replaces the lock's golden with a workspace-relative baseline (a renderer pull request that moves pixels). Locally: `node scripts/ecosystem-lock.mjs resolve --consumer opf`.\n- **Depends-On.** For a change that needs an unmerged pull request of another ecosystem repository, add a line to the pull request body, for example `Depends-On: OpenPresentation/opf#264` (several pull requests may be listed, comma-separated or on several lines; the `https://github.com/OpenPresentation/<repository>/pull/<number>` form works too). On `pull_request` and `merge_group` events the action then checks that repository out at the named pull request instead of the lock: its test merge commit (`refs/pull/<n>/merge`) while it is open and mergeable, its head while it has conflicts, and its merge commit once it is merged (so a dependent pull request needs no edit after its dependency merges; re-run its checks). A closed, unmerged dependency fails the step; another organization's repository, a repository outside the four and a dependency on the pull request's own repository are reported and ignored; trailers inside fenced code blocks do not count. The body is read through the REST API, so after editing it re-run the checks. In the merge queue the pull request is the head pull request of the queue branch (`gh-readonly-queue/main/pr-<n>-<sha>`), so a breaking coordinated change is checked against its open sibling pull requests there too. In a batch of more than one pull request only that head pull request's `Depends-On` applies, so queue a coordinated change alone. `push` and scheduled runs always use the lock, so main is red for the sibling suites until the dependencies merge and the roll moves the lock. This replaces throwaway pin branches and repin pull requests. Check a body locally with `node scripts/ecosystem-lock.mjs depends-on --body-file body.md`. A renderer dependency that moves pixels brings its own golden: when opf-render comes from a `Depends-On` pull request and the step has no `golden-override` of its own, the action adopts the `golden-override` of that pull request's `.github/workflows/ci.yml` (the roller's rule: an `opf-render/test/golden/<name>` baseline or an `opf/scripts/fixtures/<name>.sha256.json` fixture, which must exist at the commit that is checked out) and logs `golden ... adopted from Depends-On OpenPresentation/opf-render#<n>`. `merge_group` runs keep the lock's golden, so the lock must still describe the locked renderer (FA-19).\n- **Roller.** `scripts/ecosystem-roll.mjs` (workflow \"Ecosystem lock roller\", `.github/workflows/ecosystem-roll.yml`) tries the four `main` branches together: it builds the candidate lock (the four `main` SHAs; the lock's golden, or opf-render main's `golden-override` when it sets one), force-moves `ecosystem-roll/main` to core `main`, commits the candidate there, and proposes it as a pull request only when the coordinated checks pass. It never writes `main`. `node scripts/ecosystem-roll.mjs plan --lock ecosystem.lock.json` shows the candidate without writing.\n - **Without the GitHub App (today).** The roller runs with `GITHUB_TOKEN`, whose pushes start no workflow and whose pull requests start no checks. So it dispatches `Coordinated public packages` and `OPF CI` on its branch (a `workflow_dispatch` made with `GITHUB_TOKEN` does start a run), waits for them, and proposes only a green candidate; those runs report the required checks on the branch head, which is the pull request head. The repository does not let GitHub Actions open pull requests (\"Allow GitHub Actions to create and approve pull requests\" is off), so a green roll ends with a compare link in the job summary and a maintainer opens the pull request. The workflow is dispatch-only: the hourly schedule stays commented out.\n - **With the App ([opf#298](https://github.com/OpenPresentation/opf/issues/298)).** Set the repository variable `ECOSYSTEM_APP_ID` and the secret `ECOSYSTEM_APP_PRIVATE_KEY`: the workflow's token step then runs and the roller uses the App token with no code change, opening the pull request itself (its normal checks decide). Then uncomment the schedule, and let each sibling send a `repository_dispatch` of type `ecosystem-main-updated` after a merge to `main`.\n- **No hand pins.** `scripts/ecosystem-lock.test.mjs` fails when a core workflow checks out an OpenPresentation repository at a hand-written SHA or selects a hand-written `OPF_GOLDEN_BASELINE`. The lock was generated from the last hand pins of `.github/workflows/ecosystem-ci.yml`; see [the CI study](programs/release-readiness/ci-cd.md), section 3.\n\n## Regenerating core's golden fixture\n\nA pull request that changes `examples/`, or the core code that composes them, moves the corpus digest and slide hashes in the fixture `ecosystem.lock.json` `golden` selects (`scripts/fixtures/opf-examples-png.*.sha256.json`; `scripts/fixtures/README.md` keeps the review record). opf-render's `regenerate-goldens` reads only the lock, so it cannot see an unmerged core pull request. The `regenerate-core-golden` workflow (`.github/workflows/regenerate-core-golden.yml`, helpers in `scripts/core-golden.mjs`, tests in `pnpm check:core-golden`) does it for a branch of this repository, with the method of RR-20 (opf#367): the locked renderer's `test/golden.mjs`, core linked, scale 0.25, `systemFonts: false`, on Linux in the Playwright image `ecosystem-ci.yml` pins, Node 24.\n\n```sh\ngh workflow run regenerate-core-golden.yml -f branch=<branch> # render, push one fixture commit, start CI\ngh workflow run regenerate-core-golden.yml -f branch=<branch> -f dry-run=true # render and report only\n```\n\n- **Inputs.** `branch` (required): a branch of this repository, never the default branch; a fork (`owner:branch`), a ref, a SHA or any other name is refused, and the branch is checked out from this repository only. `dry-run` (default false) renders and writes the report without pushing. `dispatch-ci` (default true) starts `OPF CI` and `Coordinated public packages` on the branch after the push (a push with `GITHUB_TOKEN` starts no workflow). Dispatch it on `main`: a `workflow_dispatch` sees only workflows on the default branch, and the run refuses any other ref.\n- **Step one, the reproducibility guard.** It renders main's examples with main's core and the locked renderer (the renderer commit in `ecosystem.lock.json`) and requires the generated fixture to be byte-identical to main's committed one. If it is not, the run fails and pushes nothing: the environment cannot be trusted to write a fixture (or main's fixture is stale), and the run log lists the slides that differ.\n- **Step two.** It renders the branch head the same way. The fixture is written to the path the branch's `ecosystem.lock.json` selects; the commit may touch only that one `scripts/fixtures/<name>.sha256.json` (any other change fails the run), its message lists every changed slide (deck, index, old and new hash) compared with main's fixture, and it is pushed to the branch without force by `github-actions[bot]`. The push is a second job that holds the write token and runs only scripts from `main`; the branch's code (install, build, render) runs in a job with read access only. A branch that moved while rendering, a branch whose lock locks another renderer than main (rebase), and a branch that lacks a change on `main` to `examples/`, `packages/`, `spec/`, `scripts/fixtures/`, the lock or the lockfile (rebase) fail without pushing. A fixture that already equals the render, or `dry-run`, pushes nothing.\n- **A renderer that is not merged yet (`renderer-ref`).** For one coordinated change whose renderer is still a branch (OPF 0.15 with FA-23), pass `-f renderer-ref=<opf-render branch or SHA> -f fixture-name=<name>`, and dispatch on the branch itself (`--ref <branch>`) until `main` carries this workflow version. The guard is unchanged (main with the locked renderer). The branch is rendered with the `renderer-ref` renderer into a new fixture, `scripts/fixtures/opf-examples-png.<name>.sha256.json` (never main's, which main's lock still selects); the push also points the branch's `ecosystem.lock.json` `golden` at it and appends the review record, with the renderer SHA, to `scripts/fixtures/README.md`, and the commit message names the renderer SHA. The \"branch lock renderer must equal main's\" check is relaxed for such a run, and the log says why.\n- **Candidate gallery (`gallery-ref`).** With `renderer-ref`, an optional exact 40-character lowercase commit SHA of `OpenPresentation/gallery` links a gallery2 candidate into the candidate core and separate renderer only. Its checkout identity and package metadata are verified; the main guard retains its locked gallery and renderer. The SHA is recorded in the report. This does not modify dependency metadata or registry locks.\n- **Job summary and artifact.** The summary lists the changed slides per deck, ready to paste into the pull request body (add the reason for each change), and every old and new hash; the `core-golden-report` artifact holds the same (`changes.json`, `summary.md`, `commit-message.txt`, the fixture) and the review sheets and PNGs of the slides that differ from main's fixture. Look at them before approving: the commit is evidence, not an approval.\n- **Merge rhythm.** Merges are sequential and each examples-touching merge changes main's fixture. After every such merge, the next examples-touching pull request rebases onto main and dispatches this workflow, then merges once CI on the new commit is green. Edit the pull request body with the list before merging.\n- **CI after the push.** A dispatched run reads `ecosystem.lock.json` only, so for a pull request whose body has `Depends-On:` lines close and reopen it (or push) to run its own `pull_request` checks.\n- **Limits.** The renderer is the locked one; a pull request that also needs an unmerged opf-render change cannot be regenerated here. A fixture in another repository (a lock `golden` that names opf-render) is refused.\n\n## Coordinated CI: the contract tier\n\n`Coordinated public packages` (`.github/workflows/ecosystem-ci.yml`) runs the three siblings' suites in two depths ([the CI study](programs/release-readiness/ci-cd.md), section 3, consumer-driven contracts). `scripts/ecosystem-scope.mjs` (`tierForEvent`) decides the depth and every sibling shard passes it as `--tier` to `scripts/test-package-ecosystem.mjs` (`scripts/package-ecosystem-plan.mjs` lists the commands).\n\n| Event | Depth | What a sibling runs |\n|---|---|---|\n| `pull_request` | `contract` | `npm run test:contract` (instead of `npm run test`) |\n| `merge_group`, the nightly `schedule` (01:37 UTC), `workflow_dispatch` | `full` | `npm run test` |\n| `pull_request` that changes `ecosystem.lock.json`, the workflow, the tiering scripts or the `ecosystem-refs` action, comes from an `ecosystem-roll/*` branch or has the label `ecosystem-full` or `full-ci` | `full` | `npm run test` |\n\n- **The contract suite.** Each sibling defines it in `test/suites.json`: `contractExclude` names the tests of `npm test` (`scripts/run-tests.mjs`) that the contract leaves out, each with a reason (tests of the package's own machinery that take no input from core). Every other test is in the contract, so a new test gates core pull requests until someone excludes it on purpose. `node scripts/run-tests.mjs --suite contract --list` prints it; the sibling's `check:runners` fails on an exclusion that names a missing file or has no reason. `test:contract` also keeps the cheap build steps that bundle core (the renderer's font licence check and browser bundle, the converter's browser bundle). Nothing else in a shard changes: typecheck, validate, `test:code`, `test:font-preparation`, `test:font-variants`, the browser steps and the golden gate (805 raster hashes, exact) run at both depths, and no tolerance moved.\n- **Nothing is skipped by the tier.** Only a pull request can run `contract`; a docs-only or contract-only change never lowers a `merge_group`, `main` or nightly run (`scripts/ecosystem-scope.test.mjs`). A sibling whose commit has no `test:contract` script (a lock that predates RR-53) runs its full `npm run test` with a warning.\n- **The merge queue and the nightly run (RR-57).** The queue's `merge_group` run is the full gate before main, and main is fast-forwarded to the exact commit it tested, so there is no push-to-main run (it repeated the same SHA; `scripts/release-train.mjs` reads the queue run's check runs on that commit). The nightly run is the safety net for anything that reaches main without the queue. A red nightly run fails the workflow run and the job `main-status` keeps an issue labelled `main-red` open for the red period (a comment for each further red run, closed by the next green one); poll it with `gh api 'repos/OpenPresentation/opf/issues?labels=main-red&state=open'`. Fix forward or revert. Add the label `ecosystem-full` (or `full-ci`) to a pull request that you know reaches past the contract (the font catalogs, the script packs, the PDF path) to run the full suites on it; the label takes effect on the next push or a re-run.\n- **Merge order.** A sibling pull request that adds or changes its contract merges first; the roller then moves the lock. Core's pull request tier reads the contract at the lock's commit, or at the pull request named by a `Depends-On:` line.\n"
86
86
  },
87
87
  {
88
88
  "slug": "evidence-2026-09-08-windows",
@@ -106,7 +106,7 @@ var docsData = Object.freeze([
106
106
  "slug": "font-fidelity",
107
107
  "file": "docs/font-fidelity.md",
108
108
  "title": "Measured fonts and reproducible previews",
109
- "markdown": "# Measured fonts and reproducible previews\n\n## Start with the installed package\n\nUse the coordinated versions in the [quickstart](quickstart.md). Load fonts once per worker with\n`loadFonts({pack: 'office', scripts: 'auto', presentation})` from `@openpresentation/opf-render/fonts-node`,\nand pass the same handle as `{fonts}` to preview and export. When reusing a worker for another deck,\nensure its required faces are loaded before rendering, and bound concurrency. The renderer's\n[soak benchmark](https://github.com/OpenPresentation/opf-render#long-lived-workers-measured-memory-and-latency-opf-render171)\n(`npm run bench:soak`) measured no memory growth over 500 jobs per worker. A worker holds about 1 GiB, peaking near\n2 GiB on script-heavy decks, and its output is byte-identical on every repeat. Measure your own decks the same way.\nFor an authored Roboto deck, `pack: 'base'` avoids loading Office replacements. Neither loader reads system fonts by default.\n\nIn browsers, serve the renderer's pinned font files from your own origin and await the font handle's `ensure(presentation)`\nbefore measuring or editing. The renderer README documents font copying and the player integration. Standalone SVG must\ncarry the used font bytes; script faces require `embedScriptFonts: true` in the Node loader. The CLI's default SVG mode\nrequests this automatically (`--text fonts`); `--text system` needs matching viewer fonts, and `--text paths` draws the glyphs as outlines so the SVG needs none. PPTX normally names the authored families\nand requires them on the recipient's machine; it does not automatically embed the preview fonts.\n\nThe policy below is current. Version-specific measurements and implementation history are evidence for their named\ncheckpoints, not transfer-size budgets for every release. The [font roadmap](plans/font-roadmap.md) records historical\nplanning; use installed package APIs and measured output when choosing a deployment strategy.\n\n## Pass `fonts`, otherwise the estimate\n\nText measurement comes from one place: the `fonts` handle that opf-render's `loadFonts()` returns. Since RR-55 the same\nhandle carries it for every step that lays text out:\n\n- core's `validate`, `paginate` and the slide context (`resolveSlideContext`);\n- opf-render's `toSvg`, `toPng` and `toPdf`;\n- opf-pptx's `toPptx`.\n\n```js\nconst fonts = await loadFonts({ pack: 'office' });\nvalidate(deck, { fonts });\nconst { presentation } = paginate(deck, { fonts });\nconst svg = toSvg(presentation, { fonts });\nconst pptx = await toPptx(presentation, { fonts });\n```\n\nPass the same handle to preview, validation, pagination and export, so that all of them measure with the faces the\npreview draws. Then line breaks, overflow findings, page breaks and the exported text boxes agree, and the same handle\ngives identical geometry across engines (opf-pptx's `layout-parity` test checks that the exported PPTX paragraphs agree with the SVG preview and with\ncore's composed items, with and without `fonts`).\n\nA call without `fonts` is not an error: it uses core's built-in estimate, and says so (`checks.layout: \"estimated\"` in\n`validate`, `layout: \"estimated\"` in `paginate`). The estimate is deterministic and needs no font files: 0.54 em per\ncharacter, 0.62 em for capital letters and digits, 0.32 em for a space, 1 em for CJK characters, and zero width for\ncombining marks. It is close for Latin text in an ordinary text face and too narrow for some scripts (the tracking issue\nis opf#566), so treat an estimated result as a draft and measure before you rely on a page break or an overflow finding.\nCore's Node `convert` and the CLI's `convert` and `paginate` do this for you: they prepare an office-pack `fonts` handle once per process (`opf validate` loads none, so it is estimated). A library\ncall (`validate`, `paginate`, `toSvg`, `toPptx`) never loads fonts on its own, because that would change every\nfonts-less result and pull font files into a browser bundle; pass the handle you want.\n\n## Font policy (FF-31)\n\nOPF keeps one machine-readable font policy table, [`spec/reference/font-policy.json`](../spec/reference/font-policy.json). Core exports it as `FONT_POLICY`, `fontPolicyFor()` and `applyFontPolicyDecisions()` from `@openpresentation/opf` or `@openpresentation/opf/font-policy`. Each row gives a family's license class, where viewers get it, whether OPF may ever embed it, and its preview replacement with a measured width difference. It also lists alternates, ending where possible with a face that already ships with opf-render. The [licensing table](programs/font-fidelity-everywhere/font-licensing.md) lists all 153 rows. [`font-policy.schema.json`](../spec/reference/font-policy.schema.json) is its JSON Schema. The [measurement evidence](evidence/font-replacements-20260923/README.md) explains how each replacement was chosen.\n\n**The policy in brief (owner decisions, 2026-09-29).** This is the canonical statement; the sibling repositories link here.\n\n1. **The user's font selection is the source of truth.** The user picks a font, for example Aptos or Calibri through a font scheme. That name is what the document, the theme and the PPTX carry.\n2. **License-restricted (proprietary) fonts are never bundled or embedded,** so previews cannot draw them. Openly licensed fonts such as Carlito and Roboto are bundled, and \"licensed\" below always means license-restricted. Live previews, SVG, the editor and gallery thumbnails use an open look-alike instead. The goal is a replacement that looks similar and is metric-compatible (same advance widths and line metrics), so text occupies the same size on screen and wraps as it does in PowerPoint. Calibri\u2192Carlito is the model.\n3. **Where no metric-compatible open replacement exists yet,** a visual-only look-alike is a documented fallback. It is reported as visual and is a known layout-fidelity gap to close, not the intended end state. The parity scoreboard counts it as near, not perfect. The Aptos family is no longer such a case: Intos previews it as metric.\n4. **PPTX export always writes the selected font name,** for example `typeface=\"Aptos\"` in the theme and in runs, never the replacement. PowerPoint then opens the file and shows the actual font, installed or as a Microsoft 365 cloud font.\n5. **Only open fonts may be embedded,** through the explicit embed path (FF-13).\n6. **Font files are bundled, never hotlinked.** Fonts, Google Fonts included, ship as pinned files (an exact npm version, or a vendored file with a recorded sha256) and are never loaded at runtime from a font CDN. That protects visitor privacy (a German court held the Google Fonts CDN a GDPR violation in 2022) and keeps previews offline-capable and audits reproducible. Build-time self-hosting such as `next/font/google` is not a hotlink but is not pinned, so use `next/font/local` with pinned files. Guards fail on CDN references in every repository.\n7. **Every bundled face records a verified permissive license.** Allowed: exactly OFL-1.1, Apache-2.0, MIT and UFL-1.0; not GPL, LGPL, AGPL, proprietary or unclear public-domain fonts. The record has the SPDX id, any Reserved Font Name the copyright block declares, the source URL, package@version and sha256, all checked against the LICENSE file the font ships with. A modified version (subset, instance, conversion) may not use a Reserved Font Name in its name: a family whose served name contains its reserved name (Carlito, Raleway) is bundled only as the unmodified upstream file, while one that reserves another name (Noto Sans JP reserves \"Source\") may be modified. Details and enforcement: [Font files: bundling and licenses](programs/font-fidelity-everywhere/font-licensing.md#font-files-bundling-and-licenses).\n\n**Release status.** Point 4 is published: opf-pptx 0.10.0 and later (current 0.15.0) write the selected name into the PPTX, and the editor and pptx.gallery builds that depend on the release-plan set export it. opf-pptx 0.9.1 and earlier wrote the substitute.\n\n1. **Licensed, non-free fonts are never bundled or embedded.** This covers Aptos, Calibri, Cambria, Segoe UI, Georgia, Tahoma, Grandview, Seaford, Tenorite, Consolas, Times New Roman, Arial, Courier New and the Windows script fonts. Rendering uses the family's designated open replacement instead:\n - **Metric-compatible** is the goal, used where a replacement exists and measures identical: Calibri\u2192Carlito, Arial\u2192Arimo, Times New Roman\u2192Tinos, Courier New\u2192Cousine and Georgia\u2192Gelasio. A metric row needs an upstream statement and a measurement in all four styles, with a mean width difference below 0.1% and no corpus string more than 0.3% off. Georgia\u2192Gelasio passes only with Gelasio shaped with its `liga` and `clig` features off (the row lists them as `disabledFeatures`): with default features Gelasio ligates fi, fl, ffi and ffl, which Georgia does not, and runs differ by up to 1.02%. With them off every one of the 300 corpus strings matches in all four styles (mean and maximum below 0.01%). opf-render turns those features off in measurement and in SVG, so a renderer that does not is visual against Georgia.\n - **Size adjustment** (RR-38): a visual replacement whose advances and glyphs are far from the real font's can carry `sizeAdjust`, a preview-only font-size multiplier. A renderer scales the replacement's size by it when it measures and when it draws, so lines have the length PowerPoint's have; core's composed geometry, the exported sizes and every PPTX are unchanged. Arabic Typesetting\u2192Noto Naskh Arabic is 0.64 (the real font's advances are 0.643 of the replacement's over the Arabic corpus; its ink height is 0.71, so glyphs draw about 10 percent smaller than PowerPoint's). It applies only when the face drawn is the row's replacement. `lineAscent` and `lineAscentMixed` (em; Arabic Typesetting 0.70 and 0.78, measured in a native PowerPoint 365 probe against the font's hhea ascent of 0.701) say where PowerPoint puts the baseline below the top of a line box, for a line in the real font alone and for a line that also holds other fonts; a renderer that puts baselines one em below the line top moves such runs up by `1 - lineAscent` em. The width that remains against PowerPoint is measured in [the Arabic line-width residual](#arabic-typesetting-and-traditional-arabic-line-width-residual-opf523).\n - **Alternates** are tried, in order, when the declared replacement's pack is not loaded. An alternate is always reported as visual, including on a metric row.\n - **Aptos family:** Aptos\u2192Intos, Aptos Display\u2192Intos Display, Aptos Narrow\u2192Intos Narrow and Aptos Serif\u2192Intos Serif are metric: 0.000% mean and maximum against Aptos 2.01 in all four styles, with equal vertical metrics.\n - **Otherwise the closest measured open face**, marked visual: a documented fallback and a known layout-fidelity gap until a metric-compatible replacement exists. For example, Segoe UI\u2192Red Hat Display measures a 1.72% mean width difference.\n2. **The PPTX always names the chosen family, and no font file is included.** The exporter writes `Aptos` when the document chose Aptos. PowerPoint resolves standard fonts on the viewer's machine: those shipped with Office, Windows or macOS, and Microsoft 365 cloud fonts. The replacement never reaches the package (opf-pptx `test/export-chosen-fonts.mjs`).\n3. **Openly licensed fonts render as themselves.** Examples are Roboto, Carlito and the Noto script families. They can reach a PPTX only through an explicit embed path (FF-13), never by default.\n4. **Families that are proprietary and non-standard, or missing from the table,** keep their name in the PPTX. `fontAvailabilityDiagnostics()` reports that viewers may lack them. It also flags Microsoft 365 cloud-only fonts (such as Aptos) and families that ship only in an optional Windows language feature.\n\n### Which faces are available\n\nopf-render ships only fonts that it already pins and hash-verifies:\n- **Base pack:** Roboto and Roboto Mono.\n- **Office pack:** Carlito, Caladea, Arimo, Tinos, Cousine and Gelasio.\n- **Office pack, Aptos family:** Intos, Intos Display, Intos Narrow and Intos Serif (OFL-1.1, vendored in opf-render at a pinned commit, unmodified from the upstream files). A registry built without them (for example the base pack alone) falls back to the alternates Roboto and Carlito, reported as visual.\n- **Optional `scripts` pack (FF-19):** Noto for non-Latin scripts and CJK.\n\nSome replacements are open families that no renderer pack ships yet, such as Red Hat Display for Segoe UI and Red Hat Text for Tahoma. For these, the renderer tries the declared replacement first, then the alternates. The last alternate is the best measured bundled face, so previews stay deterministic without any extra download. `registry.substitutions` records which face was used and its tier. The proposed `catalog` pack (21 OFL `@expo-google-fonts` packages, listed in the opf-render PR) would make the declared replacements and the open catalog families available. Downloading it needs approval, and it is not part of this change.\n\n| Environment | Open catalog families | Proprietary families | When the real font is required |\n| --- | --- | --- | --- |\n| Local Node, cloud or serverless (`loadFonts({pack: 'office', substitutionPolicy: 'visual'})` from `/fonts-node`) | Exact when a pack ships them (Roboto, Carlito, \u2026, Noto with `scripts`) | Metric replacement: identical widths. Visual replacement: approximate, reported in `fonts.substitutions` with the measured delta | Supply licensed files with `loadFonts({faces: [{path, family, weight, italic}]})`; they resolve as exact faces |\n| Strict mode (`substitutionPolicy: 'metric'`) | Exact | Metric replacements only | `font-unavailable` names the license class, the declared replacement and tier, the pack, and the caller hook. There is never a silent wrong-metric fallback |\n| Browser (`loadFonts` from `/fonts-browser`) | Exact when the host serves the pack files | Same replacement rules | Same hook: pass the caller's own faces |\n| PowerPoint (exported PPTX) | Named; the viewer needs the font or substitutes | The selected name, never the replacement: the real font on Office, Windows or macOS, or through Microsoft 365 cloud fonts | Named; the viewer substitutes |\n\n**Aptos, the default scheme.** Aptos is a Microsoft 365 cloud font and is not redistributable. Under the owner policy of 2026-09-29 it previews with Intos, an OFL font whose advance widths, kerning and vertical metrics equal Aptos 2.01: 0.000% mean and maximum width difference over the 300-string corpus in regular, bold, italic and bold italic, for Aptos, Aptos Display, Aptos Narrow and Aptos Serif (Aptos Serif measured from Microsoft's standalone Aptos Fonts download, the others from the Microsoft 365 cloud fonts). Line breaks, line heights and text sizes therefore agree with Aptos. The letter shapes are Intos's own (Inter-derived, Gelasio-derived for the serif), not Aptos's. The exported PPTX still names Aptos, Aptos Display, Aptos Narrow or Aptos Serif, and no Aptos file is bundled or embedded. Deployments that hold an Aptos license can pass the real files through `faces` for exact previews.\n\nIntos ships in opf-render's default office pack (`loadFonts({pack: 'office'})`), about 12 MB of font files, so the default metric policy previews the Aptos family without asking for visual mode. Without those faces, previews fall back to the alternates Roboto and Carlito, marked visual. Like the open families, Intos is an `embed: \"used\"` face: `registry.embeddedFonts` stays the 33 eager npm faces, the handle's `embeddedFonts` supplies it, and a standalone SVG embeds only the Intos faces its text draws (an Aptos slide: Intos regular and Intos Display bold, 14.7 MB with the eager faces, against 20.7 MB with all eight styles). Intos is a single-maintainer project started in September 2026, so it is pinned by commit and SHA-256 and the previous replacements stay as alternates. The pptx.gallery parity scoreboard's `fontResolution` check counts the Aptos family as perfect because the replacement is metric-compatible.\n\n**Browser hosts load the vendored faces on demand.** The eager list is what a host puts in one `fonts.json` (12.6 MB); the vendored faces (Intos and the open families, `registry.lazyFonts`, 51 faces) would add 19.4 MB, so they ship as separate hash-pinned files at their package-relative paths (`fonts/intos/...`, `fonts/<family>/...`) and load through `loadFonts({faces, lazyFontsBaseUrl})` from `/fonts-browser`. `await fonts.ensure(presentation)` (the registry's `ensureLazyFonts` and `ensureScripts` are the lower-level calls) fetches and verifies only the faces the document draws (renderer 0.11.5, face level: a plain Aptos deck needs Intos Display Bold and Intos Regular, 2 files, 1.5 MB; an italic or bold run adds one face; before 0.11.5 it was every face of the resolved families, 8 files, 5.9 MB), then adds them to the document and the registry together, so the editor never measures with a face it paints as a fallback. The gallery commits only a pinned manifest, `lazy-fonts.json` (`scripts/gallery-lazy-fonts.mjs`, written by `build-registry-gallery-editor` from the published renderer's manifest when the pinned editor example sets `lazyFontsBaseUrl`, and copied by `prepare-gallery-editor`): exact renderer version, SPDX license, license-file hash and every face's SHA-256, no bytes. The gallery's own build copies the faces from the pinned renderer package's `fonts/` directory into an untracked path, verifying each hash, the way it does for script fonts. The local editor demo (`build-editor-demo`, through `scripts/emit-lazy-fonts.mjs`) copies the files beside the page instead. The editor playground calls `fonts.ensure(presentation)` when a document needs them. `node scripts/test-editor-lazy-fonts.mjs` drives the built playground in Chromium.\n\nThe [Windows reference-font advance study](evidence/shared-metric-native-anchor/font-study-comparison.json) is exploratory source-checkpoint evidence, not an additional compatibility certification. It measures 1,024 cases across regular/bold Calibri, Arial, Times New Roman and Courier New, recording local reference-file versions/hashes and native font-slot names. Disabling optional ligatures and rounding base glyph advances to eighth-point steps predicts 949 observations within 0.02pt; 75 outliers remain, including combining marks, Arabic and Calibri kerning. Office theme tokens and per-glyph fallback are not resolved to exact native files by these name properties. No runtime provider or open-font mapping changes from this hypothesis, and no reference font is redistributed.\n\nThe composition API accepts a `textMeasurement` provider. A provider resolves font faces and returns actual text widths; callers pass the same provider to pagination (as `{ fonts: { textMeasurement } }`), editor geometry, SVG rendering, and PPTX export. Without one, the existing deterministic character-width estimate remains available. A provider with `forScripts(profile)` (the renderer's registry measurement) is a script planner: `validate` and `paginate` ask it for each slide's script-aware measurement, built from the slide's `resolveScriptFonts` profile and a run language's own profile, so they measure Arabic or Japanese runs in the script faces preview and export draw.\n\n`loadFonts(options)` from `/fonts-node` returns the fonts handle: the same registry measurement (`textMeasurement`), the faces to embed in SVG (`embeddedFonts`) and the explicit raster files (`fontFiles`), with system and bundled fallback disabled for raster calls (`useBundledFonts: false`, `loadSystemFonts: false`). Pass it as `{ fonts }` to every deck-level call: `paginate`, editor geometry, `toSvg`, `toPng`, `toPdf` and `toPptx`. `pack: 'base'` is the default for authored Roboto decks; `pack: 'office'` adds the six Office substitute families and retains metric policy unless visual substitution is explicitly requested. `fonts.substitutions` records actual substitutions; the loader does not rewrite the authored document or add native embedding.\n\nRenderer 0.8.0 groups static files by their OpenType preferred family while retaining legacy family names and explicit custom namespaces. `Roboto` requests at 500/600/800 now select the actual Medium/SemiBold/ExtraBold files instead of nearby 400/700 faces. Optional `TextStyle.fontFace` carries the physical legacy family and native bold/italic flags independently of CSS numeric weight: SemiBold/ExtraBold are regular within their legacy families. The converter consumes this metadata; providers without it retain their prior behavior. Nine actual base faces pass metadata/measurement/outline checks and offline Chromium advances on Node 20/24. Seven payload slides cover serialized native selectors, deterministic output and source/reimport. These checks do not establish native Office paint, embedding or broader script coverage; see the [Mac candidate evidence](evidence/mac-font-variants/README.md).\n\nRenderer 0.8.0 uses adjacent SVG spans when no measurement provider is supplied. This closes the visible gaps caused by estimated fragment widths while retaining estimated line breaks and all run text/style/source offsets. Supplied providers and accepted placements still use exact fragment origins. Measured SVG requests geometric precision; accepted outline placements also constrain horizontal advances with `textLength`/`spacingAndGlyphs` to avoid browser quantization drift. This can scale glyphs horizontally while retaining nominal font size and baseline. Width-only providers do not receive that constraint, and constrained widths do not certify raw font-metric equivalence. The compatible editor supports both forms. This is a spacing improvement, not evidence that unmeasured wrapping or glyph coverage is accurate; see [candidate evidence](evidence/mac-rich-flow/README.md).\n\nBoth Node loaders verify exact package versions, 33 font-file hashes and eight license-notice hashes against the immutable `BUNDLED_FONT_MANIFEST` exported from `/fonts-node`. The office loader also verifies the vendored faces: the Carlito files, the 35 open-family files and the 16 Intos files, with the hashes of their licenses and, for Intos, its provenance notice. Missing/modified resources reject with actionable errors. Default raster loading now includes all nine base faces instead of omitting Roboto semibold, italic and bold italic. Font files must remain available and unchanged between preparation and raster export. Browser loading, actual glyph coverage, variant naming, rich spacing, and native compatibility remain separate requirements. These APIs are available in [renderer 0.8.0](https://github.com/OpenPresentation/opf-render/releases/tag/opf-render-v0.8.0), published against core 0.10.0 with Node 24. Prepared HarfBuzz shaping and variable-instance work remain separate drafts.\n\nThe renderer's optional font registry uses [Fontkit](https://github.com/foliojs/fontkit) to shape text and measure glyph advances from local font bytes. It does not discover system fonts or fetch fonts. The Node loader loads the renderer's bundled Roboto and Roboto Mono faces:\n\n```js\nimport { loadFonts } from '@openpresentation/opf-render/fonts-node';\nimport { toSvg, toPng } from '@openpresentation/opf-render';\nimport { paginate } from '@openpresentation/opf/pagination';\nimport { toPptx } from '@openpresentation/opf-pptx';\n\nconst fonts = await loadFonts(); // pack: 'base', the bundled Roboto faces\n// Use design.fontScheme: 'roboto', or supply the document's actual font files.\nconst { presentation } = paginate(deck, { fonts });\nconst svg = toSvg(presentation, 1, { fonts }); // slide 1; embeds the faces it draws (text: 'paths' draws outlines instead)\nconst png = await toPng(svg, { fonts }); // draws with fonts.fontFiles only\nconst pptx = await toPptx(presentation, { fonts });\n```\n\n`loadFonts({ faces })` (from `/fonts-node` or `/fonts-browser`) accepts `{data: Uint8Array, weight, italic?, family?, postscriptName?, license?}` entries in Node or the browser, and `{path}` entries in Node. Weights are explicit, with 400 as the default. Supply each style that the document uses. Missing font families and unsupported glyphs fail with `OPFFontError`, including the source path where available. Collection fonts require a `postscriptName` selecting one face.\n\nAliases and fallback families are explicit choices:\n\n```js\nconst fonts = await loadFonts({\n pack: 'none', faces,\n aliases: { Aptos: 'Roboto', 'Aptos Display': 'Roboto' },\n fallbackFamily: 'Roboto',\n});\nconsole.log(fonts.substitutions);\nfonts.registry.clearSubstitutions(); // Start a fresh render's diagnostic collection.\n```\n\nAn available exact family takes precedence over aliases. The registry resolves a requested weight to the closest supplied weight, reports the substitution, and makes the resolved style available to rendering. Missing italic/upright styles fail instead of synthesizing an unmeasured style. `strictGlyphs: false` is an explicit escape hatch for hosts with their own glyph-fallback policy; it is unsuitable for fidelity verification.\n\nSVG embeds supplied fonts using data URIs and includes supplied license notices as metadata. The bundled loader carries the fonts' SIL Open Font License notices. For PNG/PDF, pass the same font files to the rasterizer; its native font loader does not depend on browser CSS font loading. In a browser, wait for `document.fonts.ready` before measuring or taking a screenshot. The editor playground loads and embeds bundled fonts and displays substitutions.\n\n`toPdf` (`svgToPdf` before 0.18) in renderers up to 0.11.9 was image-only: each slide was rasterized and embedded as a PNG on a PDF page. From opf-render 0.12.0 (opf-render#90, [roadmap](plans/pdf-export.md)) the default vector mode writes PDF text objects in embedded TrueType subsets of the fonts you supply or the bundled open pack (the same files the PNG preview uses; system fonts are rejected, a face whose OS/2 `fsType` forbids embedding is never embedded, the report names requested and resolved faces), with `ToUnicode` maps and `/ActualText` where the glyph map cannot give the text, vector shapes, gradients and images, and no second layout pass. `raster: true` (`mode: \"raster\"` before 0.18) keeps the image-per-slide output as an explicit compatibility mode. Extraction was checked in pdf.js, PDFium and poppler on Latin, CJK, right-to-left and Indic samples and all 805 example slides; PDFium misreads some Thai and Burmese marks, as it does in Chrome's own PDFs. No PDF/UA or PDF/A claim.\n\n## Office compatibility pack\n\n`loadFonts({ pack: 'office' })` from `@openpresentation/opf-render/fonts-node` supplies regular, bold, italic, and bold italic faces of Carlito, Caladea, Arimo, Tinos, Cousine, and Gelasio, plus the base Roboto pack. Package versions are pinned and each face carries its distribution's license notice. `includeBaseFonts: false` omits Roboto. Loading never installs fonts into the operating system or downloads fonts at render time.\n\n```js\nconst fonts = await loadFonts({\n pack: 'office',\n substitutionPolicy: 'metric', // Default for the office pack; no visual fallback.\n});\nfonts.registry.resolveFont({fontFamily: 'Calibri', fontWeight: 400});\n// requestedFamily: Calibri, resolvedFamily: Carlito, compatibility: metric\n```\n\n`loadFonts` defaults to `substitutionPolicy: 'none'` (the office pack to `'metric'`). Policies are `none`, `metric`, and `visual`; visual permits both curated tiers. An explicit `fallbackFamily` is a separate, reported `generic` fallback. Aliases are explicit visual substitutions and never establish metric compatibility. `resolveFont` reports exact resolutions as well; `substitutions` only collects changes. Resolution records include requested/resolved weights, italic, source path, and supporting upstream information where available.\n\n| Requested family | Bundled substitute | Current automatic tier |\n| --- | --- | --- |\n| Calibri | Carlito | Metric intent, standard 400/700 styles |\n| Cambria | Caladea | Visual: advances differ from Cambria 6.99 by a mean of 2.7% (FF-31 measurement). Metric-mode registries still use it, reported as visual (`metricModeFallback`) |\n| Arial | Arimo | Metric, standard 400/700 styles |\n| Times New Roman | Tinos | Metric, standard 400/700 styles |\n| Courier New | Cousine | Metric, standard 400/700 styles |\n| Georgia | Gelasio | Metric with `liga` and `clig` off (`disabledFeatures`, applied by opf-render): advances identical on all 300 corpus strings in four styles. With default features, ligature runs differ by up to 1.02% |\n| Calibri Light | Carlito | Visual: the bundle has no Carlito Light face |\n| Aptos, Aptos Display, Aptos Narrow, Aptos Serif | Intos, Intos Display, Intos Narrow, Intos Serif (office pack) | Metric: 0.000% mean and maximum against Aptos 2.01, all four styles; Roboto and Carlito are visual alternates |\n\nUpstream evidence: [Carlito](https://github.com/googlefonts/carlito), [Fontconfig mappings](https://chromium.googlesource.com/external/fontconfig/+/refs/heads/main/conf.d/30-metric-aliases.conf), [Arimo](https://github.com/google/fonts/blob/main/ofl/arimo/DESCRIPTION.en_us.html), [Tinos](https://github.com/google/fonts/blob/main/ofl/tinos/DESCRIPTION.en_us.html), [Cousine](https://github.com/google/fonts/blob/main/ofl/cousine/DESCRIPTION.en_us.html), and [Gelasio](https://github.com/SorkinType/Gelasio). Metric classification describes compatibility intent within the stated style scope, not universal identical output. Missing matching weights cannot silently qualify for the metric tier.\n\nRR-17: Liberation Sans, Serif and Mono are not bundled (Reserved Font Name, about 4.4 MB); a document that names them previews with Arimo, Tinos and Cousine, the Croscore faces Liberation 2 is built from (metric, 0.0000% in four styles). Each Latin replacement has a per-family qualification (`scripts/qualify-latin-fonts.mjs`) and a fixture in every host; see the [font tracker](programs/font-fidelity-everywhere/font-tracker.md).\n\nThe exported `FONT_COMPATIBILITY` list also contains optional visual candidates and CJK families. Listing a candidate does not bundle it or imply complete character coverage. Liberation Sans Narrow is a separate legacy distribution with a different license history; it is not part of this bundle. Wingdings, Webdings, and Symbol require character mapping before substitution; an ordinary fallback fails with `font-encoding-required`. Cambria Math previews with STIX Two Math and Segoe UI Emoji with Noto Color Emoji once opf-render's optional math and emoji packs are loaded (FF-45, [special families: emoji and math](programs/font-fidelity-everywhere/special-families-emoji-math.md)); without the pack a renderer reports `font-unavailable` naming it, like any other routed family (the former `math-font-required` failure is gone). OPF has no equation model: a Cambria Math run is text drawn per character in a math face, not MATH-table layout.\n\nDrawingML tokens such as `+mn-lt` resolve through the registry's explicit `themeFonts` option before substitution. Supply concrete `majorLatin`, `minorLatin`, and, where used, `majorEastAsia`, `minorEastAsia`, `majorComplexScript`, or `minorComplexScript` families. Missing theme mappings fail. This helper does not yet extract theme font records or embedded fonts from imported PPTX files.\n\n### Measured results and experimental fonts\n\n`node --import ./scripts/register-local-opf.mjs scripts/test-office-fonts.mjs --system` compares the bundle to reference fonts already installed in macOS's Supplemental directory. It does not redistribute reference fonts. The report records source-file hashes and individual shaped widths. Across four samples and four styles, Arimo/Arial, Tinos/Times New Roman, and Cousine/Courier New matched exactly on 48 runs. Gelasio/Georgia differed on ligature-containing runs, with a maximum difference of 2.0125%. Individual basic-Latin advances matched; disabling optional ligatures removed the tested difference. Until feature handling is consistent across outputs, the policy conservatively labels Gelasio approximate. Calibri and Cambria reference fonts were not available for this comparison.\n\nAkasia, assessed earlier ([v0.0.2 open-file assessment](evidence/akasia-assessment/README.md)), is dropped: its repository is no longer available, and Intos replaces it. `EXPERIMENTAL_FONT_CANDIDATES` now records Microsoft's Selawik, measured for Segoe UI on 2026-09-29 and rejected: 0.16% mean and 2.5% maximum in regular, no italic faces, 349 code points, lowercase 4.8% shorter. The acceptance rules for replacement fonts are in the [licensing table](programs/font-fidelity-everywhere/font-licensing.md#replacement-font-acceptance-rules).\n\nAn original OPF font project is technically feasible: independently designed or suitably open-licensed glyph outlines can be fitted to target advance widths, placement, vertical metrics, and shaping behavior. A successful font needs a reproducible source build, provenance, style/coverage tests, visual review, and cross-renderer conformance. Matching bounding boxes alone is insufficient: [OpenType horizontal metrics](https://learn.microsoft.com/en-us/typography/opentype/spec/hmtx) and [glyph positioning](https://learn.microsoft.com/en-us/typography/opentype/spec/gpos) jointly control text placement. Universal pixel identity across rasterizers is not the acceptance criterion; measured layout preservation over an explicit test matrix is.\n\n## Verification and remaining work\n\nScript fonts (CJK, Arabic, Hebrew, Indic, Thai, Khmer, Myanmar and the rest) have their own shaping corpora and per-family qualification (FF-44, RR-17): see [script-corpora.md](programs/font-fidelity-everywhere/script-corpora.md). It records, per script, glyph coverage, fontkit against HarfBuzz and Chromium, the installed originals measured in place, and the known limits (fontkit has no Myanmar shaper; the PNG path of resvg-js mis-shapes the Indic scripts, Thai, Lao, Khmer and Myanmar).\n\n### Arabic Typesetting and Traditional Arabic: line-width residual (opf#523)\n\nNeither family is bundled; both preview and compose with Noto Naskh Arabic (OFL-1.1). Arabic Typesetting carries the RR-38 `sizeAdjust` of 0.64, and Traditional Arabic has none. Native PowerPoint draws lines in both families **narrower** than the shared composition measures them. This is a known, accepted residual.\n\n**Measured.** All widths are PowerPoint 365 `TextRange.Lines` bounds or ink, against the composed line.\n\n| Family | Native lines | Native / composed width | Largest gap |\n| --- | --- | --- | --- |\n| Arabic Typesetting (proxy at 0.64) | 16, in [RR-59](evidence/rr-59-native-20261008/README.md) and [FF-46 set 2](evidence/ff-46-documented-visual-native-20261005-fonts/README.md) | 14 lines at 0.86 to 0.97 (headings 0.86 to 0.92, body 0.90 to 0.97); one FF-46 body line at 1.04; one FF-46 heading at 0.55; pooled 0.94 without that heading | 56.6 pt narrower on an 856 pt body line (284 pt on the 0.55 heading); 31 pt wider on the 1.04 line |\n| Traditional Arabic (proxy at 1) | 10, in the same two runs | 0.68 to 0.90, pooled 0.79 | 223.6 pt narrower (an 842 pt body line) |\n\nThe RR-38 probe agrees for Arabic Typesetting. Its 45-character line is 216.9, 289.0 and 386.1 pt at 18, 24 and 32 pt in PowerPoint, and composes at 222.1, 298.6 and 396.9 pt.\n\n**Why it exists.** Three reasons:\n- **No published metrics.** Microsoft's font pages for [Arabic Typesetting](https://learn.microsoft.com/en-us/typography/font-list/arabic-typesetting) and [Traditional Arabic](https://learn.microsoft.com/en-us/typography/font-list/traditional-arabic) give only the file names, styles, vendor, scripts and code pages. They publish no advance widths, `xAvgCharWidth` or vertical metrics.\n- **No metric-compatible open face.** None exists for either family: no fontconfig metric alias, no Croscore-style clone. OPF does not take metrics from the installed proprietary files.\n- **One factor cannot fit.** Per glyph, Noto Naskh Arabic's advances differ from these fonts' by more than any scale can absorb. Ligatures and letter widths vary by text. Arabic Typesetting also has only a regular face, so PowerPoint emboldens bold text synthetically, while the preview draws Noto Naskh Arabic Bold.\n\n**One factor per family does not fix it.** For Traditional Arabic, the native / Noto Naskh ratio of the 10 lines spans 0.68 to 0.90, so any one factor leaves large gaps:\n\n| Factor | Largest gap, composed wider | Largest gap, native wider |\n| --- | --- | --- |\n| 0.79 (the pooled ratio; the opf#361 proposal) | 47 pt | 48 pt |\n| 0.90 | 139 pt | none |\n\nLatin text in a deck that uses the Traditional Arabic scheme also draws in Noto Naskh Arabic's Latin, where the policy measures Traditional Arabic at 0.86 of the replacement's width. At 0.79, those lines would run about 9 percent past their composed width in PowerPoint. Arabic Typesetting's 0.64 already sits near the upper edge of its native ratios. A lower factor would let more lines run past their boxes.\n\n**Practical impact.**\n- **Line counts agree.** The PPTX export writes each composed line as its own text box, and PowerPoint does not re-wrap it. Line counts, the order of lines and the right edges of right-to-left lines therefore agree: within 0.13 pt in RR-59.\n- **Left edges differ.** The left edge of a right-to-left line (the right edge of a left-to-right one) sits closer to the text in PowerPoint. The same text could fit in fewer lines natively than the preview shows.\n- **Overhang is rare.** Because the proxy errs wide, a Traditional Arabic line keeps slack in its box. For Arabic Typesetting, an occasional line runs a few percent past its composed width: 3.78 pt past its box in RR-59 `ar-run-in-en`, not clipped.\n- **Wrapping can differ near the edge.** Text that PowerPoint lays out itself wraps where the real font's widths put it, which can differ from the preview. That covers a line box retyped in PowerPoint, and text another application re-flows.\n\nFor layouts that must wrap exactly, choose a family with a metric replacement, or supply licensed font files through `loadFonts({faces})`.\n\n`pnpm test:fonts` checks that editor and SVG geometry match and that every native PPTX text box has the same coordinates and measured line breaks. With opf-pptx FF-31 (opf-pptx#63), export names the chosen family, not the preview substitute. It writes artifacts to `artifacts/fonts/`. A real-browser check of the same Roboto run measured 324.032 pixels versus the font engine's 324.170 pixels at 25 pixels, a difference of 0.138 pixels. These are measured tolerances, not a promise of pixel identity.\n\nPPTX records the chosen font family (FF-31); it never records a preview replacement and never embeds a proprietary font binary. PowerPoint still needs those fonts installed, through Office, the OS or Microsoft 365 cloud fonts, or it substitutes them. Line height remains the shared 1.22 multiplier, rather than a complete ascent/descent model. Rich-text font overrides, mixed-script fallback and bidi layout, specialized payload internals, and native font embedding remain active fidelity work. Passing a width provider does not remove those limits.\n"
109
+ "markdown": "# Measured fonts and reproducible previews\n\n## Start with the installed package\n\nUse the coordinated versions in the [quickstart](quickstart.md). Load fonts once per worker with\n`loadFonts({pack: 'office', scripts: 'auto', presentation})` from `@openpresentation/opf-render/fonts-node`,\nand pass the same handle as `{fonts}` to preview and export. When reusing a worker for another deck,\nensure its required faces are loaded before rendering, and bound concurrency. The renderer's\n[soak benchmark](https://github.com/OpenPresentation/opf-render#long-lived-workers-measured-memory-and-latency-opf-render171)\n(`npm run bench:soak`) measured no memory growth over 500 jobs per worker. A worker holds about 1 GiB, peaking near\n2 GiB on script-heavy decks, and its output is byte-identical on every repeat. Measure your own decks the same way.\nFor an authored Roboto deck, `pack: 'base'` avoids loading Office replacements. Neither loader reads system fonts by default.\n\nIn browsers, serve the renderer's pinned font files from your own origin and await the font handle's `ensure(presentation)`\nbefore measuring or editing. The renderer README documents font copying and the player integration. Standalone SVG must\ncarry the used font bytes; script faces require `embedScriptFonts: true` in the Node loader. The CLI's default SVG mode\nrequests this automatically (`--text fonts`); `--text system` needs matching viewer fonts, and `--text paths` draws the glyphs as outlines so the SVG needs none. PPTX normally names the authored families\nand requires them on the recipient's machine; it does not automatically embed the preview fonts.\n\nThe policy below is current. Version-specific measurements and implementation history are evidence for their named\ncheckpoints, not transfer-size budgets for every release. The [font roadmap](plans/font-roadmap.md) records historical\nplanning; use installed package APIs and measured output when choosing a deployment strategy.\n\n## Pass `fonts`, otherwise the estimate\n\nText measurement comes from one place: the `fonts` handle that opf-render's `loadFonts()` returns. Since RR-55 the same\nhandle carries it for every step that lays text out:\n\n- core's `validate`, `paginate` and the slide context (`resolveSlideContext`);\n- opf-render's `toSvg`, `toPng` and `toPdf`;\n- opf-pptx's `toPptx`.\n\n```js\nconst fonts = await loadFonts({ pack: 'office' });\nvalidate(deck, { fonts });\nconst { presentation } = paginate(deck, { fonts });\nconst svg = toSvg(presentation, { fonts });\nconst pptx = await toPptx(presentation, { fonts });\n```\n\nPass the same handle to preview, validation, pagination and export, so that all of them measure with the faces the\npreview draws. Then line breaks, overflow findings, page breaks and the exported text boxes agree, and the same handle\ngives identical geometry across engines (opf-pptx's `layout-parity` test checks that the exported PPTX paragraphs agree with the SVG preview and with\ncore's composed items, with and without `fonts`).\n\nA call without `fonts` is not an error: it uses core's built-in estimate, and says so (`checks.layout: \"estimated\"` in\n`validate`, `layout: \"estimated\"` in `paginate`). The estimate is deterministic and needs no font files: 0.54 em per\ncharacter, 0.62 em for capital letters and digits, 0.32 em for a space, 1 em for CJK characters, and zero width for\ncombining marks. It is close for Latin text in an ordinary text face and too narrow for some scripts (the tracking issue\nis opf#566), so treat an estimated result as a draft and measure before you rely on a page break or an overflow finding.\nCore's Node `convert` and the CLI's `convert` and `paginate` do this for you: they prepare an office-pack `fonts` handle once per process (`opf validate` loads none, so it is estimated). A library\ncall (`validate`, `paginate`, `toSvg`, `toPptx`) never loads fonts on its own, because that would change every\nfonts-less result and pull font files into a browser bundle; pass the handle you want.\n\n## Font policy (FF-31)\n\nOPF keeps one machine-readable font policy table, [`spec/reference/font-policy.json`](../spec/reference/font-policy.json). Core exports it as `FONT_POLICY`, `fontPolicyFor()` and `applyFontPolicyDecisions()` from `@openpresentation/opf` or `@openpresentation/opf/font-policy`. Each row gives a family's license class, where viewers get it, whether OPF may ever embed it, and its preview replacement with a measured width difference. It also lists alternates, ending where possible with a face that already ships with opf-render. The [licensing table](programs/font-fidelity-everywhere/font-licensing.md) lists all 153 rows. [`font-policy.schema.json`](../spec/reference/font-policy.schema.json) is its JSON Schema. The [measurement evidence](evidence/font-replacements-20260923/README.md) explains how each replacement was chosen.\n\n**The policy in brief (owner decisions, 2026-09-29).** This is the canonical statement; the sibling repositories link here.\n\n1. **The user's font selection is the source of truth.** The user picks a font, for example Aptos or Calibri through a font scheme. That name is what the document, the theme and the PPTX carry.\n2. **License-restricted (proprietary) fonts are never bundled or embedded,** so previews cannot draw them. Openly licensed fonts such as Carlito and Roboto are bundled, and \"licensed\" below always means license-restricted. Live previews, SVG, the editor and gallery thumbnails use an open look-alike instead. The goal is a replacement that looks similar and is metric-compatible (same advance widths and line metrics), so text occupies the same size on screen and wraps as it does in PowerPoint. Calibri\u2192Carlito is the model.\n3. **Where no metric-compatible open replacement exists yet,** a visual-only look-alike is a documented fallback. It is reported as visual and is a known layout-fidelity gap to close, not the intended end state. The parity scoreboard counts it as near, not perfect. The Aptos family is no longer such a case: Intos previews it as metric.\n4. **PPTX export always writes the selected font name,** for example `typeface=\"Aptos\"` in the theme and in runs, never the replacement. PowerPoint then opens the file and shows the actual font, installed or as a Microsoft 365 cloud font.\n5. **Only open fonts may be embedded,** through the explicit embed path (FF-13).\n6. **Font files are bundled, never hotlinked.** Fonts, Google Fonts included, ship as pinned files (an exact npm version, or a vendored file with a recorded sha256) and are never loaded at runtime from a font CDN. That protects visitor privacy (a German court held the Google Fonts CDN a GDPR violation in 2022) and keeps previews offline-capable and audits reproducible. Build-time self-hosting such as `next/font/google` is not a hotlink but is not pinned, so use `next/font/local` with pinned files. Guards fail on CDN references in every repository.\n7. **Every bundled face records a verified permissive license.** Allowed: exactly OFL-1.1, Apache-2.0, MIT and UFL-1.0; not GPL, LGPL, AGPL, proprietary or unclear public-domain fonts. The record has the SPDX id, any Reserved Font Name the copyright block declares, the source URL, package@version and sha256, all checked against the LICENSE file the font ships with. A modified version (subset, instance, conversion) may not use a Reserved Font Name in its name: a family whose served name contains its reserved name (Carlito, Raleway) is bundled only as the unmodified upstream file, while one that reserves another name (Noto Sans JP reserves \"Source\") may be modified. Details and enforcement: [Font files: bundling and licenses](programs/font-fidelity-everywhere/font-licensing.md#font-files-bundling-and-licenses).\n\n**Release status.** Point 4 is published: opf-pptx 0.10.0 and later (current 0.15.0) write the selected name into the PPTX, and the editor and pptx.gallery builds that depend on the release-plan set export it. opf-pptx 0.9.1 and earlier wrote the substitute.\n\n1. **Licensed, non-free fonts are never bundled or embedded.** This covers Aptos, Calibri, Cambria, Segoe UI, Georgia, Tahoma, Grandview, Seaford, Tenorite, Consolas, Times New Roman, Arial, Courier New and the Windows script fonts. Rendering uses the family's designated open replacement instead:\n - **Metric-compatible** is the goal, used where a replacement exists and measures identical: Calibri\u2192Carlito, Arial\u2192Arimo, Times New Roman\u2192Tinos, Courier New\u2192Cousine and Georgia\u2192Gelasio. A metric row needs an upstream statement and a measurement in all four styles, with a mean width difference below 0.1% and no corpus string more than 0.3% off. Georgia\u2192Gelasio passes only with Gelasio shaped with its `liga` and `clig` features off (the row lists them as `disabledFeatures`): with default features Gelasio ligates fi, fl, ffi and ffl, which Georgia does not, and runs differ by up to 1.02%. With them off every one of the 300 corpus strings matches in all four styles (mean and maximum below 0.01%). opf-render turns those features off in measurement and in SVG, so a renderer that does not is visual against Georgia.\n - **Size adjustment** (RR-38, opf#361): a visual replacement whose advances and glyphs are far from the real font's can carry `sizeAdjust`, a preview-only font-size multiplier. A renderer scales the replacement's size by it when it measures and when it draws, so lines have the length PowerPoint's have. The exported font sizes and the PPTX font names are unchanged; an export that measures with the renderer's fonts breaks lines at the adjusted widths, as the preview does. It applies to runs of the family's script drawn in the row's replacement; Latin text in a scheme of that family is drawn by the replacement at the composed size. Six rows carry one: Arabic Typesetting 0.64 (RR-38, the corpus advance ratio), and, by the opf#361 rule (the smallest factor at which no line of the script corpus or of the scheme's Latin overhangs more than 3 percent natively), Angsana New 0.75, DilleniaUPC 0.68, Sakkal Majalla 0.89, Malgun Gothic 1.07 and Nirmala UI 1.07. Traditional Arabic, Ebrima and MS Gothic carry none: one factor cannot fit them ([measurements](evidence/ff-46-sizeadjust-native-20261010/README.md)). `lineAscent` and `lineAscentMixed` (em) say where PowerPoint puts the baseline below the top of a line box, for a line in the real font alone and for a line that also holds other fonts, measured in a native PowerPoint 365 probe: Angsana New 0.83 / 0.86, DilleniaUPC 0.82 / 0.85, Sakkal Majalla 0.76 / 0.81, Nirmala UI 0.97 / 0.94 and Arabic Typesetting 0.74 / 0.80 (corrected in opf#361 from RR-38's 0.70 / 0.78, which an earlier estimator read). A renderer that puts baselines one em below the line top moves such runs up by `1 - lineAscent` em; Malgun Gothic's baseline (1.06 em) sits below that, so it has none. Decomposed conjoining Hangul jamo (U+1100 to U+11FF, U+A960 to U+A97F, U+D7B0 to U+D7FF) are a known limitation: PowerPoint draws them unshaped and up to 1.9 times wider, which no factor fixes; precomposed Hangul, which real text uses, is what the Malgun Gothic factor is set on. The width that remains against PowerPoint is measured in [the Arabic line-width residual](#arabic-typesetting-and-traditional-arabic-line-width-residual-opf523).\n - **Alternates** are tried, in order, when the declared replacement's pack is not loaded. An alternate is always reported as visual, including on a metric row.\n - **Aptos family:** Aptos\u2192Intos, Aptos Display\u2192Intos Display, Aptos Narrow\u2192Intos Narrow and Aptos Serif\u2192Intos Serif are metric: 0.000% mean and maximum against Aptos 2.01 in all four styles, with equal vertical metrics.\n - **Otherwise the closest measured open face**, marked visual: a documented fallback and a known layout-fidelity gap until a metric-compatible replacement exists. For example, Segoe UI\u2192Red Hat Display measures a 1.72% mean width difference.\n2. **The PPTX always names the chosen family, and no font file is included.** The exporter writes `Aptos` when the document chose Aptos. PowerPoint resolves standard fonts on the viewer's machine: those shipped with Office, Windows or macOS, and Microsoft 365 cloud fonts. The replacement never reaches the package (opf-pptx `test/export-chosen-fonts.mjs`).\n3. **Openly licensed fonts render as themselves.** Examples are Roboto, Carlito and the Noto script families. They can reach a PPTX only through an explicit embed path (FF-13), never by default.\n4. **Families that are proprietary and non-standard, or missing from the table,** keep their name in the PPTX. `fontAvailabilityDiagnostics()` reports that viewers may lack them. It also flags Microsoft 365 cloud-only fonts (such as Aptos) and families that ship only in an optional Windows language feature.\n\n### Which faces are available\n\nopf-render ships only fonts that it already pins and hash-verifies:\n- **Base pack:** Roboto and Roboto Mono.\n- **Office pack:** Carlito, Caladea, Arimo, Tinos, Cousine and Gelasio.\n- **Office pack, Aptos family:** Intos, Intos Display, Intos Narrow and Intos Serif (OFL-1.1, vendored in opf-render at a pinned commit, unmodified from the upstream files). A registry built without them (for example the base pack alone) falls back to the alternates Roboto and Carlito, reported as visual.\n- **Optional `scripts` pack (FF-19):** Noto for non-Latin scripts and CJK.\n\nSome replacements are open families that no renderer pack ships yet, such as Red Hat Display for Segoe UI and Red Hat Text for Tahoma. For these, the renderer tries the declared replacement first, then the alternates. The last alternate is the best measured bundled face, so previews stay deterministic without any extra download. `registry.substitutions` records which face was used and its tier. The proposed `catalog` pack (21 OFL `@expo-google-fonts` packages, listed in the opf-render PR) would make the declared replacements and the open catalog families available. Downloading it needs approval, and it is not part of this change.\n\n| Environment | Open catalog families | Proprietary families | When the real font is required |\n| --- | --- | --- | --- |\n| Local Node, cloud or serverless (`loadFonts({pack: 'office', substitutionPolicy: 'visual'})` from `/fonts-node`) | Exact when a pack ships them (Roboto, Carlito, \u2026, Noto with `scripts`) | Metric replacement: identical widths. Visual replacement: approximate, reported in `fonts.substitutions` with the measured delta | Supply licensed files with `loadFonts({faces: [{path, family, weight, italic}]})`; they resolve as exact faces |\n| Strict mode (`substitutionPolicy: 'metric'`) | Exact | Metric replacements only | `font-unavailable` names the license class, the declared replacement and tier, the pack, and the caller hook. There is never a silent wrong-metric fallback |\n| Browser (`loadFonts` from `/fonts-browser`) | Exact when the host serves the pack files | Same replacement rules | Same hook: pass the caller's own faces |\n| PowerPoint (exported PPTX) | Named; the viewer needs the font or substitutes | The selected name, never the replacement: the real font on Office, Windows or macOS, or through Microsoft 365 cloud fonts | Named; the viewer substitutes |\n\n**Aptos, the default scheme.** Aptos is a Microsoft 365 cloud font and is not redistributable. Under the owner policy of 2026-09-29 it previews with Intos, an OFL font whose advance widths, kerning and vertical metrics equal Aptos 2.01: 0.000% mean and maximum width difference over the 300-string corpus in regular, bold, italic and bold italic, for Aptos, Aptos Display, Aptos Narrow and Aptos Serif (Aptos Serif measured from Microsoft's standalone Aptos Fonts download, the others from the Microsoft 365 cloud fonts). Line breaks, line heights and text sizes therefore agree with Aptos. The letter shapes are Intos's own (Inter-derived, Gelasio-derived for the serif), not Aptos's. The exported PPTX still names Aptos, Aptos Display, Aptos Narrow or Aptos Serif, and no Aptos file is bundled or embedded. Deployments that hold an Aptos license can pass the real files through `faces` for exact previews.\n\nIntos ships in opf-render's default office pack (`loadFonts({pack: 'office'})`), about 12 MB of font files, so the default metric policy previews the Aptos family without asking for visual mode. Without those faces, previews fall back to the alternates Roboto and Carlito, marked visual. Like the open families, Intos is an `embed: \"used\"` face: `registry.embeddedFonts` stays the 33 eager npm faces, the handle's `embeddedFonts` supplies it, and a standalone SVG embeds only the Intos faces its text draws (an Aptos slide: Intos regular and Intos Display bold, 14.7 MB with the eager faces, against 20.7 MB with all eight styles). Intos is a single-maintainer project started in September 2026, so it is pinned by commit and SHA-256 and the previous replacements stay as alternates. The pptx.gallery parity scoreboard's `fontResolution` check counts the Aptos family as perfect because the replacement is metric-compatible.\n\n**Browser hosts load the vendored faces on demand.** The eager list is what a host puts in one `fonts.json` (12.6 MB); the vendored faces (Intos and the open families, `registry.lazyFonts`, 51 faces) would add 19.4 MB, so they ship as separate hash-pinned files at their package-relative paths (`fonts/intos/...`, `fonts/<family>/...`) and load through `loadFonts({faces, lazyFontsBaseUrl})` from `/fonts-browser`. `await fonts.ensure(presentation)` (the registry's `ensureLazyFonts` and `ensureScripts` are the lower-level calls) fetches and verifies only the faces the document draws (renderer 0.11.5, face level: a plain Aptos deck needs Intos Display Bold and Intos Regular, 2 files, 1.5 MB; an italic or bold run adds one face; before 0.11.5 it was every face of the resolved families, 8 files, 5.9 MB), then adds them to the document and the registry together, so the editor never measures with a face it paints as a fallback. The gallery commits only a pinned manifest, `lazy-fonts.json` (`scripts/gallery-lazy-fonts.mjs`, written by `build-registry-gallery-editor` from the published renderer's manifest when the pinned editor example sets `lazyFontsBaseUrl`, and copied by `prepare-gallery-editor`): exact renderer version, SPDX license, license-file hash and every face's SHA-256, no bytes. The gallery's own build copies the faces from the pinned renderer package's `fonts/` directory into an untracked path, verifying each hash, the way it does for script fonts. The local editor demo (`build-editor-demo`, through `scripts/emit-lazy-fonts.mjs`) copies the files beside the page instead. The editor playground calls `fonts.ensure(presentation)` when a document needs them. `node scripts/test-editor-lazy-fonts.mjs` drives the built playground in Chromium.\n\nThe [Windows reference-font advance study](evidence/shared-metric-native-anchor/font-study-comparison.json) is exploratory source-checkpoint evidence, not an additional compatibility certification. It measures 1,024 cases across regular/bold Calibri, Arial, Times New Roman and Courier New, recording local reference-file versions/hashes and native font-slot names. Disabling optional ligatures and rounding base glyph advances to eighth-point steps predicts 949 observations within 0.02pt; 75 outliers remain, including combining marks, Arabic and Calibri kerning. Office theme tokens and per-glyph fallback are not resolved to exact native files by these name properties. No runtime provider or open-font mapping changes from this hypothesis, and no reference font is redistributed.\n\nThe composition API accepts a `textMeasurement` provider. A provider resolves font faces and returns actual text widths; callers pass the same provider to pagination (as `{ fonts: { textMeasurement } }`), editor geometry, SVG rendering, and PPTX export. Without one, the existing deterministic character-width estimate remains available. A provider with `forScripts(profile)` (the renderer's registry measurement) is a script planner: `validate` and `paginate` ask it for each slide's script-aware measurement, built from the slide's `resolveScriptFonts` profile and a run language's own profile, so they measure Arabic or Japanese runs in the script faces preview and export draw.\n\n`loadFonts(options)` from `/fonts-node` returns the fonts handle: the same registry measurement (`textMeasurement`), the faces to embed in SVG (`embeddedFonts`) and the explicit raster files (`fontFiles`), with system and bundled fallback disabled for raster calls (`useBundledFonts: false`, `loadSystemFonts: false`). Pass it as `{ fonts }` to every deck-level call: `paginate`, editor geometry, `toSvg`, `toPng`, `toPdf` and `toPptx`. `pack: 'base'` is the default for authored Roboto decks; `pack: 'office'` adds the six Office substitute families and retains metric policy unless visual substitution is explicitly requested. `fonts.substitutions` records actual substitutions; the loader does not rewrite the authored document or add native embedding.\n\nRenderer 0.8.0 groups static files by their OpenType preferred family while retaining legacy family names and explicit custom namespaces. `Roboto` requests at 500/600/800 now select the actual Medium/SemiBold/ExtraBold files instead of nearby 400/700 faces. Optional `TextStyle.fontFace` carries the physical legacy family and native bold/italic flags independently of CSS numeric weight: SemiBold/ExtraBold are regular within their legacy families. The converter consumes this metadata; providers without it retain their prior behavior. Nine actual base faces pass metadata/measurement/outline checks and offline Chromium advances on Node 20/24. Seven payload slides cover serialized native selectors, deterministic output and source/reimport. These checks do not establish native Office paint, embedding or broader script coverage; see the [Mac candidate evidence](evidence/mac-font-variants/README.md).\n\nRenderer 0.8.0 uses adjacent SVG spans when no measurement provider is supplied. This closes the visible gaps caused by estimated fragment widths while retaining estimated line breaks and all run text/style/source offsets. Supplied providers and accepted placements still use exact fragment origins. Measured SVG requests geometric precision; accepted outline placements also constrain horizontal advances with `textLength`/`spacingAndGlyphs` to avoid browser quantization drift. This can scale glyphs horizontally while retaining nominal font size and baseline. Width-only providers do not receive that constraint, and constrained widths do not certify raw font-metric equivalence. The compatible editor supports both forms. This is a spacing improvement, not evidence that unmeasured wrapping or glyph coverage is accurate; see [candidate evidence](evidence/mac-rich-flow/README.md).\n\nBoth Node loaders verify exact package versions, 33 font-file hashes and eight license-notice hashes against the immutable `BUNDLED_FONT_MANIFEST` exported from `/fonts-node`. The office loader also verifies the vendored faces: the Carlito files, the 35 open-family files and the 16 Intos files, with the hashes of their licenses and, for Intos, its provenance notice. Missing/modified resources reject with actionable errors. Default raster loading now includes all nine base faces instead of omitting Roboto semibold, italic and bold italic. Font files must remain available and unchanged between preparation and raster export. Browser loading, actual glyph coverage, variant naming, rich spacing, and native compatibility remain separate requirements. These APIs are available in [renderer 0.8.0](https://github.com/OpenPresentation/opf-render/releases/tag/opf-render-v0.8.0), published against core 0.10.0 with Node 24. Prepared HarfBuzz shaping and variable-instance work remain separate drafts.\n\nThe renderer's optional font registry uses [Fontkit](https://github.com/foliojs/fontkit) to shape text and measure glyph advances from local font bytes. It does not discover system fonts or fetch fonts. The Node loader loads the renderer's bundled Roboto and Roboto Mono faces:\n\n```js\nimport { loadFonts } from '@openpresentation/opf-render/fonts-node';\nimport { toSvg, toPng } from '@openpresentation/opf-render';\nimport { paginate } from '@openpresentation/opf/pagination';\nimport { toPptx } from '@openpresentation/opf-pptx';\n\nconst fonts = await loadFonts(); // pack: 'base', the bundled Roboto faces\n// Use design.fontScheme: 'roboto', or supply the document's actual font files.\nconst { presentation } = paginate(deck, { fonts });\nconst svg = toSvg(presentation, 1, { fonts }); // slide 1; embeds the faces it draws (text: 'paths' draws outlines instead)\nconst png = await toPng(svg, { fonts }); // draws with fonts.fontFiles only\nconst pptx = await toPptx(presentation, { fonts });\n```\n\n`loadFonts({ faces })` (from `/fonts-node` or `/fonts-browser`) accepts `{data: Uint8Array, weight, italic?, family?, postscriptName?, license?}` entries in Node or the browser, and `{path}` entries in Node. Weights are explicit, with 400 as the default. Supply each style that the document uses. Missing font families and unsupported glyphs fail with `OPFFontError`, including the source path where available. Collection fonts require a `postscriptName` selecting one face.\n\nAliases and fallback families are explicit choices:\n\n```js\nconst fonts = await loadFonts({\n pack: 'none', faces,\n aliases: { Aptos: 'Roboto', 'Aptos Display': 'Roboto' },\n fallbackFamily: 'Roboto',\n});\nconsole.log(fonts.substitutions);\nfonts.registry.clearSubstitutions(); // Start a fresh render's diagnostic collection.\n```\n\nAn available exact family takes precedence over aliases. The registry resolves a requested weight to the closest supplied weight, reports the substitution, and makes the resolved style available to rendering. Missing italic/upright styles fail instead of synthesizing an unmeasured style. `strictGlyphs: false` is an explicit escape hatch for hosts with their own glyph-fallback policy; it is unsuitable for fidelity verification.\n\nSVG embeds supplied fonts using data URIs and includes supplied license notices as metadata. The bundled loader carries the fonts' SIL Open Font License notices. For PNG/PDF, pass the same font files to the rasterizer; its native font loader does not depend on browser CSS font loading. In a browser, wait for `document.fonts.ready` before measuring or taking a screenshot. The editor playground loads and embeds bundled fonts and displays substitutions.\n\n`toPdf` (`svgToPdf` before 0.18) in renderers up to 0.11.9 was image-only: each slide was rasterized and embedded as a PNG on a PDF page. From opf-render 0.12.0 (opf-render#90, [roadmap](plans/pdf-export.md)) the default vector mode writes PDF text objects in embedded TrueType subsets of the fonts you supply or the bundled open pack (the same files the PNG preview uses; system fonts are rejected, a face whose OS/2 `fsType` forbids embedding is never embedded, the report names requested and resolved faces), with `ToUnicode` maps and `/ActualText` where the glyph map cannot give the text, vector shapes, gradients and images, and no second layout pass. `raster: true` (`mode: \"raster\"` before 0.18) keeps the image-per-slide output as an explicit compatibility mode. Extraction was checked in pdf.js, PDFium and poppler on Latin, CJK, right-to-left and Indic samples and all 805 example slides; PDFium misreads some Thai and Burmese marks, as it does in Chrome's own PDFs. No PDF/UA or PDF/A claim.\n\n## Office compatibility pack\n\n`loadFonts({ pack: 'office' })` from `@openpresentation/opf-render/fonts-node` supplies regular, bold, italic, and bold italic faces of Carlito, Caladea, Arimo, Tinos, Cousine, and Gelasio, plus the base Roboto pack. Package versions are pinned and each face carries its distribution's license notice. `includeBaseFonts: false` omits Roboto. Loading never installs fonts into the operating system or downloads fonts at render time.\n\n```js\nconst fonts = await loadFonts({\n pack: 'office',\n substitutionPolicy: 'metric', // Default for the office pack; no visual fallback.\n});\nfonts.registry.resolveFont({fontFamily: 'Calibri', fontWeight: 400});\n// requestedFamily: Calibri, resolvedFamily: Carlito, compatibility: metric\n```\n\n`loadFonts` defaults to `substitutionPolicy: 'none'` (the office pack to `'metric'`). Policies are `none`, `metric`, and `visual`; visual permits both curated tiers. An explicit `fallbackFamily` is a separate, reported `generic` fallback. Aliases are explicit visual substitutions and never establish metric compatibility. `resolveFont` reports exact resolutions as well; `substitutions` only collects changes. Resolution records include requested/resolved weights, italic, source path, and supporting upstream information where available.\n\n| Requested family | Bundled substitute | Current automatic tier |\n| --- | --- | --- |\n| Calibri | Carlito | Metric intent, standard 400/700 styles |\n| Cambria | Caladea | Visual: advances differ from Cambria 6.99 by a mean of 2.7% (FF-31 measurement). Metric-mode registries still use it, reported as visual (`metricModeFallback`) |\n| Arial | Arimo | Metric, standard 400/700 styles |\n| Times New Roman | Tinos | Metric, standard 400/700 styles |\n| Courier New | Cousine | Metric, standard 400/700 styles |\n| Georgia | Gelasio | Metric with `liga` and `clig` off (`disabledFeatures`, applied by opf-render): advances identical on all 300 corpus strings in four styles. With default features, ligature runs differ by up to 1.02% |\n| Calibri Light | Carlito | Visual: the bundle has no Carlito Light face |\n| Aptos, Aptos Display, Aptos Narrow, Aptos Serif | Intos, Intos Display, Intos Narrow, Intos Serif (office pack) | Metric: 0.000% mean and maximum against Aptos 2.01, all four styles; Roboto and Carlito are visual alternates |\n\nUpstream evidence: [Carlito](https://github.com/googlefonts/carlito), [Fontconfig mappings](https://chromium.googlesource.com/external/fontconfig/+/refs/heads/main/conf.d/30-metric-aliases.conf), [Arimo](https://github.com/google/fonts/blob/main/ofl/arimo/DESCRIPTION.en_us.html), [Tinos](https://github.com/google/fonts/blob/main/ofl/tinos/DESCRIPTION.en_us.html), [Cousine](https://github.com/google/fonts/blob/main/ofl/cousine/DESCRIPTION.en_us.html), and [Gelasio](https://github.com/SorkinType/Gelasio). Metric classification describes compatibility intent within the stated style scope, not universal identical output. Missing matching weights cannot silently qualify for the metric tier.\n\nRR-17: Liberation Sans, Serif and Mono are not bundled (Reserved Font Name, about 4.4 MB); a document that names them previews with Arimo, Tinos and Cousine, the Croscore faces Liberation 2 is built from (metric, 0.0000% in four styles). Each Latin replacement has a per-family qualification (`scripts/qualify-latin-fonts.mjs`) and a fixture in every host; see the [font tracker](programs/font-fidelity-everywhere/font-tracker.md).\n\nThe exported `FONT_COMPATIBILITY` list also contains optional visual candidates and CJK families. Listing a candidate does not bundle it or imply complete character coverage. Liberation Sans Narrow is a separate legacy distribution with a different license history; it is not part of this bundle. Wingdings, Webdings, and Symbol require character mapping before substitution; an ordinary fallback fails with `font-encoding-required`. Cambria Math previews with STIX Two Math and Segoe UI Emoji with Noto Color Emoji once opf-render's optional math and emoji packs are loaded (FF-45, [special families: emoji and math](programs/font-fidelity-everywhere/special-families-emoji-math.md)); without the pack a renderer reports `font-unavailable` naming it, like any other routed family (the former `math-font-required` failure is gone). OPF has no equation model: a Cambria Math run is text drawn per character in a math face, not MATH-table layout.\n\nDrawingML tokens such as `+mn-lt` resolve through the registry's explicit `themeFonts` option before substitution. Supply concrete `majorLatin`, `minorLatin`, and, where used, `majorEastAsia`, `minorEastAsia`, `majorComplexScript`, or `minorComplexScript` families. Missing theme mappings fail. This helper does not yet extract theme font records or embedded fonts from imported PPTX files.\n\n### Measured results and experimental fonts\n\n`node --import ./scripts/register-local-opf.mjs scripts/test-office-fonts.mjs --system` compares the bundle to reference fonts already installed in macOS's Supplemental directory. It does not redistribute reference fonts. The report records source-file hashes and individual shaped widths. Across four samples and four styles, Arimo/Arial, Tinos/Times New Roman, and Cousine/Courier New matched exactly on 48 runs. Gelasio/Georgia differed on ligature-containing runs, with a maximum difference of 2.0125%. Individual basic-Latin advances matched; disabling optional ligatures removed the tested difference. Until feature handling is consistent across outputs, the policy conservatively labels Gelasio approximate. Calibri and Cambria reference fonts were not available for this comparison.\n\nAkasia, assessed earlier ([v0.0.2 open-file assessment](evidence/akasia-assessment/README.md)), is dropped: its repository is no longer available, and Intos replaces it. `EXPERIMENTAL_FONT_CANDIDATES` now records Microsoft's Selawik, measured for Segoe UI on 2026-09-29 and rejected: 0.16% mean and 2.5% maximum in regular, no italic faces, 349 code points, lowercase 4.8% shorter. The acceptance rules for replacement fonts are in the [licensing table](programs/font-fidelity-everywhere/font-licensing.md#replacement-font-acceptance-rules).\n\nAn original OPF font project is technically feasible: independently designed or suitably open-licensed glyph outlines can be fitted to target advance widths, placement, vertical metrics, and shaping behavior. A successful font needs a reproducible source build, provenance, style/coverage tests, visual review, and cross-renderer conformance. Matching bounding boxes alone is insufficient: [OpenType horizontal metrics](https://learn.microsoft.com/en-us/typography/opentype/spec/hmtx) and [glyph positioning](https://learn.microsoft.com/en-us/typography/opentype/spec/gpos) jointly control text placement. Universal pixel identity across rasterizers is not the acceptance criterion; measured layout preservation over an explicit test matrix is.\n\n## Verification and remaining work\n\nScript fonts (CJK, Arabic, Hebrew, Indic, Thai, Khmer, Myanmar and the rest) have their own shaping corpora and per-family qualification (FF-44, RR-17): see [script-corpora.md](programs/font-fidelity-everywhere/script-corpora.md). It records, per script, glyph coverage, fontkit against HarfBuzz and Chromium, the installed originals measured in place, and the known limits (fontkit has no Myanmar shaper; the PNG path of resvg-js mis-shapes the Indic scripts, Thai, Lao, Khmer and Myanmar).\n\n### Arabic Typesetting and Traditional Arabic: line-width residual (opf#523)\n\nNeither family is bundled; both preview and compose with Noto Naskh Arabic (OFL-1.1). Arabic Typesetting carries the RR-38 `sizeAdjust` of 0.64, and Traditional Arabic has none. Native PowerPoint draws lines in both families **narrower** than the shared composition measures them. This is a known, accepted residual.\n\n**Measured.** All widths are PowerPoint 365 `TextRange.Lines` bounds or ink, against the composed line.\n\n| Family | Native lines | Native / composed width | Largest gap |\n| --- | --- | --- | --- |\n| Arabic Typesetting (proxy at 0.64) | 16, in [RR-59](evidence/rr-59-native-20261008/README.md) and [FF-46 set 2](evidence/ff-46-documented-visual-native-20261005-fonts/README.md) | 14 lines at 0.86 to 0.97 (headings 0.86 to 0.92, body 0.90 to 0.97); one FF-46 body line at 1.04; one FF-46 heading at 0.55; pooled 0.94 without that heading | 56.6 pt narrower on an 856 pt body line (284 pt on the 0.55 heading); 31 pt wider on the 1.04 line |\n| Traditional Arabic (proxy at 1) | 10, in the same two runs | 0.68 to 0.90, pooled 0.79 | 223.6 pt narrower (an 842 pt body line) |\n\nThe RR-38 probe agrees for Arabic Typesetting. Its 45-character line is 216.9, 289.0 and 386.1 pt at 18, 24 and 32 pt in PowerPoint, and composes at 222.1, 298.6 and 396.9 pt.\n\n**Why it exists.** Three reasons:\n- **No published metrics.** Microsoft's font pages for [Arabic Typesetting](https://learn.microsoft.com/en-us/typography/font-list/arabic-typesetting) and [Traditional Arabic](https://learn.microsoft.com/en-us/typography/font-list/traditional-arabic) give only the file names, styles, vendor, scripts and code pages. They publish no advance widths, `xAvgCharWidth` or vertical metrics.\n- **No metric-compatible open face.** None exists for either family: no fontconfig metric alias, no Croscore-style clone. OPF does not take metrics from the installed proprietary files.\n- **One factor cannot fit.** Per glyph, Noto Naskh Arabic's advances differ from these fonts' by more than any scale can absorb. Ligatures and letter widths vary by text. Arabic Typesetting also has only a regular face, so PowerPoint emboldens bold text synthetically, while the preview draws Noto Naskh Arabic Bold.\n\n**One factor per family does not fix it.** For Traditional Arabic, the native / Noto Naskh ratio of the 10 lines spans 0.68 to 0.90, so any one factor leaves large gaps:\n\n| Factor | Largest gap, composed wider | Largest gap, native wider |\n| --- | --- | --- |\n| 0.79 (the pooled ratio; the opf#361 proposal) | 47 pt | 48 pt |\n| 0.90 | 139 pt | none |\n\nLatin text in a deck that uses the Traditional Arabic scheme also draws in Noto Naskh Arabic's Latin, where the policy measures Traditional Arabic at 0.86 of the replacement's width. At 0.79, those lines would run about 9 percent past their composed width in PowerPoint. Arabic Typesetting's 0.64 already sits near the upper edge of its native ratios. A lower factor would let more lines run past their boxes.\n\n**Practical impact.**\n- **Line counts agree.** The PPTX export writes each composed line as its own text box, and PowerPoint does not re-wrap it. Line counts, the order of lines and the right edges of right-to-left lines therefore agree: within 0.13 pt in RR-59.\n- **Left edges differ.** The left edge of a right-to-left line (the right edge of a left-to-right one) sits closer to the text in PowerPoint. The same text could fit in fewer lines natively than the preview shows.\n- **Overhang is rare.** Because the proxy errs wide, a Traditional Arabic line keeps slack in its box. For Arabic Typesetting, an occasional line runs a few percent past its composed width: 3.78 pt past its box in RR-59 `ar-run-in-en`, not clipped.\n- **Wrapping can differ near the edge.** Text that PowerPoint lays out itself wraps where the real font's widths put it, which can differ from the preview. That covers a line box retyped in PowerPoint, and text another application re-flows.\n\nFor layouts that must wrap exactly, choose a family with a metric replacement, or supply licensed font files through `loadFonts({faces})`.\n\n`pnpm test:fonts` checks that editor and SVG geometry match and that every native PPTX text box has the same coordinates and measured line breaks. With opf-pptx FF-31 (opf-pptx#63), export names the chosen family, not the preview substitute. It writes artifacts to `artifacts/fonts/`. A real-browser check of the same Roboto run measured 324.032 pixels versus the font engine's 324.170 pixels at 25 pixels, a difference of 0.138 pixels. These are measured tolerances, not a promise of pixel identity.\n\nPPTX records the chosen font family (FF-31); it never records a preview replacement and never embeds a proprietary font binary. PowerPoint still needs those fonts installed, through Office, the OS or Microsoft 365 cloud fonts, or it substitutes them. Line height remains the shared 1.22 multiplier, rather than a complete ascent/descent model. Rich-text font overrides, mixed-script fallback and bidi layout, specialized payload internals, and native font embedding remain active fidelity work. Passing a width provider does not remove those limits.\n"
110
110
  },
111
111
  {
112
112
  "slug": "footnotes-citations-captions",
@@ -274,7 +274,7 @@ var docsData = Object.freeze([
274
274
  "slug": "release-process",
275
275
  "file": "docs/release-process.md",
276
276
  "title": "OPF Release Process",
277
- "markdown": "# OPF Release Process\n\nUse Node 24 (`24.x`) for all future source, candidate and registry verification.\nThe packages declare the open-ended `engines.node` `>=22` (RR-20, tested on Node 22, 24 and 26 by the `node-range`\nCI jobs, which run in the merge queue and on pull requests labelled `full-ci`); a closed range such as `24.x` makes npm on any other Node silently install an older release, and\n`pnpm check:engines-range` fails on one, including in a lockfile that pins a release made after the fix.\nThe next releases must document the [Node 24 migration](migrations/node24.md)\nand use new versions. Historical dual-runtime release records remain unchanged.\n\nThis document is the release runbook for the public JavaScript package,\n[`@openpresentation/opf`](https://www.npmjs.com/package/@openpresentation/opf).\n\nThe canonical release path is:\n\n1. Merge the release commit to `main`.\n2. Push a semver tag whose name matches the package version.\n3. Let GitHub Actions publish to npm through npm trusted publishing.\n4. Verify npm and the GitHub Release the publish workflow creates from the changelog section.\n\n## Agent authorization and coordinated release order\n\nThe owner authorized agents to prepare and publish npm releases on 2026-09-29\nand for future releases whenever a release is required. This does not waive any\ngate: a release still needs a merged release-prep PR, green required checks and\nthe verification below. Agents keep publishing on the trusted-publishing\nworkflows (GitHub Actions OIDC with `--provenance`); a local `npm publish` is a\nfallback only when a workflow cannot run, and it loses the provenance\nattestation that every previous version carries.\n\nThe engine packages depend on each other, so publish in this order and wait for\neach version to appear on the registry before starting the next:\n\n1. `@openpresentation/opf` (this repository, `opf-vX.Y.Z` tag).\n2. `@openpresentation/opf-render` (`opf-render-vX.Y.Z` tag) and\n `@openpresentation/opf-pptx` (`opf-pptx-vX.Y.Z` tag). Both depend on core; PPTX\n also devDepends on the renderer, so publish the renderer first.\n3. `@openpresentation/opf-editor` (`opf-editor-vX.Y.Z` tag), which depends on core\n and peers/devDepends on the renderer and PPTX.\n\nEach sibling's release-prep PR raises its dependency floors to the just-published\nversions. Its lockfile can only be refreshed after the upstream version exists on\nnpm (`npm install --package-lock-only`), so merge sibling release PRs only after\nthe upstream publish. `@openpresentation/cli` depends on core (a regular dependency; its floor is raised by the release-prep PR like the others, the commands run that core's Node engine) and is released\nseparately by `cli-publish.yml` (`cli-vX.Y.Z`) when a CLI release is needed.\n\nThe CLI release gate includes the installed-package regression `packages/cli/test/installed-regression.mjs` (RR-59, [opf#476](https://github.com/OpenPresentation/opf/issues/476)). It drives the installed `opf` binary beside the published opf-render and opf-pptx, the render font packages and the Noto JP and SC script packages, with no network after the install (a preload refuses every connection and fetch, and a local server that the image URLs point at counts zero requests): `opf convert deck.opf.json deck.svg` embeds `Noto Sans JP` 400 and 700 for a `ja-JP` deck, nothing under `--text system` and no CJK face for a Latin-only deck; `opf convert deck.opf.json deck.pptx` of a deck with an `https:` content image (an image block and a quote photo) exits 0 with a `pptx/unresolved-asset` warning at the slide path and the placeholder, exits 1 and writes no file under `--fail-on warning`, and exports a native picture for an embedded or local PNG under `--fail-on warning`. It runs in `pnpm test:cli:packed:peers` (the candidate CLI and core tarballs; OPF CI, the Windows and engines-range CLI jobs, and `cli-publish.yml` before `npm publish`) and in `pnpm test:registry-ecosystem` (the published set of `release-plan.json`, from plan 0.16).\n\nA sibling pull request that needs a core that is not on npm yet (a breaking core release and its sibling PRs, `Depends-On: OpenPresentation/opf#N`) declares it in its `package.json` as `\"opf\": { \"requiresUnreleasedCore\": \"X.Y.Z\" }` (RR-55). Its CI then skips only the packed install against published dependencies (`npm run test:packed`, with a `::notice::`) while the installed published `@openpresentation/opf` is lower than `X.Y.Z`; every linked-ecosystem step still runs. The field is for pull requests only: `release-train.mjs prep` deletes it (and refuses a field above the core of the train), and `plan` flags a release commit whose `package.json` still carries it.\n\nRelease-prep PRs contain only version bumps, changelog entries, dependency ranges\nand lockfile changes (plus current-instruction docs). The changelog entries are\nnot written by hand: every change adds a fragment `changes/<slug>.md` in its own\nPR (RR-46, [changes/README.md](../changes/README.md)), and the release-prep PR\nruns the assembler, which moves the fragments into the new release section of\n`CHANGELOG.md` and deletes them. Each sibling repository has the same\n`changes/` directory and script:\n\n```sh\n# core (packages are named in each fragment: opf = CHANGELOG.md, cli = packages/cli/CHANGELOG.md)\nnode scripts/changelog-fragments.mjs assemble --version X.Y.Z --package opf --date YYYY-MM-DD\nnode scripts/changelog-fragments.mjs assemble --version A.B.C --package cli # only when the CLI is released\n# opf-render, opf-pptx, opf-editor\nnode scripts/changelog-fragments.mjs assemble --version X.Y.Z --date YYYY-MM-DD [--summary \"Patch release: ...\"]\n```\n\nUse `--dry-run` to preview, and check that no `changes/*.md` other than\n`README.md` remains for the released package before opening the PR. After the whole set is on\nthe registry, a follow-up docs change updates `release-plan.json`, the\ncompatibility matrix and the quickstart to the published set, and the gallery\nconsumer dependencies are bumped.\n\n## Release train (scripted, RR-51)\n\n`scripts/release-train.mjs` runs the coordinated release above in lockstep order. It is run by the supervisor with\ntheir own `gh` login (or `GH_TOKEN`); every command is a dry run unless `--execute` is given. It only reads, opens\nrelease-prep pull requests and creates tags: it never merges a pull request and never publishes. Each package is still\npublished by its own repository's trusted-publishing workflow (OIDC, `--provenance`) when its tag appears.\n\nName the versions of the train, any subset: `--core X.Y.Z --render X.Y.Z --pptx X.Y.Z --editor X.Y.Z --cli X.Y.Z`.\nThe order is always core, then opf-render, then opf-pptx, then opf-editor and the CLI.\n\n```sh\n# 1. What is missing (read only; exit 1 until every package is on npm)\nnode scripts/release-train.mjs plan --core 0.12.1 --render 0.12.1 --pptx 0.12.3 --editor 0.11.3 --cli 0.10.1\n\n# 2. The release-prep PR of the next package, once its upstream is on npm (dry run first: a scratch clone and the diff)\nnode scripts/release-train.mjs prep render --core 0.12.1 --render 0.12.1 [--item RR-nn]\nnode scripts/release-train.mjs prep render --core 0.12.1 --render 0.12.1 --item RR-nn --execute\n\n# 3. After a person merged it with green CI: tag the release commit, wait for the publish run and npm, verify\nnode scripts/release-train.mjs tag render --core 0.12.1 --render 0.12.1 --execute\n\n# 4. Verify any published version (also run by `tag` and `run`)\nnode scripts/release-train.mjs verify @openpresentation/opf-render@0.12.1\n\n# Or the whole sequence: it stops at the first step that needs a person and prints the command to resume\nnode scripts/release-train.mjs run --core 0.12.1 --render 0.12.1 --pptx 0.12.3 --editor 0.11.3 --cli 0.10.1 --execute\n```\n\nWhat each step checks:\n\n- `plan` reads, per package: whether the version is already on npm (then it is only verified), the version in the\n manifest at `main`'s head, the release commit (the commit that set the version) and its merged release-prep PR, the\n required checks on the release commit (the `main` ruleset's required checks; for a repository without a ruleset,\n every reported check), the tag, whether the upstream versions of the train are on npm, and the dependency floors.\n It flags every sibling whose `@openpresentation/opf` floor stays below a new core in the train, with hints from core's\n fragments or changelog section: the tool cannot know whether a core release moves geometry, so the release owner\n decides whether the lockstep rule below applies (flag, never decided).\n- `prep` refuses until every upstream version of the train is on npm. In a scratch clone of `main` it bumps the\n version, runs `node scripts/changelog-fragments.mjs assemble --version X.Y.Z --date <today>` (with `--package opf` or\n `--package cli` in core, `--summary` when given), raises the floors that name a package of the train (caret and\n tilde ranges keep their operator; exact devDependency pins move to the exact version; `workspace:*` is untouched;\n for the CLI, `PEER_RANGES` in `packages/cli/src/peers.ts` follows its peer ranges) and refreshes the lockfile\n (`npm install --package-lock-only` in the siblings, `pnpm install --lockfile-only` in core). It fails if a fragment\n for the package is left or if anything other than the manifest, changelog, fragments, lockfile and peers file\n changed. The PR body lists README lines that name the previous version for a person to review; prose is not\n rewritten. It also deletes `opf.requiresUnreleasedCore` from the manifest when the sibling declares it (see above; `plan` flags a release commit that keeps it). Branch `codex/release-<package>-<x-y-z>` unless `--branch` is given. An open release-prep PR (found by\n branch or by a \"release <package> X.Y.Z\" title) or a merged one is detected and nothing is written.\n- `tag` re-verifies right before tagging: the version at the release commit, that the commit is on `main`, green\n required checks, every upstream of the train on npm and every runtime floor naming a version npm has. It then creates\n `refs/tags/<prefix>X.Y.Z` (`opf-v`, `opf-render-v`, `opf-pptx-v`, `opf-editor-v`, `cli-v`) with\n `POST /repos/{repo}/git/refs` on the release commit (checks that are still running, or not reported yet right after\n the merge, stop it unless `--checks-wait-minutes N` is given: it then polls them every `--poll-seconds` for up to N\n minutes, and a red check still stops it at once), polls that repository's publish workflow run for the tag\n (default every 120 s, up to 90 min), polls npm until the version is visible, and runs `verify`. A tag that already\n exists on the release commit is not created again; one on another commit stops the train. A failed publish run stops\n with its link: never move or re-push the tag; re-run a transient failure (`gh run rerun <id> --failed`), fix a real\n one on `main` with a new version.\n- `verify` checks the registry manifest, that `gitHead` equals the tagged commit (and that the commit is on `main` and\n carries the version), `dist.attestations` with the SLSA v1 provenance predicate, the provenance statement itself\n (built by `.github/workflows/<publish workflow>` of the package's repository on `refs/tags/<tag>` from the tagged\n commit, subject digest equal to `dist.integrity`), `npm audit signatures --include-attestations` in a scratch\n project that installs exactly that version (no invalid or missing signatures; the package has a verified\n attestation), and the GitHub release for the tag (every lockstep package's publish workflow creates one from 0.18\n on, RR-20; a gallery release has none). A release that is not \"Latest\" passes: the CLI's is created with\n `--latest=false` because core owns \"Latest\" in `OpenPresentation/opf`. The dist-tag is reported for information.\n - Propagation: right after a publish, npm serves the packument (with `dist.attestations.url`) minutes before the\n attestation bundle (HTTP 404 `{\"error\":\"Not found\"}`) and before a fresh `npm install` can resolve the version\n (`notarget`, \"No matching version found\"); the first live use, core 0.12.1, failed `verify` on exactly these two\n for 2-4 minutes. Only those two checks wait: `tag` and `run` retry the bundle fetch and the scratch install every\n 30 s (`--attest-poll-seconds`) for up to 15 min (`--attest-wait-minutes`; 0 turns the wait off) and say what they\n wait for in the log. Any answer that is not \"not yet there\" fails at once without waiting: a bundle that is\n present but has no SLSA provenance or names another repository, workflow, ref, commit or digest, an invalid or\n missing signature, any other install error. If the wait runs out, the check fails with \"still not propagated\n after N min\". Standalone `verify` keeps failing fast; `verify <package>@X.Y.Z --wait <minutes>` gives it the same\n retry (for example right after a publish run you started by hand).\n- `run` repeats `plan` per package in order and does the next step: verify what is on npm, stop at an open release-prep\n PR, open a missing one (`prep`), wait for pending checks on a merged release commit, then `tag`. Re-running it after\n a stop skips every step already done; a version already on npm is never published again.\n\nThe follow-up docs PR (`release-plan.json`, the compatibility matrix and the quickstart) and the gallery consumer bumps\nstay by hand after the train.\n\n`.github/workflows/release-train.yml` is the same script as a `workflow_dispatch` (inputs: the versions and `mode`).\nUntil the GitHub App of [opf#298](https://github.com/OpenPresentation/opf/issues/298) exists it is plan-only: tags\npushed with a workflow's `GITHUB_TOKEN` start no other workflow (so the publish workflows would never run), and\n`GITHUB_TOKEN` cannot push branches or open pull requests in the sibling repositories, so `mode: execute` fails at\nonce. With the App (`ECOSYSTEM_APP_ID` variable and `ECOSYSTEM_APP_PRIVATE_KEY` secret, the roller's App) `mode:\nexecute` runs `run --execute` with the App's token.\n\nThe steps by hand below remain the fallback when the script cannot run.\n\n## The gallery lane (RR-78)\n\n`@openpresentation/gallery` (the pptx.gallery catalog records) is released from its own repository,\n[OpenPresentation/gallery](https://github.com/OpenPresentation/gallery), on its own version line, outside the lockstep\ntrain: `release-train.mjs` has it in `INDEPENDENT_PACKAGES` (repository `gallery`, tags `vX.Y.Z`, workflow\n`gallery-publish.yml`), never in `PACKAGES`, so no train plans, preps or tags it, and `release-plan.json` lists it under\n`independentPackages`. A gallery release needs no core release, and a core release does not release the gallery.\n\n- **Versions.** New records are a minor release. Removing or renaming an id, changing how an existing record draws, or\n a change that needs a newer record schema (`opf.catalogSchema`) is a major release. The gallery repository's\n pixel-stability check (`npm run check:stability`, on every pull request there and before every publish) fails a\n minor or patch release that changes an existing record's drawing or removes an id.\n- **Release and publish.** In the gallery repository: a pull request that sets the version and the `CHANGELOG.md`\n section, then a `vX.Y.Z` tag on main. Its `gallery-publish.yml` refuses a tag that is not `v` plus the version, a\n tag off main and a `repository` other than OpenPresentation/gallery, runs every check, and publishes with\n `npm publish --access public --provenance` (see its CONTRIBUTING.md).\n- **Verify.** `node scripts/release-train.mjs verify gallery@X.Y.Z` here (tag `vX.Y.Z` and gitHead in\n OpenPresentation/gallery, provenance built by `gallery-publish.yml` on `refs/tags/vX.Y.Z`, signatures). The workflow\n creates no GitHub release.\n- **Trusted publisher on npm** (`@openpresentation/gallery`, Settings, Trusted publishing): GitHub Actions,\n organization `OpenPresentation`, repository `gallery`, workflow filename `gallery-publish.yml`, no environment.\n- **First release.** `1.0.0` is the first version published from OpenPresentation/gallery; `0.0.1` was a placeholder\n published by hand. Against 0.0.1 the stability check runs as a major release and reports no changed record.\n\nCore depends on `@openpresentation/gallery@^1` from OPF 0.19 (its Node defaults and the CLI register `gallery`); a\ncore release then needs the gallery version its range names on npm first, as for any dependency.\n\n## Geometry-moving core releases: lockstep floors\n\nCore composition changes that move geometry (for example opf#169 cover centering) make the preview and the PPTX export drift when `@openpresentation/opf-render` and `@openpresentation/opf-pptx` resolve different core versions (measured: 186-300 pt title offsets on covers).\n\nRule: when a core release contains composition or geometry changes, the same release train must raise BOTH the renderer's and PPTX's core floor (`dependencies` and, where present, `peerDependencies`) to that core version, publish them together, and raise the editor's floor too. Do not release core alone and leave a sibling on the older floor.\n\nThe parity harness must always run with `--import <opf>/scripts/register-local-opf.mjs` (as `run.ps1` does) so every engine shares one core.\n\n## Release Preconditions\n\nBefore tagging, confirm that the release commit on `main` already contains:\n\n- `packages/javascript/package.json` with the intended version.\n- `CHANGELOG.md` with the matching release section (assembled from `changes/`, see above).\n- Passing `OPF CI` on the release commit.\n\nThe publish workflow validates the tag name against\n`packages/javascript/package.json`, so the tag must point at the release commit.\n\n## Tag And Publish\n\nUse the `opf-vX.Y.Z` tag form for the package release:\n\n```sh\ngit checkout main\ngit pull origin main\ngrep '\"version\"' packages/javascript/package.json\ngit tag opf-vX.Y.Z\ngit push origin opf-vX.Y.Z\n```\n\nFor example, version `0.3.0` used:\n\n```sh\ngit tag opf-v0.3.0\ngit push origin opf-v0.3.0\n```\n\nPushing the tag triggers `.github/workflows/npm-publish.yml`. The workflow:\n\n- runs on tags matching `opf-v*` or `@openpresentation/opf@v*`\n- installs dependencies with pnpm on Node 24\n- verifies the tag matches `packages/javascript/package.json`\n- runs typecheck and tests\n- runs the npm package dry-run check\n- publishes from `packages/javascript` with `npm publish --access public --provenance`\n\nDo not rerun a successful publish for the same version. npm package versions are\nimmutable; a second publish for an already-published version should fail.\n\n## Trusted Publishing\n\nnpm publishing is configured to use GitHub Actions OIDC trusted publishing, not\na long-lived npm token.\n\nExpected npm package trusted-publisher settings:\n\n| Setting | Value |\n|---|---|\n| Package | `@openpresentation/opf` |\n| Publisher | GitHub Actions |\n| Organization/repository | `OpenPresentation/opf` |\n| Workflow filename | `npm-publish.yml` |\n| Environment | empty, unless the workflow is later moved behind a GitHub Environment |\n| Permission | `npm publish` |\n\nExpected workflow settings:\n\n```yaml\npermissions:\n contents: read\n id-token: write\n```\n\nThe publish step should not set `NODE_AUTH_TOKEN`:\n\n```yaml\n- name: Publish to npm\n working-directory: packages/javascript\n run: npm publish --access public\n```\n\nIf a future release fails with npm authentication errors, check the npm\ntrusted-publisher settings first. Only use an `NPM_TOKEN` repository secret as a\ntemporary fallback, and remove or revoke it once OIDC publishing works again.\n\n## Verify The Release\n\n`node scripts/release-train.mjs verify <package>@X.Y.Z` runs every registry check of this section and the provenance\nchecks (see \"Release train\" above). By hand, after the workflow completes, verify npm:\n\n```sh\nnpm view @openpresentation/opf version\n```\n\nThe output should equal the package version that was tagged.\n\nSpot-check the validator API from a clean project or temporary directory:\n\n```sh\nnpm install @openpresentation/opf@X.Y.Z\nnode --input-type=module -e \"import {validate} from '@openpresentation/opf'; console.log(validate({name:'t', narrative:'not-a-real-id', slides:[{title:'t'}]}, {only:['format','references']}).findings)\"\n```\n\nThe expected result is one `opf/unresolved-reference` warning for the narrative (no catalog is registered).\n\n## GitHub Release Notes\n\nFrom 0.18 on, every lockstep package's tag workflow ends in a `release` job that creates a GitHub Release after the npm\npublish (RR-20). The job holds `contents: write`; the publish job keeps only `id-token: write` for provenance. It takes\nthe notes from the matching `## X.Y.Z` section of the package's changelog and fails when the section is missing.\n\n| Package | Repository, workflow | Tag | Title | Changelog | Latest |\n| --- | --- | --- | --- | --- | --- |\n| `@openpresentation/opf` | `opf`, `npm-publish.yml` | `opf-vX.Y.Z` | `@openpresentation/opf X.Y.Z` | `CHANGELOG.md` | yes |\n| `@openpresentation/opf-render` | `opf-render`, `npm-publish.yml` | `opf-render-vX.Y.Z` | `@openpresentation/opf-render X.Y.Z` | `CHANGELOG.md` | yes |\n| `@openpresentation/opf-pptx` | `opf-pptx`, `release.yml` | `opf-pptx-vX.Y.Z` | `@openpresentation/opf-pptx X.Y.Z` | `CHANGELOG.md` | yes |\n| `@openpresentation/opf-editor` | `opf-editor`, `release.yml` | `opf-editor-vX.Y.Z` | `@openpresentation/opf-editor X.Y.Z` | `CHANGELOG.md` | yes |\n| `@openpresentation/cli` | `opf`, `cli-publish.yml` | `cli-vX.Y.Z` | `@openpresentation/cli X.Y.Z` | `packages/cli/CHANGELOG.md` | no (core owns it) |\n\nReleases before 0.18 are not backfilled. `verify` requires the release for each of these packages. If release creation\nfailed, rerun the failed `release` job (it keeps an existing release and its notes), or create the missing release for\nthe existing tag, for example:\n\n```sh\ngh release create opf-render-vX.Y.Z \\\n --repo OpenPresentation/opf-render \\\n --title '@openpresentation/opf-render X.Y.Z' \\\n --notes-file /path/to/release-notes.md \\\n --verify-tag --latest\n```\n\nUse the matching `## X.Y.Z` section of the changelog as the release notes.\n\n## Troubleshooting\n\nIf the tag/version check fails, the tag does not point at the release commit or\nthe tag name does not match `packages/javascript/package.json`. Delete the bad\nlocal and remote tag, fetch `main`, and tag the correct commit:\n\n```sh\ngit push origin :refs/tags/opf-vX.Y.Z\ngit tag -d opf-vX.Y.Z\ngit checkout main\ngit pull origin main\ngit tag opf-vX.Y.Z\ngit push origin opf-vX.Y.Z\n```\n\nIf tests fail, fix the code on `main`, create a new release commit, and move the\ntag only if npm has not already published that version.\n\nIf npm publish fails with `ENEEDAUTH`, confirm:\n\n- npm has a trusted publisher for `OpenPresentation/opf`\n- the trusted publisher uses workflow filename `npm-publish.yml`\n- `.github/workflows/npm-publish.yml` has `id-token: write`\n- the publish job is running on a modern Node/npm toolchain\n\nIf npm publish fails after the version is already present on npm, do not retry\nthe same publish. Verify the package and treat the failure as a duplicate\npublish attempt.\n"
277
+ "markdown": "# OPF Release Process\n\nUse Node 24 (`24.x`) for all future source, candidate and registry verification.\nThe packages declare the open-ended `engines.node` `>=22` (RR-20, tested on Node 22, 24 and 26 by the `node-range`\nCI jobs, which run in the merge queue and on pull requests labelled `full-ci`); a closed range such as `24.x` makes npm on any other Node silently install an older release, and\n`pnpm check:engines-range` fails on one, including in a lockfile that pins a release made after the fix.\nThe next releases must document the [Node 24 migration](migrations/node24.md)\nand use new versions. Historical dual-runtime release records remain unchanged.\n\nThis document is the release runbook for the public JavaScript package,\n[`@openpresentation/opf`](https://www.npmjs.com/package/@openpresentation/opf).\n\nThe canonical release path is:\n\n1. Merge the release commit to `main`.\n2. Push a semver tag whose name matches the package version.\n3. Let GitHub Actions publish to npm through npm trusted publishing.\n4. Verify npm and the GitHub Release the publish workflow creates from the changelog section.\n\n## Agent authorization and coordinated release order\n\nThe owner authorized agents to prepare and publish npm releases on 2026-09-29\nand for future releases whenever a release is required. This does not waive any\ngate: a release still needs a merged release-prep PR, green required checks and\nthe verification below. Agents keep publishing on the trusted-publishing\nworkflows (GitHub Actions OIDC with `--provenance`); a local `npm publish` is a\nfallback only when a workflow cannot run, and it loses the provenance\nattestation that every previous version carries.\n\nThe engine packages depend on each other, so publish in this order and wait for\neach version to appear on the registry before starting the next:\n\n1. `@openpresentation/opf` (this repository, `opf-vX.Y.Z` tag).\n2. `@openpresentation/opf-render` (`opf-render-vX.Y.Z` tag) and\n `@openpresentation/opf-pptx` (`opf-pptx-vX.Y.Z` tag). Both depend on core; PPTX\n also devDepends on the renderer, so publish the renderer first.\n3. `@openpresentation/opf-editor` (`opf-editor-vX.Y.Z` tag), which depends on core\n and peers/devDepends on the renderer and PPTX.\n\nEach sibling's release-prep PR raises its dependency floors to the just-published\nversions. Its lockfile can only be refreshed after the upstream version exists on\nnpm (`npm install --package-lock-only`), so merge sibling release PRs only after\nthe upstream publish. `@openpresentation/cli` depends on core (a regular dependency; its floor is raised by the release-prep PR like the others, the commands run that core's Node engine) and is released\nseparately by `cli-publish.yml` (`cli-vX.Y.Z`) when a CLI release is needed.\n\nThe CLI release gate includes the installed-package regression `packages/cli/test/installed-regression.mjs` (RR-59, [opf#476](https://github.com/OpenPresentation/opf/issues/476)). It drives the installed `opf` binary beside the published opf-render and opf-pptx, the render font packages and the Noto JP and SC script packages, with no network after the install (a preload refuses every connection and fetch, and a local server that the image URLs point at counts zero requests): `opf convert deck.opf.json deck.svg` embeds `Noto Sans JP` 400 and 700 for a `ja-JP` deck, nothing under `--text system` and no CJK face for a Latin-only deck; `opf convert deck.opf.json deck.pptx` of a deck with an `https:` content image (an image block and a quote photo) exits 0 with a `pptx/unresolved-asset` warning at the slide path and the placeholder, exits 1 and writes no file under `--fail-on warning`, and exports a native picture for an embedded or local PNG under `--fail-on warning`. It runs in `pnpm test:cli:packed:peers` (the candidate CLI and core tarballs; OPF CI, the Windows and engines-range CLI jobs, and `cli-publish.yml` before `npm publish`) and in `pnpm test:registry-ecosystem` (the published set of `release-plan.json`, from plan 0.16).\n\nA sibling pull request that needs a core that is not on npm yet (a breaking core release and its sibling PRs, `Depends-On: OpenPresentation/opf#N`) declares it in its `package.json` as `\"opf\": { \"requiresUnreleasedCore\": \"X.Y.Z\" }` (RR-55). Its CI then skips only the packed install against published dependencies (`npm run test:packed`, with a `::notice::`) while the installed published `@openpresentation/opf` is lower than `X.Y.Z`; every linked-ecosystem step still runs. The field is for pull requests only: `release-train.mjs prep` deletes it (and refuses a field above the core of the train), and `plan` flags a release commit whose `package.json` still carries it.\n\nRelease-prep PRs contain only version bumps, changelog entries, dependency ranges\nand lockfile changes (plus current-instruction docs). The changelog entries are\nnot written by hand: every change adds a fragment `changes/<slug>.md` in its own\nPR (RR-46, [changes/README.md](../changes/README.md)), and the release-prep PR\nruns the assembler, which moves the fragments into the new release section of\n`CHANGELOG.md` and deletes them. Each sibling repository has the same\n`changes/` directory and script:\n\n```sh\n# core (packages are named in each fragment: opf = CHANGELOG.md, cli = packages/cli/CHANGELOG.md)\nnode scripts/changelog-fragments.mjs assemble --version X.Y.Z --package opf --date YYYY-MM-DD\nnode scripts/changelog-fragments.mjs assemble --version A.B.C --package cli # only when the CLI is released\n# opf-render, opf-pptx, opf-editor\nnode scripts/changelog-fragments.mjs assemble --version X.Y.Z --date YYYY-MM-DD [--summary \"Patch release: ...\"]\n```\n\nUse `--dry-run` to preview, and check that no `changes/*.md` other than\n`README.md` remains for the released package before opening the PR. After the whole set is on\nthe registry, a follow-up docs change updates `release-plan.json`, the\ncompatibility matrix and the quickstart to the published set, and the gallery\nconsumer dependencies are bumped.\n\n## Release train (scripted, RR-51)\n\n`scripts/release-train.mjs` runs the coordinated release above in lockstep order. It is run by the supervisor with\ntheir own `gh` login (or `GH_TOKEN`); every command is a dry run unless `--execute` is given. It only reads, opens\nrelease-prep pull requests and creates tags: it never merges a pull request and never publishes. Each package is still\npublished by its own repository's trusted-publishing workflow (OIDC, `--provenance`) when its tag appears.\n\nName the versions of the train, any subset: `--core X.Y.Z --render X.Y.Z --pptx X.Y.Z --editor X.Y.Z --cli X.Y.Z`.\nThe order is always core, then opf-render, then opf-pptx, then opf-editor and the CLI.\n\n```sh\n# 1. What is missing (read only; exit 1 until every package is on npm)\nnode scripts/release-train.mjs plan --core 0.12.1 --render 0.12.1 --pptx 0.12.3 --editor 0.11.3 --cli 0.10.1\n\n# 2. The release-prep PR of the next package, once its upstream is on npm (dry run first: a scratch clone and the diff)\nnode scripts/release-train.mjs prep render --core 0.12.1 --render 0.12.1 [--item RR-nn]\nnode scripts/release-train.mjs prep render --core 0.12.1 --render 0.12.1 --item RR-nn --execute\n\n# 3. After a person merged it with green CI: tag the release commit, wait for the publish run and npm, verify\nnode scripts/release-train.mjs tag render --core 0.12.1 --render 0.12.1 --execute\n\n# 4. Verify any published version (also run by `tag` and `run`)\nnode scripts/release-train.mjs verify @openpresentation/opf-render@0.12.1\n\n# Or the whole sequence: it stops at the first step that needs a person and prints the command to resume\nnode scripts/release-train.mjs run --core 0.12.1 --render 0.12.1 --pptx 0.12.3 --editor 0.11.3 --cli 0.10.1 --execute\n```\n\nWhat each step checks:\n\n- `plan` reads, per package: whether the version is already on npm (then it is only verified), the version in the\n manifest at `main`'s head, the release commit (the commit that set the version) and its merged release-prep PR, the\n required checks on the release commit (the `main` ruleset's required checks; for a repository without a ruleset,\n every reported check), the tag, whether the upstream versions of the train are on npm, and the dependency floors.\n It flags every sibling whose `@openpresentation/opf` floor stays below a new core in the train, with hints from core's\n fragments or changelog section: the tool cannot know whether a core release moves geometry, so the release owner\n decides whether the lockstep rule below applies (flag, never decided).\n- `prep` refuses until every upstream version of the train is on npm. In a scratch clone of `main` it bumps the\n version, runs `node scripts/changelog-fragments.mjs assemble --version X.Y.Z --date <today>` (with `--package opf` or\n `--package cli` in core, `--summary` when given), raises the floors that name a package of the train (caret and\n tilde ranges keep their operator; exact devDependency pins move to the exact version; `workspace:*` is untouched;\n for CLI preparation, already-compatible patch peer ranges stay unchanged while development pins advance;\n core runtime files are never edited by CLI prep) and refreshes the lockfile\n (`npm install --package-lock-only` in the siblings, `pnpm install --lockfile-only` in core). It fails if a fragment\n for the package is left or if anything other than the manifest, changelog, fragments, lockfile and peers file\n changed. The PR body lists README lines that name the previous version for a person to review; prose is not\n rewritten. It also deletes `opf.requiresUnreleasedCore` from the manifest when the sibling declares it (see above; `plan` flags a release commit that keeps it). Branch `codex/release-<package>-<x-y-z>` unless `--branch` is given. An open release-prep PR (found by\n branch or by a \"release <package> X.Y.Z\" title) or a merged one is detected and nothing is written.\n- `tag` re-verifies right before tagging: the version at the release commit, that the commit is on `main`, green\n required checks, every upstream of the train on npm and every runtime floor naming a version npm has. It then creates\n `refs/tags/<prefix>X.Y.Z` (`opf-v`, `opf-render-v`, `opf-pptx-v`, `opf-editor-v`, `cli-v`) with\n `POST /repos/{repo}/git/refs` on the release commit (checks that are still running, or not reported yet right after\n the merge, stop it unless `--checks-wait-minutes N` is given: it then polls them every `--poll-seconds` for up to N\n minutes, and a red check still stops it at once), polls that repository's publish workflow run for the tag\n (default every 120 s, up to 90 min), polls npm until the version is visible, and runs `verify`. A tag that already\n exists on the release commit is not created again; one on another commit stops the train. A failed publish run stops\n with its link: never move or re-push the tag; re-run a transient failure (`gh run rerun <id> --failed`), fix a real\n one on `main` with a new version.\n- `verify` checks the registry manifest, that `gitHead` equals the tagged commit (and that the commit is on `main` and\n carries the version), `dist.attestations` with the SLSA v1 provenance predicate, the provenance statement itself\n (built by `.github/workflows/<publish workflow>` of the package's repository on `refs/tags/<tag>` from the tagged\n commit, subject digest equal to `dist.integrity`), `npm audit signatures --include-attestations` in a scratch\n project that installs exactly that version (no invalid or missing signatures; the package has a verified\n attestation), and the GitHub release for the tag (every lockstep package's publish workflow creates one from 0.18\n on, RR-20; a gallery release has none). A release that is not \"Latest\" passes: the CLI's is created with\n `--latest=false` because core owns \"Latest\" in `OpenPresentation/opf`. The dist-tag is reported for information.\n - Propagation: right after a publish, npm serves the packument (with `dist.attestations.url`) minutes before the\n attestation bundle (HTTP 404 `{\"error\":\"Not found\"}`) and before a fresh `npm install` can resolve the version\n (`notarget`, \"No matching version found\"); the first live use, core 0.12.1, failed `verify` on exactly these two\n for 2-4 minutes. Only those two checks wait: `tag` and `run` retry the bundle fetch and the scratch install every\n 30 s (`--attest-poll-seconds`) for up to 15 min (`--attest-wait-minutes`; 0 turns the wait off) and say what they\n wait for in the log. Any answer that is not \"not yet there\" fails at once without waiting: a bundle that is\n present but has no SLSA provenance or names another repository, workflow, ref, commit or digest, an invalid or\n missing signature, any other install error. If the wait runs out, the check fails with \"still not propagated\n after N min\". Standalone `verify` keeps failing fast; `verify <package>@X.Y.Z --wait <minutes>` gives it the same\n retry (for example right after a publish run you started by hand).\n- `run` repeats `plan` per package in order and does the next step: verify what is on npm, stop at an open release-prep\n PR, open a missing one (`prep`), wait for pending checks on a merged release commit, then `tag`. Re-running it after\n a stop skips every step already done; a version already on npm is never published again.\n\nThe follow-up docs PR (`release-plan.json`, the compatibility matrix and the quickstart) and the gallery consumer bumps\nstay by hand after the train.\n\n`.github/workflows/release-train.yml` is the same script as a `workflow_dispatch` (inputs: the versions and `mode`).\nUntil the GitHub App of [opf#298](https://github.com/OpenPresentation/opf/issues/298) exists it is plan-only: tags\npushed with a workflow's `GITHUB_TOKEN` start no other workflow (so the publish workflows would never run), and\n`GITHUB_TOKEN` cannot push branches or open pull requests in the sibling repositories, so `mode: execute` fails at\nonce. With the App (`ECOSYSTEM_APP_ID` variable and `ECOSYSTEM_APP_PRIVATE_KEY` secret, the roller's App) `mode:\nexecute` runs `run --execute` with the App's token.\n\nThe steps by hand below remain the fallback when the script cannot run.\n\n## The gallery lane (RR-78)\n\n`@openpresentation/gallery` (the pptx.gallery catalog records) is released from its own repository,\n[OpenPresentation/gallery](https://github.com/OpenPresentation/gallery), on its own version line, outside the lockstep\ntrain: `release-train.mjs` has it in `INDEPENDENT_PACKAGES` (repository `gallery`, tags `vX.Y.Z`, workflow\n`gallery-publish.yml`), never in `PACKAGES`, so no train plans, preps or tags it, and `release-plan.json` lists it under\n`independentPackages`. A gallery release needs no core release, and a core release does not release the gallery.\n\n- **Versions.** New records are a minor release. Removing or renaming an id, changing how an existing record draws, or\n a change that needs a newer record schema (`opf.catalogSchema`) is a major release. The gallery repository's\n pixel-stability check (`npm run check:stability`, on every pull request there and before every publish) fails a\n minor or patch release that changes an existing record's drawing or removes an id.\n- **Release and publish.** In the gallery repository: a pull request that sets the version and the `CHANGELOG.md`\n section, then a `vX.Y.Z` tag on main. Its `gallery-publish.yml` refuses a tag that is not `v` plus the version, a\n tag off main and a `repository` other than OpenPresentation/gallery, runs every check, and publishes with\n `npm publish --access public --provenance` (see its CONTRIBUTING.md).\n- **Verify.** `node scripts/release-train.mjs verify gallery@X.Y.Z` here (tag `vX.Y.Z` and gitHead in\n OpenPresentation/gallery, provenance built by `gallery-publish.yml` on `refs/tags/vX.Y.Z`, signatures). The workflow\n creates no GitHub release.\n- **Trusted publisher on npm** (`@openpresentation/gallery`, Settings, Trusted publishing): GitHub Actions,\n organization `OpenPresentation`, repository `gallery`, workflow filename `gallery-publish.yml`, no environment.\n- **First release.** `1.0.0` is the first version published from OpenPresentation/gallery; `0.0.1` was a placeholder\n published by hand. Against 0.0.1 the stability check runs as a major release and reports no changed record.\n\nCore depends on `@openpresentation/gallery@^1` from OPF 0.19 (its Node defaults and the CLI register `gallery`); a\ncore release then needs the gallery version its range names on npm first, as for any dependency.\n\n## Geometry-moving core releases: lockstep floors\n\nCore composition changes that move geometry (for example opf#169 cover centering) make the preview and the PPTX export drift when `@openpresentation/opf-render` and `@openpresentation/opf-pptx` resolve different core versions (measured: 186-300 pt title offsets on covers).\n\nRule: when a core release contains composition or geometry changes, the same release train must raise BOTH the renderer's and PPTX's core floor (`dependencies` and, where present, `peerDependencies`) to that core version, publish them together, and raise the editor's floor too. Do not release core alone and leave a sibling on the older floor.\n\nThe parity harness must always run with `--import <opf>/scripts/register-local-opf.mjs` (as `run.ps1` does) so every engine shares one core.\n\n## Release Preconditions\n\nBefore tagging, confirm that the release commit on `main` already contains:\n\n- `packages/javascript/package.json` with the intended version.\n- `CHANGELOG.md` with the matching release section (assembled from `changes/`, see above).\n- Passing `OPF CI` on the release commit.\n\nThe publish workflow validates the tag name against\n`packages/javascript/package.json`, so the tag must point at the release commit.\n\n## Tag And Publish\n\nUse the `opf-vX.Y.Z` tag form for the package release:\n\n```sh\ngit checkout main\ngit pull origin main\ngrep '\"version\"' packages/javascript/package.json\ngit tag opf-vX.Y.Z\ngit push origin opf-vX.Y.Z\n```\n\nFor example, version `0.3.0` used:\n\n```sh\ngit tag opf-v0.3.0\ngit push origin opf-v0.3.0\n```\n\nPushing the tag triggers `.github/workflows/npm-publish.yml`. The workflow:\n\n- runs on tags matching `opf-v*` or `@openpresentation/opf@v*`\n- installs dependencies with pnpm on Node 24\n- verifies the tag matches `packages/javascript/package.json`\n- runs typecheck and tests\n- runs the npm package dry-run check\n- publishes from `packages/javascript` with `npm publish --access public --provenance`\n\nDo not rerun a successful publish for the same version. npm package versions are\nimmutable; a second publish for an already-published version should fail.\n\n## Trusted Publishing\n\nnpm publishing is configured to use GitHub Actions OIDC trusted publishing, not\na long-lived npm token.\n\nExpected npm package trusted-publisher settings:\n\n| Setting | Value |\n|---|---|\n| Package | `@openpresentation/opf` |\n| Publisher | GitHub Actions |\n| Organization/repository | `OpenPresentation/opf` |\n| Workflow filename | `npm-publish.yml` |\n| Environment | empty, unless the workflow is later moved behind a GitHub Environment |\n| Permission | `npm publish` |\n\nExpected workflow settings:\n\n```yaml\npermissions:\n contents: read\n id-token: write\n```\n\nThe publish step should not set `NODE_AUTH_TOKEN`:\n\n```yaml\n- name: Publish to npm\n working-directory: packages/javascript\n run: npm publish --access public\n```\n\nIf a future release fails with npm authentication errors, check the npm\ntrusted-publisher settings first. Only use an `NPM_TOKEN` repository secret as a\ntemporary fallback, and remove or revoke it once OIDC publishing works again.\n\n## Verify The Release\n\n`node scripts/release-train.mjs verify <package>@X.Y.Z` runs every registry check of this section and the provenance\nchecks (see \"Release train\" above). By hand, after the workflow completes, verify npm:\n\n```sh\nnpm view @openpresentation/opf version\n```\n\nThe output should equal the package version that was tagged.\n\nSpot-check the validator API from a clean project or temporary directory:\n\n```sh\nnpm install @openpresentation/opf@X.Y.Z\nnode --input-type=module -e \"import {validate} from '@openpresentation/opf'; console.log(validate({name:'t', narrative:'not-a-real-id', slides:[{title:'t'}]}, {only:['format','references']}).findings)\"\n```\n\nThe expected result is one `opf/unresolved-reference` warning for the narrative (no catalog is registered).\n\n## GitHub Release Notes\n\nFrom 0.18 on, every lockstep package's tag workflow ends in a `release` job that creates a GitHub Release after the npm\npublish (RR-20). The job holds `contents: write`; the publish job keeps only `id-token: write` for provenance. It takes\nthe notes from the matching `## X.Y.Z` section of the package's changelog and fails when the section is missing.\n\n| Package | Repository, workflow | Tag | Title | Changelog | Latest |\n| --- | --- | --- | --- | --- | --- |\n| `@openpresentation/opf` | `opf`, `npm-publish.yml` | `opf-vX.Y.Z` | `@openpresentation/opf X.Y.Z` | `CHANGELOG.md` | yes |\n| `@openpresentation/opf-render` | `opf-render`, `npm-publish.yml` | `opf-render-vX.Y.Z` | `@openpresentation/opf-render X.Y.Z` | `CHANGELOG.md` | yes |\n| `@openpresentation/opf-pptx` | `opf-pptx`, `release.yml` | `opf-pptx-vX.Y.Z` | `@openpresentation/opf-pptx X.Y.Z` | `CHANGELOG.md` | yes |\n| `@openpresentation/opf-editor` | `opf-editor`, `release.yml` | `opf-editor-vX.Y.Z` | `@openpresentation/opf-editor X.Y.Z` | `CHANGELOG.md` | yes |\n| `@openpresentation/cli` | `opf`, `cli-publish.yml` | `cli-vX.Y.Z` | `@openpresentation/cli X.Y.Z` | `packages/cli/CHANGELOG.md` | no (core owns it) |\n\nReleases before 0.18 are not backfilled. `verify` requires the release for each of these packages. If release creation\nfailed, rerun the failed `release` job (it keeps an existing release and its notes), or create the missing release for\nthe existing tag, for example:\n\n```sh\ngh release create opf-render-vX.Y.Z \\\n --repo OpenPresentation/opf-render \\\n --title '@openpresentation/opf-render X.Y.Z' \\\n --notes-file /path/to/release-notes.md \\\n --verify-tag --latest\n```\n\nUse the matching `## X.Y.Z` section of the changelog as the release notes.\n\n## Troubleshooting\n\nIf the tag/version check fails, the tag does not point at the release commit or\nthe tag name does not match `packages/javascript/package.json`. Delete the bad\nlocal and remote tag, fetch `main`, and tag the correct commit:\n\n```sh\ngit push origin :refs/tags/opf-vX.Y.Z\ngit tag -d opf-vX.Y.Z\ngit checkout main\ngit pull origin main\ngit tag opf-vX.Y.Z\ngit push origin opf-vX.Y.Z\n```\n\nIf tests fail, fix the code on `main`, create a new release commit, and move the\ntag only if npm has not already published that version.\n\nIf npm publish fails with `ENEEDAUTH`, confirm:\n\n- npm has a trusted publisher for `OpenPresentation/opf`\n- the trusted publisher uses workflow filename `npm-publish.yml`\n- `.github/workflows/npm-publish.yml` has `id-token: write`\n- the publish job is running on a modern Node/npm toolchain\n\nIf npm publish fails after the version is already present on npm, do not retry\nthe same publish. Verify the package and treat the failure as a duplicate\npublish attempt.\n\n### CLI peer contracts before a new minor train\n\nA CLI release prep advances development pins, but does not define a new runtime peer contract.\nBefore a train crosses an incompatible peer minor (for example 0.18 to 0.19), the reviewed feature changes\nmust align the CLI's peer metadata with core's peer manifest and `packages/javascript/src/node/peers.ts`.\nRelease core's new contract first. Then CLI prep keeps those aligned ranges and advances its core dependency\nand development pins. If CLI metadata still names the old incompatible line, prep stops before cloning;\nmake the contract change in its own reviewed PR, rather than editing core runtime files in release prep.\n"
278
278
  },
279
279
  {
280
280
  "slug": "rich-text",
@@ -1 +1 @@
1
- export { FONT_POLICY, applyFontPolicyDecisions, fontAvailabilityDiagnostics, fontPolicyFor } from './chunk-WXSPETQ6.js';
1
+ export { FONT_POLICY, applyFontPolicyDecisions, fontAvailabilityDiagnostics, fontPolicyFor } from './chunk-F7PLNWHQ.js';
package/dist/index.js CHANGED
@@ -20,7 +20,7 @@ export { CHART_TYPES, DEFAULT_VARIABLE_DATE_FORMAT, ENGINE_DEFAULT_CHART_TYPES,
20
20
  export { catalogDisplayKinds, catalogSchemaNames } from './chunk-TRDXRRQK.js';
21
21
  export { CATALOG_GROUP_PATTERN, CATALOG_REFERENCE_PATTERN, OPFCatalogsOptionError, OPFUnresolvedReferenceError, catalogGroupSource, catalogKinds, catalogRecords, catalogReferenceSites, parseReference, resolveReference, unresolvedReference } from './chunk-SPFV27O5.js';
22
22
  export { audience, chartType, colorScheme, fontScheme, language, layout, narrative, presentation, purpose, schemaEntries, schemaNames, schemas, socialPlatform, theme, tone } from './chunk-2PEWUUBR.js';
23
- export { FONT_POLICY, applyFontPolicyDecisions, fontAvailabilityDiagnostics, fontPolicyFor } from './chunk-WXSPETQ6.js';
23
+ export { FONT_POLICY, applyFontPolicyDecisions, fontAvailabilityDiagnostics, fontPolicyFor } from './chunk-F7PLNWHQ.js';
24
24
 
25
25
  // src/index.ts
26
26
  var DEFAULT_CATALOGS = [defaultCatalog];
@@ -34,7 +34,7 @@ var stats2 = (presentation2, options) => stats(presentation2, withDefaultCatalog
34
34
  var paginate2 = (input, options) => paginate(input, withDefaultCatalog(options));
35
35
  var embed2 = (document, options) => embed(document, withDefaultCatalog(options));
36
36
  var edit2 = (deck, patch, options) => edit(deck, patch, withDefaultCatalog(options));
37
- var engine = () => import('./conversion-4CLOEG6O.js');
37
+ var engine = () => import('./conversion-SHYVL7FC.js');
38
38
  var rethrow = (error, fallback) => {
39
39
  if (error instanceof OPFValidationError) throw error;
40
40
  throw asApiError(OPFApiError, error, fallback);
@@ -20,7 +20,7 @@ import './chunk-MBU42S5Y.js';
20
20
  import './chunk-TRDXRRQK.js';
21
21
  import './chunk-SPFV27O5.js';
22
22
  import './chunk-2PEWUUBR.js';
23
- import './chunk-WXSPETQ6.js';
23
+ import './chunk-F7PLNWHQ.js';
24
24
 
25
25
  // src/node/paginate.ts
26
26
  async function paginateDeck(deck, options) {
@@ -43,6 +43,11 @@
43
43
  "replacement": {
44
44
  "family": "Noto Sans Thai",
45
45
  "compatibility": "visual",
46
+ "sizeAdjust": 0.75,
47
+ "lineAscent": 0.83,
48
+ "lineAscentMixed": 0.86,
49
+ "lineAscentBasis": "opf#361 native probe, PowerPoint 365 16.0 build 20430 (RR-38 setup, 2880 px; bottom edge of the last row with 35 percent of the darkest row's ink): 0.833 / 0.833 / 0.833 em at 18 / 24 / 32 pt, with a Latin run 0.856 / 0.866 / 0.862 (replaces the 0.85 read from 1280 px PNGs). Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
50
+ "sizeAdjustBasis": "opf#361 rule: the smallest factor (0.01 steps, up) at which no line of the FF-44 script corpus or of the scheme's Latin overhangs over 3 percent natively, regular and bold (Angsana New 5.06 advances over Noto Sans Thai's, fontkit, read in place). Exact 0.7401; worst line thai-digits +1.6 percent; Latin slack 42.9 percent (Latin is drawn by the replacement at the composed size). Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
46
51
  "measured": {
47
52
  "replacement": "Noto Sans Thai",
48
53
  "meanAbsWidthDelta": 0.7476,
@@ -274,9 +279,9 @@
274
279
  "family": "Noto Naskh Arabic",
275
280
  "compatibility": "visual",
276
281
  "sizeAdjust": 0.64,
277
- "lineAscent": 0.7,
278
- "lineAscentMixed": 0.78,
279
- "lineAscentBasis": "RR-38 native PowerPoint 365 probe (probe-arabic.pptx, 18, 24 and 32 pt, 100 percent line spacing, zero insets, top anchor, rtl paragraph): the baseline of an Arabic Typesetting-only line (the most common ink-bottom row of the line) sits 0.68, 0.71 and 0.72 em below the box top (regular) and 0.68, 0.72 and 0.73 em (bold), against Arabic Typesetting's hhea ascent of 0.701 em; a line that also holds an Aptos run (Arabic, 'PowerPoint 365', Arabic) sits 0.77, 0.78 and 0.78 em below it. Core places the baseline one em below the line top, so the preview drew Arabic lines 0.22 to 0.30 em lower.",
282
+ "lineAscent": 0.74,
283
+ "lineAscentMixed": 0.8,
284
+ "lineAscentBasis": "opf#361 native probe, PowerPoint 365 16.0 build 20430 (RR-38 setup, 2880 px; bottom edge of the last row with 35 percent of the darkest row's ink): 0.741 / 0.736 / 0.740 em at 18 / 24 / 32 pt, with a Latin run 0.801 / 0.810 / 0.800. Corrects RR-38's 0.70 / 0.78 (an earlier estimator; RR-38's own 2560 px PNGs re-read 0.743 / 0.745 / 0.746 and 0.79 to 0.80 with this rule). Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
280
285
  "sizeAdjustBasis": "RR-38: sum of Arabic Typesetting 6.85 advances over sum of Noto Naskh Arabic 400 advances (fontkit, each sample's OpenType language). 0.643 on the 12 Arabic-script corpus samples of opf-render test/fixtures/script-corpora.json (per-sample range 0.57 to 0.86); 0.598 on 29 Arabic strings of the RR-05b native deck (running Arabic text); 0.614 pooled; Noto Naskh Arabic 700 gives 0.62, 0.58 and 0.59, so bold runs draw about 3 percent long. Ink height of alef, lam, meem and heh is 0.71 of Noto Naskh Arabic's. 0.64 balances the two: lines come out about 4 percent longer than PowerPoint's and glyphs about 10 percent smaller. Native check (RR-38 probe): PowerPoint's BoundWidth of the 45-character probe line is 216.9, 289.0 and 386.1 pt at 18, 24 and 32 pt, 1.8 to 2.0 percent above the fontkit advance of the installed font (213.0, 284.0, 378.7).",
281
286
  "measured": {
282
287
  "replacement": "Noto Naskh Arabic",
@@ -1099,6 +1104,11 @@
1099
1104
  "replacement": {
1100
1105
  "family": "Noto Sans Thai",
1101
1106
  "compatibility": "visual",
1107
+ "sizeAdjust": 0.68,
1108
+ "lineAscent": 0.82,
1109
+ "lineAscentMixed": 0.85,
1110
+ "lineAscentBasis": "opf#361 native probe, PowerPoint 365 16.0 build 20430 (RR-38 setup, 2880 px; bottom edge of the last row with 35 percent of the darkest row's ink): 0.815 / 0.819 / 0.813 em at 18 / 24 / 32 pt, with a Latin run 0.856 / 0.852 / 0.841. Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
1111
+ "sizeAdjustBasis": "opf#361 rule: the smallest factor (0.01 steps, up) at which no line of the FF-44 script corpus or of the scheme's Latin overhangs over 3 percent natively, regular and bold (DilleniaUPC 5.05 advances over Noto Sans Thai's, fontkit, read in place). Exact 0.6704; worst line thai-digits +1.6 percent; Latin slack 41.5 percent (Latin is drawn by the replacement at the composed size). Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
1102
1112
  "measured": {
1103
1113
  "replacement": "Noto Sans Thai",
1104
1114
  "meanAbsWidthDelta": 0.7077,
@@ -1904,6 +1914,8 @@
1904
1914
  "replacement": {
1905
1915
  "family": "Noto Sans KR",
1906
1916
  "compatibility": "visual",
1917
+ "sizeAdjust": 1.07,
1918
+ "sizeAdjustBasis": "opf#361 rule: the smallest factor (0.01 steps, up) at which no line of the FF-44 script corpus or of the scheme's Latin overhangs over 3 percent natively, regular and bold (Malgun Gothic 6.69 advances over Noto Sans KR's, fontkit, read in place). Exact 1.0557 on precomposed Hangul; worst line kore-sentence +1.6 percent at 1.07 (Hangul); latn-numerals +2.7 percent (Latin, at any factor); Latin slack 1.6 percent (Latin is drawn by the replacement at the composed size). Evidence: docs/evidence/ff-46-sizeadjust-native-20261010. Decomposed conjoining jamo (U+1100-11FF, U+A960-A97F, U+D7B0-D7FF; sample kore-jamo, 1.0669) is a documented limitation outside the row decision: PowerPoint draws it unshaped. No lineAscent: the native baseline is 1.06 em, below core's 1.0.",
1907
1919
  "measured": {
1908
1920
  "replacement": "Noto Sans KR",
1909
1921
  "meanAbsWidthDelta": 0.0176,
@@ -2194,6 +2206,11 @@
2194
2206
  "replacement": {
2195
2207
  "family": "Noto Sans Devanagari",
2196
2208
  "compatibility": "visual",
2209
+ "sizeAdjust": 1.07,
2210
+ "lineAscent": 0.97,
2211
+ "lineAscentMixed": 0.94,
2212
+ "lineAscentBasis": "opf#361 native probe, PowerPoint 365 16.0 build 20430 (RR-38 setup, 2880 px; bottom edge of the last row with 35 percent of the darkest row's ink): Devanagari stem bottoms 0.981 / 0.972 / 0.969 em at 18 / 24 / 32 pt, with a Latin run 0.930 / 0.935 / 0.935. Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
2213
+ "sizeAdjustBasis": "opf#361 rule: the smallest factor (0.01 steps, up) at which no line of the FF-44 script corpus or of the scheme's Latin overhangs over 3 percent natively, regular and bold (Nirmala UI 1.46 advances over Noto Sans Devanagari's, fontkit, read in place). Exact 1.0695; worst line deva-matras +2.9 percent; Latin slack 6.0 percent (Latin is drawn by the replacement at the composed size). Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
2197
2214
  "measured": {
2198
2215
  "replacement": "Noto Sans Devanagari",
2199
2216
  "meanAbsWidthDelta": 0.0638,
@@ -2900,6 +2917,11 @@
2900
2917
  "replacement": {
2901
2918
  "family": "Noto Naskh Arabic",
2902
2919
  "compatibility": "visual",
2920
+ "sizeAdjust": 0.89,
2921
+ "lineAscent": 0.76,
2922
+ "lineAscentMixed": 0.81,
2923
+ "lineAscentBasis": "opf#361 native probe, PowerPoint 365 16.0 build 20430 (RR-38 setup, 2880 px; bottom edge of the last row with 35 percent of the darkest row's ink): 0.759 / 0.764 / 0.760 em at 18 / 24 / 32 pt, with a Latin run 0.819 / 0.810 / 0.810 (replaces the 0.78 read from 1280 px PNGs). Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
2924
+ "sizeAdjustBasis": "opf#361 rule: the smallest factor (0.01 steps, up) at which no line of the FF-44 script corpus or of the scheme's Latin overhangs over 3 percent natively, regular and bold (Sakkal Majalla 7.00 advances over Noto Naskh Arabic's, fontkit, read in place). Exact 0.8866; worst line arab-fa-digits +2.6 percent; Latin slack 35.1 percent (Latin is drawn by the replacement at the composed size). Evidence: docs/evidence/ff-46-sizeadjust-native-20261010.",
2903
2925
  "measured": {
2904
2926
  "replacement": "Noto Naskh Arabic",
2905
2927
  "meanAbsWidthDelta": 0.5423,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpresentation/opf",
3
- "version": "0.18.3",
3
+ "version": "0.18.4",
4
4
  "description": "Canonical Open Presentation Format schemas, TypeScript types, local validation and composition; the pinned default catalog ships as the opt-in @openpresentation/opf/catalog.",
5
5
  "license": "MIT",
6
6
  "author": "Open Presentation",