Merge remote-tracking branch 'origin/main' into codex/issue-2844-accessibility

This commit is contained in:
Amruth Pillai
2026-09-06 06:08:23 +02:00
28 changed files with 4697 additions and 1 deletions
@@ -0,0 +1,32 @@
# Plan 23 item-pagination execution evidence
## Scope
Plan 23 steps 1–3 were evaluated from `origin/main` at `368858a56` (Plan 21 / PR #3477 merged). Widow/orphan UI and authored-page continuation guidance remain deferred from this execution, and Semantic CSS was not changed.
## Durable diagnostic matrix
`packages/pdf/src/templates/shared/item-pagination.test.tsx` renders physical PDF pages and checks numbered tokens exactly once for:
- an item that fits remaining space;
- an item that fits a full page but not the remaining space;
- an oversized item taller than one page;
- a two-line paragraph at a boundary;
- nested bullets; and
- built-in plus custom items in an Azurill sidebar/main-column overflow fixture.
The fixture also keeps authored `metadata.layout.pages` separate from renderer-generated physical pages.
The current deterministic baseline is: fit remainder = 1 physical page; full-page-but-not-remainder = 3 pages with sampled tokens on pages 2/2/3; oversized = 5 pages with sampled tokens on pages 1/3/5; two-line boundary = 2 pages; nested bullets = 1 page; Azurill built-in/custom/sidebar = 5 pages with sampled tokens on pages 1/4/5/5/1. Page numbers here are 1-based; every token still appears exactly once.
## Concrete blocker
React PDF's only available item-level keep-together primitive is `View wrap={false}`. A durable renderer fixture with 180 paragraph-like child views shows that a non-wrapping item cannot safely fall back when its content exceeds one page: the renderer omits the oversized tail instead of splitting it. Applying the same prop to shared `SectionItem` would therefore violate the lossless token requirement; no item schema flag or menu control was added.
Do not estimate item height from HTML length, persist physical pages, alter existing Semantic CSS, or claim #3350 complete. A future implementation needs renderer-supported conditional keep-together behavior or an actual measured two-pass fallback that preserves every token.
## Verification
- `pnpm --filter @reactive-resume/pdf exec vitest run src/templates/shared/item-pagination.test.tsx`: 7 tests passed.
- No production source or schema changes made after the unsafe fallback was reproduced.
- Undo/persistence/lock UI coverage is intentionally absent because no item control was shipped; add it only when safe fallback exists.
+157
View File
@@ -0,0 +1,157 @@
# Plan 27 Phase A diagnostic evidence
Date: 2026-09-06
Issue: [#3377](https://github.com/amruthpillai/reactive-resume/issues/3377)
Revision: `2a4a1583b` (`origin/main` at run start)
Scope: Phase A, steps 1–2 only. No resolver, runtime behavior, or remote-source behavior changed.
## Drift and authority
- Worktree started clean and `HEAD` matched `origin/main`; `git diff origin/main...HEAD` was empty.
- Current catalog is `packages/fonts/src/webfontlist.json`. Its web font records point at both Google Fonts static assets and jsDelivr assets; “Google blocked” is not an offline proof.
- Browser preview is `apps/web/src/components/typography/font-display.tsx` and calls `FontFace.load()` against each catalog preview URL.
- Browser PDF preview/download is `apps/web/src/features/resume/export/pdf-document.tsx` → `@reactive-resume/pdf/browser`; registration is `packages/pdf/src/hooks/use-register-fonts.ts`.
- Server PDF is `apps/server/src/http/resume-pdf.ts` → `createResumePdfDownload`; Playwright browser routing cannot observe that process’s outbound font fetches.
- The issue is open and unmodified. PR #3455 is the approved planning PR; its plan/decision log grants execution of this bounded diagnostic and manifest evidence.
## Deterministic fixture
`tests/e2e/fixtures/offline-fonts.ts` seeds one disposable resume after sample creation. It writes the same text into basics and summary, hides the picture, selects IBM Plex Serif 400/700 for body and heading, and marks the row public for the server-PDF surface.
The exact markers are versioned as `offline-font-scripts-v1`:
| Marker | Script or coverage |
| --- | --- |
| `Latin punctuation • — “quotes” €` | Latin plus General Punctuation and currency |
| `简体中文` | Han / Simplified Chinese |
| `العربية` | Arabic |
| `עברית` | Hebrew |
| `ไทย` | Thai |
| `Emoji 🚀` | Emoji |
`tests/e2e/specs/offline-fonts.spec.ts` is opt-in (`OFFLINE_FONT_DIAGNOSTIC=1`) so the normal PR E2E suite does not become network-dependent. Each surface creates a new browser context with persisted auth state, disabled service workers, and no prior browser cache. Every non-same-origin request is aborted and recorded as `{ hostname, path }`; query strings, fragments, headers, bodies, tokens, and full URLs never enter diagnostic output. Reports are attached as JSON and emitted with the same sanitized shape.
The four surfaces are separate tests:
1. Font picker preview opens Typography → Font Family and waits for lazy `FontFace` preview loads.
2. Builder PDF preview navigates to the builder, captures the active PDF canvas, and measures marker-local raster crops.
3. Browser PDF download uses the Export dialog, rasterizes the downloaded PDF, and measures marker-local crops when generation succeeds.
4. Server PDF calls the public PDF endpoint and records text-layer marker presence when generation succeeds.
Builder/browser-PDF reports keep PDF text extraction as a separate `textLayerMarkers` signal; it does not prove visible glyph outlines. Raster evidence attaches a rendered PNG and per-marker crop metrics, failing for blank or tofu-like visible crops. Blocked browser font requests classify browser surfaces as `network-error`. The server report deliberately says `server-outbound-requests-unobservable-from-playwright`; its cold-network gate remains unresolved because server outbound capture and verifiable restart identity require external host-level controls.
## Run protocol and cold-cache boundary
Build and database setup follow `tests/e2e/README.md`. Run each surface in a separately restarted production server process so module-level PDF font registration state cannot leak between controls:
```text
OFFLINE_FONT_DIAGNOSTIC=1 OFFLINE_FONT_DIAGNOSTIC_SERVER_RESTARTED=1 \
pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --grep "picker preview"
```
Stop and restart the production server before repeating the command with `builder PDF`, `browser PDF`, and `server PDF` grep patterns. The environment used for this change had no built `apps/server/dist` or `apps/web/dist`, no running PostgreSQL instance, and no production server to restart, so the cold E2E matrix was not run. This is an explicit infrastructure blocker, not a pass claim. The test records `serverRestartFlag` only as caller input and labels it non-proof; it does not claim a completed cold-network gate.
The current Playwright route guard cannot impose host-level egress denial on Node.js running the server. A genuinely cold server test therefore needs a separately restarted server plus host-level egress capture/deny (for example, a controlled network namespace or an approved outbound proxy). Do not infer server network behavior from an empty browser request list.
## Administrator-hosted manifest proposal
This is a proposal, not an asset download. It intentionally contains only the primary family and glyph fallbacks required by the fixture and current PDF fallback map, not the full catalog.
```json
{
"schemaVersion": "offline-fonts-v1",
"mode": "local-only",
"assetRoot": "/fonts/offline/v1",
"families": {
"IBM Plex Serif": {
"normal": { "400": "ibm-plex-serif/400.ttf", "700": "ibm-plex-serif/700.ttf" },
"italic": { "400": "ibm-plex-serif/400-italic.ttf", "700": "ibm-plex-serif/700-italic.ttf" },
"preview": "ibm-plex-serif/preview.ttf"
},
"IBM Plex Sans": {
"normal": { "400": "ibm-plex-sans/400.ttf", "700": "ibm-plex-sans/700.ttf" },
"italic": { "400": "ibm-plex-sans/400-italic.ttf", "700": "ibm-plex-sans/700-italic.ttf" },
"preview": "ibm-plex-sans/preview.ttf"
},
"Noto Serif": {
"normal": { "400": "noto-serif/400.ttf", "700": "noto-serif/700.ttf" },
"italic": { "400": "noto-serif/400-italic.ttf", "700": "noto-serif/700-italic.ttf" },
"preview": "noto-serif/preview.ttf"
},
"Noto Sans": {
"normal": { "400": "noto-sans/400.ttf", "700": "noto-sans/700.ttf" },
"italic": { "400": "noto-sans/400-italic.ttf", "700": "noto-sans/700-italic.ttf" },
"preview": "noto-sans/preview.ttf"
},
"Noto Sans SC": { "normal": { "400": "noto-sans-sc/400.ttf", "700": "noto-sans-sc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-sc/preview.ttf" },
"Noto Serif SC": { "normal": { "400": "noto-serif-sc/400.ttf", "700": "noto-serif-sc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-sc/preview.ttf" },
"Noto Sans TC": { "normal": { "400": "noto-sans-tc/400.ttf", "700": "noto-sans-tc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-tc/preview.ttf" },
"Noto Serif TC": { "normal": { "400": "noto-serif-tc/400.ttf", "700": "noto-serif-tc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-tc/preview.ttf" },
"Noto Sans JP": { "normal": { "400": "noto-sans-jp/400.ttf", "700": "noto-sans-jp/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-jp/preview.ttf" },
"Noto Serif JP": { "normal": { "400": "noto-serif-jp/400.ttf", "700": "noto-serif-jp/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-jp/preview.ttf" },
"Noto Sans KR": { "normal": { "400": "noto-sans-kr/400.ttf", "700": "noto-sans-kr/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-kr/preview.ttf" },
"Noto Serif KR": { "normal": { "400": "noto-serif-kr/400.ttf", "700": "noto-serif-kr/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-kr/preview.ttf" },
"Noto Sans Arabic": { "normal": { "400": "noto-sans-arabic/400.ttf", "700": "noto-sans-arabic/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-arabic/preview.ttf" },
"Noto Naskh Arabic": { "normal": { "400": "noto-naskh-arabic/400.ttf", "700": "noto-naskh-arabic/700.ttf" }, "italic": "reuse-normal", "preview": "noto-naskh-arabic/preview.ttf" },
"Noto Sans Hebrew": { "normal": { "400": "noto-sans-hebrew/400.ttf", "700": "noto-sans-hebrew/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-hebrew/preview.ttf" },
"Noto Sans Thai": { "normal": { "400": "noto-sans-thai/400.ttf", "700": "noto-sans-thai/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-thai/preview.ttf" },
"Noto Emoji": { "normal": { "400": "noto-emoji/400.ttf", "700": "noto-emoji/700.ttf" }, "italic": "reuse-normal", "preview": "noto-emoji/preview.ttf" }
}
}
```
### Candidate source, license, script, and size evidence
Sizes are `Content-Length` bytes from a HEAD request to the exact current catalog assets on 2026-09-06. Responses reported `Content-Encoding: gzip`; these are compressed transfer-size estimates, not a claim about the eventual on-disk representation. Preview paths are aliases to the selected 400 face and add no extra bytes when stored once. Primary sources: [IBM Plex LICENSE.txt](https://github.com/IBM/plex/blob/master/LICENSE.txt), [Noto core LICENSE](https://github.com/notofonts/noto-fonts/blob/main/LICENSE), [Noto CJK Sans LICENSE](https://github.com/notofonts/noto-cjk/blob/main/Sans/LICENSE), and [Noto Emoji font LICENSE](https://github.com/googlefonts/noto-emoji/blob/main/fonts/LICENSE).
| Family | Style/weights in proposal | Current catalog source version | License | Script/fallback role | Gzip bytes (selected files) | Build owner; runtime owners |
| --- | --- | --- | --- | --- | ---: | --- |
| IBM Plex Serif | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/ibmplexserif/v20` | OFL 1.1, Reserved Font Name `Plex` | Primary serif; Latin and punctuation stack | 294,717 | `packages/fonts`; `apps/web` FontDisplay; `packages/pdf` registration |
| IBM Plex Sans | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/ibmplexsans/v23` | OFL 1.1, Reserved Font Name `Plex` | Primary sans | 435,469 | `packages/fonts`; `apps/web` FontDisplay; `packages/pdf` registration |
| Noto Serif | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/notoserif/v33` | OFL 1.1 | Serif punctuation fallback | 1,055,120 | `packages/fonts`; `packages/pdf` fallback registration |
| Noto Sans | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/notosans/v42` | OFL 1.1 | Sans punctuation fallback | 1,236,259 | `packages/fonts`; `packages/pdf` fallback registration |
| Noto Sans SC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanssc/v40` | OFL 1.1 (Noto CJK) | Simplified Han; CJK fallback | 12,766,416 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Serif SC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifsc/v35` | OFL 1.1 (Noto CJK) | Simplified Han serif fallback | 17,350,185 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Sans TC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanstc/v39` | OFL 1.1 (Noto CJK) | Traditional Han fallback | 8,628,278 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Serif TC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoseriftc/v36` | OFL 1.1 (Noto CJK) | Traditional Han serif fallback | 11,804,923 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Sans JP | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansjp/v56` | OFL 1.1 (Noto CJK) | Kana and Japanese Han fallback | 6,383,035 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Serif JP | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifjp/v33` | OFL 1.1 (Noto CJK) | Kana and Japanese Han serif fallback | 8,685,862 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Sans KR | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanskr/v39` | OFL 1.1 (Noto CJK) | Hangul and Korean Han fallback | 6,102,888 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Serif KR | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifkr/v31` | OFL 1.1 (Noto CJK) | Hangul and Korean Han serif fallback | 11,113,442 | `packages/fonts`; `packages/pdf` CJK fallback |
| Noto Sans Arabic | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansarabic/v33` | OFL 1.1 | Arabic sans fallback | 178,455 | `packages/fonts`; `packages/pdf` script fallback |
| Noto Naskh Arabic | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notonaskharabic/v44` | OFL 1.1 | Arabic serif fallback | 190,924 | `packages/fonts`; `packages/pdf` script fallback |
| Noto Sans Hebrew | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanshebrew/v50` | OFL 1.1 | Hebrew fallback for both serif/sans slots | 55,707 | `packages/fonts`; `packages/pdf` script fallback |
| Noto Sans Thai | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansthai/v29` | OFL 1.1 | Thai fallback for both serif/sans slots | 55,173 | `packages/fonts`; `packages/pdf` script fallback |
| Noto Emoji | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoemoji/v62` | OFL 1.1 for font files; assets/tools have separate licenses | Emoji outline fallback; verify renderer support | 1,153,847 | `packages/fonts`; `packages/pdf` script fallback |
Estimated transfer size for all rows and listed styles: **87,490,700 bytes (~83.44 MiB)**. This confirms why a full-catalog bundle is out of scope. A later implementation should subset by declared glyph requirements or make the administrator choose fallback families; it must not silently fetch another CDN.
### Source and license obligations
- Pin an upstream release/commit and retain source attribution plus the complete applicable license with hosted assets. Do not use mutable `@latest` URLs as runtime sources.
- IBM Plex’s license has Reserved Font Name `Plex`; modified/subset outputs must follow OFL naming requirements.
- Noto core, Noto CJK, and Noto Emoji font files are OFL 1.1, but Noto Emoji documents separate Apache/public-domain treatment for tools and flag image assets. Bundle only font files unless those other assets are intentionally needed and separately attributed.
- License checks are build-owner responsibility (`packages/fonts`/tooling); runtime owners (`apps/web` and `packages/pdf`) consume only the validated manifest.
### Missing-family and missing-asset behavior
Local mode must resolve only same-origin administrator-hosted manifest paths. If imported resume data names an unavailable family, show an actionable missing-family error naming the family and required local asset; apply a configured local fallback only when the administrator explicitly supplied one. If a required weight/style/fallback asset is absent, fail the affected preview/export with an actionable diagnostic containing family/style/weight and local path. Never retry Google Fonts, jsDelivr, or any other remote URL in local mode.
Standard PDF families (Helvetica, Courier, Times-Roman) remain file-free. They do not prove that a document containing punctuation, CJK, Arabic, Hebrew, Thai, or emoji is network-free; the script fallback rows remain required.
## Verification record
Completed read-only checks before handoff:
- CodeGraph exploration of font catalog, picker preview, browser PDF, server PDF, and existing fallback tests.
- `pnpm dlx @tanstack/intent@latest list`: no matching local intent skill for this work.
- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts turbo.json`: passed.
- `git diff --check`: passed.
- `pnpm --filter @reactive-resume/fonts test`: passed (55 tests).
- `pnpm --filter @reactive-resume/pdf exec vitest run src/hooks/use-register-fonts.test.ts`: passed (35 tests).
- Web typography/regression suite: passed (940 tests across 135 files); web and server package typechecks passed.
- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list`: passed (4 diagnostic tests collected).
- E2E diagnostic execution: blocked by missing build outputs and unavailable PostgreSQL/server; no success claim made.
- `pnpm exec turbo boundaries`: passed on fresh rerun (Turbo 2.10.12, 1108 files, no issues).
The implementation intentionally stops at diagnostic fixtures and manifest evidence. Shared source resolution, asset hosting, local-mode configuration, and production behavior remain Phase A step 3+ work.
@@ -0,0 +1,22 @@
# Plan 27A remediation round 2
Date: 2026-09-06
Base: `ae8e2f76f`
## Focused fixes
- Removed multilingual markers from the fixture headline. Each marker now exists only in its dedicated summary paragraph.
- Added pure marker-location helpers. Marker lookup joins PDF text items, supports markers split across items, rejects duplicate occurrences, and rejects non-whitespace neighbors that could contaminate a local crop.
- Raster measurement still scans with antialiasing padding but counts ink only inside the marker box, preventing neighboring glyphs from making blank or tofu-like evidence pass.
- Browser PDF download now separates download errors from post-download evidence errors. A received download with failed rasterization is reported as `unresolved-raster-evidence-error` and fails the opt-in test rather than passing as a generic download error.
- Added focused pure tests covering duplicate, split, neighboring, blank, and tofu-like cases.
- Removed trailing spaces from `plan-27a-remediation.md`.
## Verification
- `pnpm exec vitest run tests/e2e/fixtures/offline-font-markers.test.ts` — 5/5 passed.
- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts tests/e2e/fixtures/offline-font-markers.ts tests/e2e/fixtures/offline-font-markers.test.ts` — passed.
- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list` — 4 tests collected.
- `git diff --check` — passed after remediation-document whitespace cleanup.
Full diagnostic E2E remains opt-in and was not run in this focused round. Server outbound request capture and verifiable restart identity remain explicit external host-level blockers; no production resolver changes were made.
+24
View File
@@ -0,0 +1,24 @@
# Plan 27A remediation
Date: 2026-09-06
Base: `61b58ae9a`
Scope: concrete findings from `.orchestration/plan-27a-independent-review.md` only.
## Remediated findings
- Builder PDF preview and browser PDF download now produce raster evidence. The fixture stores each multilingual marker in its own summary paragraph, allowing the diagnostic to locate marker-local PDF text boxes and measure only those raster crops. Reports attach a rendered PNG plus per-marker `inkPixels`, trimmed dimensions, and status. Blank and tofu-like crops fail assertions; no whole-page snapshot is used.
- PDF text extraction is reported separately as `textLayerMarkers`. It is not described or asserted as proof of visible glyph outlines.
- Server PDF output remains text-extraction-only and is explicitly classified as `serverGateStatus: unresolved-external-host-level-blocker`. `serverRestartFlag` is caller input, not restart proof. Browser Playwright routing is not used to infer server egress, and no production resolver or instrumentation behavior was added.
- `.orchestration/plan-27a-diagnostic.md` now records a fresh boundaries pass and the corrected `87,490,700 bytes (~83.44 MiB)` arithmetic.
- Diagnostic remains opt-in through `OFFLINE_FONT_DIAGNOSTIC=1`; normal CI behavior remains unchanged. Request logs stay sanitized to hostname and pathname.
## Verification
- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts` — passed.
- `git diff --check` — passed.
- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list` — 4 tests collected.
- `pnpm --filter @reactive-resume/fonts test` — 55/55 passed.
- `pnpm --filter @reactive-resume/pdf exec vitest run src/hooks/use-register-fonts.test.ts` — 35/35 passed.
- `pnpm exec turbo boundaries` — passed on fresh rerun (Turbo 2.10.12, 1108 files, no issues).
Full diagnostic E2E remains unrun because this environment lacks production build output, PostgreSQL, and a production server. Server cold-network capture and verifiable restart identity remain external host-level blockers by design; this remediation does not claim that gate is complete.
@@ -0,0 +1,17 @@
# Issue 3350 remediation evidence
## Findings addressed
- `item-pagination.test.tsx` now derives complete numbered-token inventories for each generated fixture and asserts every token exactly once. Sampled token-to-physical-page placement checks remain separate.
- Pagination fixtures snapshot `metadata.layout.pages` before rendering and assert authored layout pages are unchanged afterward. Overflow fixtures also assert physical PDF page count exceeds authored page count.
- Unsafe `wrap={false}` renderer coverage remains diagnostic-only; no item controls, schema flags, or runtime behavior were added.
## Verification
- `rtk proxy pnpm --filter @reactive-resume/pdf exec vitest run src/semantic/pagination.test.tsx src/templates/shared/item-pagination.test.tsx` — 2 files, 11 tests passed.
- `rtk proxy pnpm --filter @reactive-resume/pdf typecheck` — passed.
- `rtk proxy pnpm exec biome check packages/pdf/src/templates/shared/item-pagination.test.tsx` — passed.
- `rtk proxy pnpm exec turbo boundaries` — passed; 1109 files checked.
- `rtk git diff --check origin/main...HEAD` — passed.
Only PDF test coverage and this evidence file changed; production behavior remains untouched.
@@ -0,0 +1,602 @@
import type { SemanticNode } from "@reactive-resume/resume/stylesheet";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { TextItem } from "pdfjs-dist/types/src/display/api";
import type { RasterizedPdfPage } from "../../semantic/test/rasterize-pdf";
import { createHash } from "node:crypto";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { pdf, renderToBuffer } from "@react-pdf/renderer";
import { encode } from "fast-png";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { act, createElement } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { resolveLevelDisplaySizes } from "@reactive-resume/schema/resume/level-display-sizes";
import { ResumeDocument } from "../../document";
import { resolveResumeRuntime } from "../../semantic/resolve";
import { rasterizePdf } from "../../semantic/test/rasterize-pdf";
const templates = [
"azurill",
"bronzor",
"chikorita",
"ditgar",
"ditto",
"gengar",
"glalie",
"kakuna",
"lapras",
"leafish",
"meowth",
"onyx",
"pikachu",
"rhyhorn",
"scizor",
] as const satisfies readonly Template[];
const dateMarkers = [
"EXP_LONG",
"EXP_NO_LOCATION",
"ROLE_ONE",
"ROLE_TWO",
"EXP_NO_PERIOD",
"EDU_DATE",
"PROJECT_DATE",
"AWARD_DATE",
"CERT_DATE",
"VOLUNTEER_DATE",
"CUSTOM_EXP_DATE",
"CUSTOM_ROLE_DATE",
"CUSTOM_EDU_DATE",
"CUSTOM_PROJECT_DATE",
"CUSTOM_AWARD_DATE",
"CUSTOM_CERT_DATE",
"CUSTOM_VOLUNTEER_DATE",
] as const;
type DateMarker = (typeof dateMarkers)[number];
const expectedMissingMarkers = {
azurill: [],
bronzor: [],
chikorita: [],
ditgar: [],
ditto: [],
gengar: [],
glalie: [],
kakuna: [],
lapras: [],
leafish: [],
meowth: ["EXP_NO_PERIOD"],
onyx: [],
pikachu: [],
rhyhorn: [],
scizor: [],
} as const satisfies Record<Template, readonly DateMarker[]>;
const baselineDirectory = join(dirname(fileURLToPath(import.meta.url)), "../../../test-artifacts/date-layout");
type PdfPageText = {
pageNumber: number;
items: TextItem[];
};
type RenderedFixture = {
bytes: Uint8Array;
pages: PdfPageText[];
raster: readonly RasterizedPdfPage[];
};
const emptyWebsite = () => ({ url: "", label: "", inlineLink: false });
const required = <T,>(value: T | undefined): T => {
if (value === undefined) throw new Error("Missing fixture value");
return value;
};
const richText = (text: string) => `<p>${text}</p>`;
const dateFixture = (locale: "en-US" | "ar-SA" = "en-US"): ResumeData => {
const data = structuredClone(defaultResumeData);
data.picture.hidden = true;
data.basics = {
name: locale === "ar-SA" ? "RTL Date Matrix" : "LTR Date Matrix",
headline: "Date layout characterization",
email: "dates@example.com",
phone: "",
location: locale === "ar-SA" ? "دبي" : "Berlin",
website: { url: "", label: "" },
customFields: [],
};
data.metadata.page.locale = locale;
data.metadata.page.marginX = 28;
data.metadata.page.marginY = 20;
data.metadata.typography.body.fontFamily = "Helvetica";
data.metadata.typography.heading.fontFamily = "Helvetica";
data.metadata.layout.pages = [
{
fullWidth: true,
main: [
"experience",
"education",
"projects",
"awards",
"certifications",
"publications",
"volunteer",
"custom-experience",
"custom-education",
"custom-projects",
"custom-awards",
"custom-certifications",
"custom-publications",
"custom-volunteer",
],
sidebar: [],
},
];
data.sections.experience = {
...data.sections.experience,
title: "Experience matrix",
items: [
{
id: "experience-dated",
hidden: false,
company: "Experience Parent",
position: "Lead Engineer",
location: "Oslo LOCATION_ORDER",
period: "März 2018 – Dezember 2024 EXP_LONG",
website: emptyWebsite(),
description: richText("Experience dated details"),
roles: [
{
id: "role-one",
position: "Role One",
period: "2020 ROLE_ONE",
description: richText("First role details"),
},
{
id: "role-two",
position: "Role Two",
period: "2022 ROLE_TWO",
description: richText("Second role details"),
},
],
},
{
id: "experience-no-location",
hidden: false,
company: "Experience No Location",
position: "Solo Role",
location: "",
period: "2016 EXP_NO_LOCATION",
website: emptyWebsite(),
description: "",
roles: [],
},
{
id: "experience-no-period",
hidden: false,
company: "Experience No Period",
position: "No Date Role",
location: "Oslo EXP_NO_PERIOD",
period: "",
website: emptyWebsite(),
description: "",
roles: [],
},
],
};
data.sections.education = {
...data.sections.education,
title: "Education matrix",
items: [
{
id: "education-dated",
hidden: false,
school: "Education School",
degree: "Degree",
area: "Area",
grade: "Grade",
location: "Education City",
period: "2019 EDU_DATE",
website: emptyWebsite(),
description: richText("Education details"),
},
{
id: "education-empty-period",
hidden: false,
school: "Education Empty Period",
degree: "",
area: "",
grade: "",
location: "",
period: "",
website: emptyWebsite(),
description: "",
},
],
};
data.sections.projects = {
...data.sections.projects,
title: "Projects matrix",
items: [
{
id: "project-dated",
hidden: false,
name: "Project Entry",
period: "2020 PROJECT_DATE",
website: emptyWebsite(),
description: richText("Project details"),
},
],
};
data.sections.awards = {
...data.sections.awards,
title: "Awards matrix",
items: [
{
id: "award-dated",
hidden: false,
title: "Award Entry",
awarder: "Award Society",
date: "2021 AWARD_DATE",
website: emptyWebsite(),
description: richText("Award details"),
},
],
};
data.sections.certifications = {
...data.sections.certifications,
title: "Certifications matrix",
items: [
{
id: "certification-dated",
hidden: false,
title: "Certification Entry",
issuer: "Certification Issuer",
date: "2022 CERT_DATE",
website: emptyWebsite(),
description: richText("Certification details"),
},
],
};
data.sections.publications = {
...data.sections.publications,
title: "Publications matrix",
items: [
{
id: "publication-empty-date",
hidden: false,
title: "Publication Empty Date",
publisher: "Publication Publisher",
date: "",
website: emptyWebsite(),
description: richText("Publication details"),
},
],
};
data.sections.volunteer = {
...data.sections.volunteer,
title: "Volunteer matrix",
items: [
{
id: "volunteer-dated",
hidden: false,
organization: "Volunteer Organization",
location: "Volunteer City",
period: "2023 VOLUNTEER_DATE",
website: emptyWebsite(),
description: richText("Volunteer details"),
},
],
};
data.customSections = [
{
...data.sections.experience,
id: "custom-experience",
type: "experience",
title: "Custom Experience matrix",
items: [
{
...required(data.sections.experience.items[0]),
id: "custom-experience-item",
company: "Custom Experience",
location: "Custom Oslo",
period: "2024 CUSTOM_EXP_DATE",
roles: [
{
id: "custom-role",
position: "Custom Role",
period: "2024 CUSTOM_ROLE_DATE",
description: "",
},
],
},
],
},
{
...data.sections.education,
id: "custom-education",
type: "education",
title: "Custom Education matrix",
items: [
{ ...required(data.sections.education.items[0]), id: "custom-education-item", period: "2024 CUSTOM_EDU_DATE" },
],
},
{
...data.sections.projects,
id: "custom-projects",
type: "projects",
title: "Custom Projects matrix",
items: [
{ ...required(data.sections.projects.items[0]), id: "custom-project-item", period: "2024 CUSTOM_PROJECT_DATE" },
],
},
{
...data.sections.awards,
id: "custom-awards",
type: "awards",
title: "Custom Awards matrix",
items: [{ ...required(data.sections.awards.items[0]), id: "custom-award-item", date: "2024 CUSTOM_AWARD_DATE" }],
},
{
...data.sections.certifications,
id: "custom-certifications",
type: "certifications",
title: "Custom Certifications matrix",
items: [
{
...required(data.sections.certifications.items[0]),
id: "custom-certification-item",
date: "2024 CUSTOM_CERT_DATE",
},
],
},
{
...data.sections.publications,
id: "custom-publications",
type: "publications",
title: "Custom Publications matrix",
items: [{ ...required(data.sections.publications.items[0]), id: "custom-publication-item", date: "" }],
},
{
...data.sections.volunteer,
id: "custom-volunteer",
type: "volunteer",
title: "Custom Volunteer matrix",
items: [
{
...required(data.sections.volunteer.items[0]),
id: "custom-volunteer-item",
period: "2024 CUSTOM_VOLUNTEER_DATE",
},
],
},
];
return data;
};
const extractPages = async (bytes: Uint8Array): Promise<PdfPageText[]> => {
const loadingTask = getDocument({ data: bytes, useSystemFonts: true });
try {
const document = await loadingTask.promise;
const pages: PdfPageText[] = [];
for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) {
const page = await document.getPage(pageNumber);
const content = await page.getTextContent();
pages.push({
pageNumber,
items: content.items.filter(
(item): item is TextItem => "str" in item && typeof item.str === "string" && item.str.trim().length > 0,
),
});
}
return pages;
} finally {
await loadingTask.destroy();
}
};
const renderFixture = async (data: ResumeData, template: Template): Promise<RenderedFixture> => {
const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters<typeof renderToBuffer>[0];
let bytes = new Uint8Array();
await act(async () => {
bytes = new Uint8Array(await renderToBuffer(element));
});
const pages = await extractPages(bytes.slice());
const raster = await rasterizePdf(bytes.slice());
return { bytes, pages, raster };
};
const allItems = (result: RenderedFixture) => result.pages.flatMap((page) => page.items);
const textFor = (result: RenderedFixture) =>
allItems(result)
.map((item) => item.str)
.join(" ");
const markerItems = (result: RenderedFixture, marker: DateMarker) =>
allItems(result).filter((item) => new RegExp(`(?:^|\\s)${marker}(?=\\s|$)`).test(item.str));
const textItems = (result: RenderedFixture, text: string) => allItems(result).filter((item) => item.str.includes(text));
const coordinatesFor = (result: RenderedFixture, markers: readonly DateMarker[]) =>
Object.fromEntries(
markers.map((marker) => [
marker,
markerItems(result, marker).map((item) => ({
x: Number(item.transform[4].toFixed(2)),
y: Number(item.transform[5].toFixed(2)),
width: Number(item.width.toFixed(2)),
text: item.str,
})),
]),
);
const sha256 = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");
const fixtureEvidence = (result: RenderedFixture, coordinates: Record<string, unknown>) => {
const rasterPngs = result.raster.map((page) => encode(page));
return {
pages: result.pages.map((page) => ({ pageNumber: page.pageNumber, itemCount: page.items.length })),
rasterSha256: rasterPngs.map(sha256),
coordinates,
};
};
const requiredMarkers = (result: RenderedFixture) => {
const text = textFor(result);
for (const marker of dateMarkers) expect(text).toContain(marker);
return coordinatesFor(result, dateMarkers);
};
const writeArtifacts = (name: string, result: RenderedFixture, coordinates: Record<string, unknown>): void => {
const output = process.env.DATE_LAYOUT_ARTIFACT_DIR;
if (!output) return;
mkdirSync(output, { recursive: true });
writeFileSync(join(output, `${name}.json`), `${JSON.stringify(fixtureEvidence(result, coordinates), null, "\t")}\n`);
for (const [index, page] of result.raster.entries()) {
writeFileSync(join(output, `${name}-page-${index + 1}.png`), encode(page));
}
};
const assertFixtureBaseline = (name: string, result: RenderedFixture, coordinates: Record<string, unknown>): void => {
if (process.env.DATE_LAYOUT_ARTIFACT_DIR) return;
const evidence = fixtureEvidence(result, coordinates);
expect(JSON.parse(readFileSync(join(baselineDirectory, `${name}.json`), "utf8"))).toEqual(evidence);
for (const index of result.raster.keys()) {
const expectedPng = readFileSync(join(baselineDirectory, `${name}-page-${index + 1}.png`));
expect(sha256(expectedPng)).toBe(evidence.rasterSha256[index]);
}
};
type HostNode = {
type: string;
style?: unknown;
value?: string;
children?: HostNode[];
};
const nodeText = (node: HostNode): string => node.value ?? (node.children ?? []).map(nodeText).join("");
const findLink = (node: HostNode, target: string): HostNode | undefined => {
if (node.type === "LINK" && nodeText(node) === target) return node;
for (const child of node.children ?? []) {
const match = findLink(child, target);
if (match) return match;
}
};
const linkDecoration = async (hideLinkUnderline: boolean) => {
const data = structuredClone(defaultResumeData);
data.picture.hidden = true;
data.basics.name = "Link control";
data.basics.email = "dates@example.com";
data.metadata.page.hideLinkUnderline = hideLinkUnderline;
data.metadata.layout.pages = [{ fullWidth: true, main: [], sidebar: [] }];
const instance = pdf(createElement(ResumeDocument, { data, template: "chikorita" }) as never);
await expect.poll(() => instance.container.document).not.toBeNull();
const link = findLink(instance.container.document as HostNode, data.basics.email);
const styles = Array.isArray(link?.style) ? link.style : link?.style ? [link.style] : [];
return Object.assign({}, ...styles).textDecoration;
};
const flatten = (node: SemanticNode): SemanticNode[] => [node, ...node.children.flatMap(flatten)];
describe("date layout characterization (#3155, #2841)", () => {
it.each([
["chikorita", "ltr"],
["ditto", "ltr"],
["chikorita", "rtl"],
["ditto", "rtl"],
] as const)("records Q7 date matrix coordinates and raster for %s %s", async (template, direction) => {
const data = dateFixture(direction === "rtl" ? "ar-SA" : "en-US");
const result = await renderFixture(data, template);
const coordinates = requiredMarkers(result);
const text = textFor(result);
expect(text).toContain("Publication Empty Date");
expect(text).toContain("Custom Publications matrix");
const location = textItems(result, "Oslo LOCATION_ORDER");
expect(
location,
location.map((item) => `${item.str}@${item.transform[4]},${item.transform[5]}`).join(" | "),
).toHaveLength(1);
coordinates.LOCATION_ORDER = location.map((item) => ({
x: Number(item.transform[4].toFixed(2)),
y: Number(item.transform[5].toFixed(2)),
width: Number(item.width.toFixed(2)),
text: item.str,
}));
if (template === "chikorita" && direction === "ltr") {
const date = required(markerItems(result, "EXP_LONG")[0]);
expect(location[0]?.transform[5]).toBeGreaterThan(date.transform[5]);
}
for (const marker of dateMarkers) expect(markerItems(result, marker), marker).toHaveLength(1);
writeArtifacts(`${template}-${direction}`, result, coordinates);
assertFixtureBaseline(`${template}-${direction}`, result, coordinates);
});
it("records default date evidence for every template without claiming parity geometry", async () => {
const evidence: Record<string, unknown> = {};
for (const template of templates) {
const result = await renderFixture(dateFixture(), template);
const coordinates = coordinatesFor(result, dateMarkers);
const missingMarkers = dateMarkers.filter((marker) => markerItems(result, marker).length === 0);
expect(missingMarkers, template).toEqual(expectedMissingMarkers[template]);
evidence[template] = {
pageCount: result.pages.length,
textItemCount: allItems(result).length,
missingMarkers,
rasterSha256: result.raster.map((page) => createHash("sha256").update(page.data).digest("hex")),
coordinates,
};
}
const output = process.env.DATE_LAYOUT_ARTIFACT_DIR;
if (output) {
mkdirSync(output, { recursive: true });
writeFileSync(join(output, "all-templates.json"), `${JSON.stringify(evidence, null, "\t")}\n`);
} else {
expect(JSON.parse(readFileSync(join(baselineDirectory, "all-templates.json"), "utf8"))).toEqual(evidence);
}
});
it("verifies #2841 link underline toggle independently of date layout", async () => {
expect(await linkDecoration(false)).toBe("underline");
expect(await linkDecoration(true)).toBe("none");
});
it("verifies #2841 level design variants and explicit size control independently", () => {
const data = dateFixture();
data.sections.skills.items = [
{
id: "skill-level-control",
hidden: false,
icon: "star",
iconColor: "",
name: "Level control",
proficiency: "",
level: 3,
keywords: [],
},
];
data.metadata.layout.pages = [{ fullWidth: true, main: ["skills"], sidebar: [] }];
for (const type of ["hidden", "circle", "square", "rectangle", "rectangle-full", "progress-bar", "icon"] as const) {
data.metadata.design.level = { type, icon: "star" };
const runtime = resolveResumeRuntime({ data, template: "chikorita", mode: "legacy" });
const level = flatten(runtime.sourceTree).find((node) => node.kind === "level");
if (type === "hidden") expect(level).toBeUndefined();
else expect(level?.children.every((node) => node.attributes.type === type)).toBe(true);
}
expect(resolveLevelDisplaySizes({ bodyFontSize: 12 })).toEqual({ decorationSize: 10, levelIconExplicitSize: 14 });
expect(resolveLevelDisplaySizes({ bodyFontSize: 12, levelFontSize: 7 })).toEqual({
decorationSize: 7,
levelIconExplicitSize: 7,
});
});
});
@@ -0,0 +1,212 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { describe, expect, it, vi } from "vitest";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { createElement } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../../document";
vi.mock("@react-pdf/renderer", async (importOriginal) => ({
...(await importOriginal<typeof import("@react-pdf/renderer")>()),
}));
type PdfTextItem = { str: string };
type ParsedPdfPage = { getTextContent: () => Promise<{ items: PdfTextItem[] }> };
type ParsedPdf = { numPages: number; getPage: (pageNumber: number) => Promise<ParsedPdfPage> };
const renderPdf = async (data: ResumeData, template: Template = "onyx"): Promise<Uint8Array> => {
const renderer = await vi.importActual<typeof import("@react-pdf/renderer")>("@react-pdf/renderer");
const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters<
typeof renderer.renderToBuffer
>[0];
return new Uint8Array(await renderer.renderToBuffer(element));
};
const parsePdf = (data: Uint8Array): Promise<ParsedPdf> => getDocument({ data }).promise as Promise<ParsedPdf>;
const readPhysicalPages = async (document: ParsedPdf): Promise<string[]> => {
const pages: string[] = [];
for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) {
const page = await document.getPage(pageNumber);
const content = await page.getTextContent();
pages.push(content.items.map(({ str }) => str).join(" "));
}
return pages;
};
const makeItem = (id: string, description: string) => ({
id,
hidden: false,
company: id,
position: "Synthetic item",
location: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description,
roles: [],
});
const makeFixture = (items: ResumeData["sections"]["experience"]["items"]): ResumeData => {
const data = structuredClone(defaultResumeData);
data.picture.hidden = true;
data.basics.name = "ITEM PAGINATION HEADER";
data.metadata.template = "onyx";
data.metadata.typography.body.fontFamily = "Helvetica";
data.metadata.typography.heading.fontFamily = "Helvetica";
data.metadata.layout.pages = [{ fullWidth: true, main: ["experience"], sidebar: [] }];
data.sections.experience.title = "Experience";
data.sections.experience.items = items;
return data;
};
const numberedTokens = (prefix: string, count: number) =>
Array.from({ length: count }, (_value, index) => `${prefix}_${String(index + 1).padStart(3, "0")}`);
const numberedParagraphs = (prefix: string, count: number) =>
numberedTokens(prefix, count)
.map((token) => `<p>${token}</p>`)
.join("");
const expectTokensExactlyOnce = (pages: string[], tokens: string[]) => {
const renderedText = pages.join(" ");
for (const token of tokens) expect(renderedText.split(token)).toHaveLength(2);
};
describe("item pagination token matrix", () => {
it.each([
{
name: "item fits remaining space",
data: makeFixture([
makeItem("boundary", numberedParagraphs("BOUNDARY", 26)),
makeItem("fit", numberedParagraphs("FIT", 4)),
]),
allTokens: [...numberedTokens("BOUNDARY", 26), ...numberedTokens("FIT", 4)],
sampledTokens: ["FIT_001", "FIT_002", "FIT_003", "FIT_004"],
expectedPages: 1,
expectedTokenPages: [0, 0, 0, 0],
expectsPhysicalOverflow: false,
},
{
name: "item fits a full page but not remaining space",
data: makeFixture([
makeItem("boundary", numberedParagraphs("BOUNDARY", 48)),
makeItem("full-page", numberedParagraphs("FULL_PAGE", 42)),
]),
allTokens: [...numberedTokens("BOUNDARY", 48), ...numberedTokens("FULL_PAGE", 42)],
sampledTokens: ["FULL_PAGE_001", "FULL_PAGE_021", "FULL_PAGE_042"],
expectedPages: 3,
expectedTokenPages: [1, 1, 2],
expectsPhysicalOverflow: true,
},
{
name: "item taller than a page",
data: makeFixture([makeItem("oversized", numberedParagraphs("OVERSIZED", 180))]),
allTokens: numberedTokens("OVERSIZED", 180),
sampledTokens: ["OVERSIZED_001", "OVERSIZED_090", "OVERSIZED_180"],
expectedPages: 5,
expectedTokenPages: [0, 2, 4],
expectsPhysicalOverflow: true,
},
{
name: "two-line paragraph near boundary",
data: makeFixture([
makeItem("boundary", numberedParagraphs("BOUNDARY", 52)),
makeItem("paragraph", "<p>PARAGRAPH_001 first line<br />PARAGRAPH_002 second line</p>"),
]),
allTokens: [...numberedTokens("BOUNDARY", 52), "PARAGRAPH_001", "PARAGRAPH_002"],
sampledTokens: ["PARAGRAPH_001", "PARAGRAPH_002"],
expectedPages: 2,
expectedTokenPages: [1, 1],
expectsPhysicalOverflow: true,
},
{
name: "nested bullets",
data: makeFixture([
makeItem(
"nested",
"<ul><li><p>NESTED_001</p><ul><li><p>NESTED_002</p></li><li><p>NESTED_003</p></li></ul></li></ul>",
),
]),
allTokens: ["NESTED_001", "NESTED_002", "NESTED_003"],
sampledTokens: ["NESTED_001", "NESTED_002", "NESTED_003"],
expectedPages: 1,
expectedTokenPages: [0, 0, 0],
expectsPhysicalOverflow: false,
},
])(
"preserves every token exactly once: $name",
async ({ data, allTokens, sampledTokens, expectedPages, expectedTokenPages, expectsPhysicalOverflow }) => {
const authoredPagesBeforeRender = structuredClone(data.metadata.layout.pages);
const pages = await readPhysicalPages(await parsePdf(await renderPdf(data)));
expect(pages).toHaveLength(expectedPages);
expect(sampledTokens.map((token) => pages.findIndex((page) => page.includes(token)))).toEqual(expectedTokenPages);
expectTokensExactlyOnce(pages, allTokens);
expect(data.metadata.layout.pages).toEqual(authoredPagesBeforeRender);
if (expectsPhysicalOverflow) expect(pages.length).toBeGreaterThan(authoredPagesBeforeRender.length);
},
);
it("preserves built-in and custom items across an Azurill sidebar and main-column overflow", async () => {
const data = makeFixture([makeItem("builtin", numberedParagraphs("BUILTIN", 150))]);
data.metadata.template = "azurill";
data.metadata.layout.pages = [{ fullWidth: false, main: ["experience"], sidebar: ["profiles"] }];
data.sections.profiles.items = [
{
id: "sidebar",
hidden: false,
icon: "github-logo",
iconColor: "",
network: "SIDEBAR_001",
username: "sidebar",
website: { url: "", label: "", inlineLink: false },
},
];
data.customSections = [
{
id: "custom-experience",
type: "experience",
title: "Custom Experience",
icon: "",
columns: 1,
hidden: false,
showHeading: true,
keepTogether: false,
startOnNewPage: false,
items: [makeItem("custom", numberedParagraphs("CUSTOM", 30))],
},
];
data.metadata.layout.pages[0]?.main.push("custom-experience");
const authoredPagesBeforeRender = structuredClone(data.metadata.layout.pages);
const pages = await readPhysicalPages(await parsePdf(await renderPdf(data, "azurill")));
expect(pages).toHaveLength(5);
expect(
["BUILTIN_001", "BUILTIN_150", "CUSTOM_001", "CUSTOM_030", "SIDEBAR_001"].map((token) =>
pages.findIndex((page) => page.includes(token)),
),
).toEqual([0, 3, 4, 4, 0]);
expectTokensExactlyOnce(pages, [...numberedTokens("BUILTIN", 150), ...numberedTokens("CUSTOM", 30), "SIDEBAR_001"]);
expect(data.metadata.layout.pages).toEqual(authoredPagesBeforeRender);
expect(pages.length).toBeGreaterThan(authoredPagesBeforeRender.length);
});
it("records unsafe renderer fallback for an oversized non-wrapping item", async () => {
const renderer = await vi.importActual<typeof import("@react-pdf/renderer")>("@react-pdf/renderer");
const tokens = Array.from({ length: 180 }, (_value, index) => `UNSAFE_${String(index + 1).padStart(3, "0")}`);
const item = createElement(
renderer.View,
{ wrap: false },
...tokens.map((token) => createElement(renderer.View, { key: token }, createElement(renderer.Text, null, token))),
);
const element = createElement(
renderer.Document,
null,
createElement(renderer.Page, { size: "A4" }, item),
) as Parameters<typeof renderer.renderToBuffer>[0];
const pages = await readPhysicalPages(await parsePdf(new Uint8Array(await renderer.renderToBuffer(element))));
// React PDF warns that an oversized View cannot wrap and drops content; this is the blocker for item Keep together.
expect(pages.join(" ")).toContain("UNSAFE_001");
expect(pages.join(" ")).not.toContain("UNSAFE_180");
});
});
@@ -0,0 +1,41 @@
# Plan 24 Step 1 — date layout characterization
Generation revision: `58ee4eead7843105da20f8a177e7d250b24c3a87` (pre-remediation characterization merge).
Comparison ref: `368858a56fc9c3152b540c39829908e2c3ea04c5` (`origin/main` at review).
Branch: `codex/issue-3155-date-layout-characterization`.
JSON and PNG files are executable baselines. Raw PDFs are intentionally omitted because renderer metadata is nondeterministic.
The durable fixture is [`date-layout.test.tsx`](../../src/templates/shared/date-layout.test.tsx). It renders the same unchanged resume data through Chikorita and Ditto in LTR and RTL modes, extracts PDF text coordinates, rasterizes each PDF page, and records default evidence for all 15 templates.
Remediation details: [`REMEDIATION.md`](REMEDIATION.md).
## Matrix coverage
- Standard date-bearing sections: Awards (`date`), Certifications (`date`), Education (`period`), Experience (`period` and two nested role periods), Projects (`period`), Publications (`date`), and Volunteer (`period`).
- Custom equivalents: all seven supported types, including a custom experience role and a custom publication with an empty date.
- Edge values: long localized German date, blank location, blank period, mixed dated/undated entries, and RTL locale (`ar-SA`).
- #2841 controls: `hideLinkUnderline` resolves independently to `underline`/`none`; all seven level design types are represented in the semantic tree; default and explicit level display sizes resolve independently.
## Current output evidence
The Chikorita LTR JSON records current default coordinates such as:
- Long Experience date: `x=381.24`, `width=186.04`.
- Chikorita Experience location: `x=452.35`, `y=706.39`; its date is `y=690.64`, so current PDF coordinates place location before date in the existing split header.
- Parent role dates: `x=488.00` and `x=486.54`.
- Education period combined with location: `x=416.99`, `width=150.29`.
- Project period: `x=465.16`.
- Award date: `x=475.41`.
- Certification date: `x=484.71`.
- Volunteer period: `x=450.91`.
These are observations of current disabled/default output, not proposed geometry. All-template JSON records page counts, text-item counts, raster SHA-256 values, coordinates, and any missing marker. Meowth omits the location-only marker in the blank-period Experience case; this is recorded as default behavior rather than normalized in the fixture.
## Issue disposition and gate
**#3155:** Q4, Q5, Q7, and Q8 are reflected in the fixture contract: optional dedicated date column, logical-start placement, all free-text date sections/custom equivalents, and all templates with default layout preserved. Current output confirms dates are trailing/inline in the existing layout; it does not establish the future dedicated-column geometry.
**#2841:** Date/location ordering remains a separate layout concern. Existing `hideLinkUnderline` and level design/size controls are present and independently verified; no duplicate schema/UI/runtime controls were added. Historical Chikorita v4 pixel parity remains unverified because no reproducible v4 reference was available.
**STOP — exact missing visual-geometry gate:** Q6 approves user-controlled per-section width and wrapping but does not specify width units, minimum/maximum bounds, or initial/default width geometry. Narrow-width behavior can be characterized, but implementation must wait for those numeric/geometry inputs; this step intentionally adds no date-column schema, UI, or renderer behavior.
@@ -0,0 +1,14 @@
# Date-layout baseline remediation
- Normal focused runs compare Chikorita/Ditto LTR+RTL JSON coordinates, page counts, and PNG hashes against checked-in artifacts.
- All-template evidence asserts exact `missingMarkers`: `[]` for 14 templates and `['EXP_NO_PERIOD']` for Meowth, the documented blank-period exception.
- Raw PDFs are omitted; renderer `CreationDate` metadata made PDF bytes nondeterministic.
- Provenance is fixed to generation revision `58ee4eead7843105da20f8a177e7d250b24c3a87` and comparison ref `368858a56fc9c3152b540c39829908e2c3ea04c5`.
Verification:
- Focused Vitest: `pnpm exec vitest run src/templates/shared/date-layout.test.tsx` — 1 file, 7 tests, twice with baseline comparison.
- PDF typecheck: `pnpm --filter @reactive-resume/pdf run typecheck` — pass.
- Targeted Biome: `pnpm exec biome check packages/pdf/src/templates/shared/date-layout.test.tsx` — pass.
- Boundaries: `pnpm exec turbo boundaries` — pass.
- Diff checks: `git diff --check` — pass.
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

@@ -0,0 +1,162 @@
{
"pages": [
{
"pageNumber": 1,
"itemCount": 49
},
{
"pageNumber": 2,
"itemCount": 38
}
],
"rasterSha256": [
"5b673865c8e5c8d847f5afad2d80d541639b5db8276e25b18ee4572a79564066",
"2e72c805845fea245b51427b104e901538af8e5c594b51810e5ee23df5bb23a3"
],
"coordinates": {
"EXP_LONG": [
{
"x": 381.24,
"y": 690.64,
"width": 186.04,
"text": "März 2018 – Dezember 2024 EXP_LONG"
}
],
"EXP_NO_LOCATION": [
{
"x": 446.21,
"y": 606.64,
"width": 121.07,
"text": "2016 EXP_NO_LOCATION"
}
],
"ROLE_ONE": [
{
"x": 488,
"y": 674.89,
"width": 79.28,
"text": "2020 ROLE_ONE"
}
],
"ROLE_TWO": [
{
"x": 486.54,
"y": 643.39,
"width": 80.74,
"text": "2022 ROLE_TWO"
}
],
"EXP_NO_PERIOD": [
{
"x": 459.47,
"y": 569.89,
"width": 107.81,
"text": "Oslo EXP_NO_PERIOD"
}
],
"EDU_DATE": [
{
"x": 416.99,
"y": 480.04,
"width": 150.29,
"text": "Education City • 2019 EDU_DATE"
}
],
"PROJECT_DATE": [
{
"x": 465.16,
"y": 384.94,
"width": 102.12,
"text": "2020 PROJECT_DATE"
}
],
"AWARD_DATE": [
{
"x": 475.41,
"y": 310.84,
"width": 91.87,
"text": "2021 AWARD_DATE"
}
],
"CERT_DATE": [
{
"x": 484.71,
"y": 220.99,
"width": 82.57,
"text": "2022 CERT_DATE"
}
],
"VOLUNTEER_DATE": [
{
"x": 450.91,
"y": 41.29,
"width": 116.37,
"text": "2023 VOLUNTEER_DATE"
}
],
"CUSTOM_EXP_DATE": [
{
"x": 443.13,
"y": 723.04,
"width": 124.15,
"text": "2024 CUSTOM_EXP_DATE"
}
],
"CUSTOM_ROLE_DATE": [
{
"x": 436.11,
"y": 707.29,
"width": 131.17,
"text": "2024 CUSTOM_ROLE_DATE"
}
],
"CUSTOM_EDU_DATE": [
{
"x": 368.5,
"y": 633.19,
"width": 198.78,
"text": "Education City • 2024 CUSTOM_EDU_DATE"
}
],
"CUSTOM_PROJECT_DATE": [
{
"x": 416.67,
"y": 559.09,
"width": 150.61,
"text": "2024 CUSTOM_PROJECT_DATE"
}
],
"CUSTOM_AWARD_DATE": [
{
"x": 426.92,
"y": 484.99,
"width": 140.36,
"text": "2024 CUSTOM_AWARD_DATE"
}
],
"CUSTOM_CERT_DATE": [
{
"x": 436.22,
"y": 395.14,
"width": 131.06,
"text": "2024 CUSTOM_CERT_DATE"
}
],
"CUSTOM_VOLUNTEER_DATE": [
{
"x": 402.42,
"y": 215.44,
"width": 164.86,
"text": "2024 CUSTOM_VOLUNTEER_DATE"
}
],
"LOCATION_ORDER": [
{
"x": 452.35,
"y": 706.39,
"width": 114.93,
"text": "Oslo LOCATION_ORDER"
}
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

@@ -0,0 +1,162 @@
{
"pages": [
{
"pageNumber": 1,
"itemCount": 47
},
{
"pageNumber": 2,
"itemCount": 41
}
],
"rasterSha256": [
"e192c24c35e5442b4ae0aa278b84f7991b54c4be5fb82ca13642dc6f2c6bde32",
"631686b14720c12e67f426ab56c65343a83997836bc7d9db371b81f8b02f7d2e"
],
"coordinates": {
"EXP_LONG": [
{
"x": 381.24,
"y": 684.52,
"width": 186.04,
"text": "März 2018 – Dezember 2024 EXP_LONG"
}
],
"EXP_NO_LOCATION": [
{
"x": 446.21,
"y": 588.28,
"width": 121.07,
"text": "EXP_NO_LOCATION 2016"
}
],
"ROLE_ONE": [
{
"x": 28,
"y": 668.77,
"width": 79.28,
"text": "ROLE_ONE 2020"
}
],
"ROLE_TWO": [
{
"x": 28,
"y": 631.15,
"width": 80.74,
"text": "ROLE_TWO 2022"
}
],
"EXP_NO_PERIOD": [
{
"x": 459.47,
"y": 551.53,
"width": 107.81,
"text": "Oslo EXP_NO_PERIOD"
}
],
"EDU_DATE": [
{
"x": 416.99,
"y": 461.68,
"width": 150.29,
"text": "Education City • 2019 EDU_DATE"
}
],
"PROJECT_DATE": [
{
"x": 28,
"y": 360.46,
"width": 102.12,
"text": "PROJECT_DATE 2020"
}
],
"AWARD_DATE": [
{
"x": 475.41,
"y": 280.24,
"width": 91.87,
"text": "AWARD_DATE 2021"
}
],
"CERT_DATE": [
{
"x": 28,
"y": 184.27,
"width": 82.57,
"text": "CERT_DATE 2022"
}
],
"VOLUNTEER_DATE": [
{
"x": 28,
"y": 789.54,
"width": 116.37,
"text": "VOLUNTEER_DATE 2023"
}
],
"CUSTOM_EXP_DATE": [
{
"x": 443.13,
"y": 677.82,
"width": 124.15,
"text": "CUSTOM_EXP_DATE 2024"
}
],
"CUSTOM_ROLE_DATE": [
{
"x": 28,
"y": 662.07,
"width": 131.17,
"text": "CUSTOM_ROLE_DATE 2024"
}
],
"CUSTOM_EDU_DATE": [
{
"x": 368.5,
"y": 587.97,
"width": 198.78,
"text": "Education City • 2024 CUSTOM_EDU_DATE"
}
],
"CUSTOM_PROJECT_DATE": [
{
"x": 28,
"y": 507.75,
"width": 150.61,
"text": "CUSTOM_PROJECT_DATE 2024"
}
],
"CUSTOM_AWARD_DATE": [
{
"x": 426.92,
"y": 427.53,
"width": 140.36,
"text": "CUSTOM_AWARD_DATE 2024"
}
],
"CUSTOM_CERT_DATE": [
{
"x": 28,
"y": 331.56,
"width": 131.06,
"text": "CUSTOM_CERT_DATE 2024"
}
],
"CUSTOM_VOLUNTEER_DATE": [
{
"x": 28,
"y": 139.62,
"width": 164.86,
"text": "CUSTOM_VOLUNTEER_DATE 2024"
}
],
"LOCATION_ORDER": [
{
"x": 452.35,
"y": 700.27,
"width": 114.93,
"text": "Oslo LOCATION_ORDER"
}
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

@@ -0,0 +1,162 @@
{
"pages": [
{
"pageNumber": 1,
"itemCount": 47
},
{
"pageNumber": 2,
"itemCount": 40
}
],
"rasterSha256": [
"055026dce70c3ae91d94283c2cfa120ac5c2cddf56c220913d93d0b58223deb2",
"24e180ef8b68eb27e25ded90d608696a47212ac9d954a5c05e3d77b88d71dbc5"
],
"coordinates": {
"EXP_LONG": [
{
"x": 381.24,
"y": 655.24,
"width": 186.04,
"text": "März 2018 – Dezember 2024 EXP_LONG"
}
],
"EXP_NO_LOCATION": [
{
"x": 446.21,
"y": 571.24,
"width": 121.07,
"text": "2016 EXP_NO_LOCATION"
}
],
"ROLE_ONE": [
{
"x": 488,
"y": 639.49,
"width": 79.28,
"text": "2020 ROLE_ONE"
}
],
"ROLE_TWO": [
{
"x": 486.54,
"y": 607.99,
"width": 80.74,
"text": "2022 ROLE_TWO"
}
],
"EXP_NO_PERIOD": [
{
"x": 459.47,
"y": 534.49,
"width": 107.81,
"text": "Oslo EXP_NO_PERIOD"
}
],
"EDU_DATE": [
{
"x": 416.99,
"y": 445.49,
"width": 150.29,
"text": "Education City • 2019 EDU_DATE"
}
],
"PROJECT_DATE": [
{
"x": 465.16,
"y": 351.24,
"width": 102.12,
"text": "2020 PROJECT_DATE"
}
],
"AWARD_DATE": [
{
"x": 475.41,
"y": 277.99,
"width": 91.87,
"text": "2021 AWARD_DATE"
}
],
"CERT_DATE": [
{
"x": 484.71,
"y": 188.99,
"width": 82.57,
"text": "2022 CERT_DATE"
}
],
"VOLUNTEER_DATE": [
{
"x": 450.91,
"y": 812.89,
"width": 116.37,
"text": "2023 VOLUNTEER_DATE"
}
],
"CUSTOM_EXP_DATE": [
{
"x": 443.13,
"y": 708.14,
"width": 124.15,
"text": "2024 CUSTOM_EXP_DATE"
}
],
"CUSTOM_ROLE_DATE": [
{
"x": 436.11,
"y": 692.39,
"width": 131.17,
"text": "2024 CUSTOM_ROLE_DATE"
}
],
"CUSTOM_EDU_DATE": [
{
"x": 368.5,
"y": 619.14,
"width": 198.78,
"text": "Education City • 2024 CUSTOM_EDU_DATE"
}
],
"CUSTOM_PROJECT_DATE": [
{
"x": 416.67,
"y": 545.89,
"width": 150.61,
"text": "2024 CUSTOM_PROJECT_DATE"
}
],
"CUSTOM_AWARD_DATE": [
{
"x": 426.92,
"y": 472.64,
"width": 140.36,
"text": "2024 CUSTOM_AWARD_DATE"
}
],
"CUSTOM_CERT_DATE": [
{
"x": 436.22,
"y": 383.64,
"width": 131.06,
"text": "2024 CUSTOM_CERT_DATE"
}
],
"CUSTOM_VOLUNTEER_DATE": [
{
"x": 402.42,
"y": 205.64,
"width": 164.86,
"text": "2024 CUSTOM_VOLUNTEER_DATE"
}
],
"LOCATION_ORDER": [
{
"x": 452.35,
"y": 670.99,
"width": 114.93,
"text": "Oslo LOCATION_ORDER"
}
]
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

@@ -0,0 +1,162 @@
{
"pages": [
{
"pageNumber": 1,
"itemCount": 46
},
{
"pageNumber": 2,
"itemCount": 42
}
],
"rasterSha256": [
"25e05e71a84e043e3e925f2580044d7493ec6f51fc3e22454edcbe562767f5a6",
"ea56e1dc8f6e96999270a7bc20864cbffb3723b07d3e72557e4828440b7f044d"
],
"coordinates": {
"EXP_LONG": [
{
"x": 381.24,
"y": 649.12,
"width": 186.04,
"text": "März 2018 – Dezember 2024 EXP_LONG"
}
],
"EXP_NO_LOCATION": [
{
"x": 446.21,
"y": 552.88,
"width": 121.07,
"text": "EXP_NO_LOCATION 2016"
}
],
"ROLE_ONE": [
{
"x": 28,
"y": 633.37,
"width": 79.28,
"text": "ROLE_ONE 2020"
}
],
"ROLE_TWO": [
{
"x": 28,
"y": 595.75,
"width": 80.74,
"text": "ROLE_TWO 2022"
}
],
"EXP_NO_PERIOD": [
{
"x": 459.47,
"y": 516.13,
"width": 107.81,
"text": "Oslo EXP_NO_PERIOD"
}
],
"EDU_DATE": [
{
"x": 416.99,
"y": 427.13,
"width": 150.29,
"text": "Education City • 2019 EDU_DATE"
}
],
"PROJECT_DATE": [
{
"x": 28,
"y": 326.76,
"width": 102.12,
"text": "PROJECT_DATE 2020"
}
],
"AWARD_DATE": [
{
"x": 475.41,
"y": 247.39,
"width": 91.87,
"text": "AWARD_DATE 2021"
}
],
"CERT_DATE": [
{
"x": 28,
"y": 152.27,
"width": 82.57,
"text": "CERT_DATE 2022"
}
],
"VOLUNTEER_DATE": [
{
"x": 28,
"y": 749.27,
"width": 116.37,
"text": "VOLUNTEER_DATE 2023"
}
],
"CUSTOM_EXP_DATE": [
{
"x": 443.13,
"y": 638.4,
"width": 124.15,
"text": "CUSTOM_EXP_DATE 2024"
}
],
"CUSTOM_ROLE_DATE": [
{
"x": 28,
"y": 622.65,
"width": 131.17,
"text": "CUSTOM_ROLE_DATE 2024"
}
],
"CUSTOM_EDU_DATE": [
{
"x": 368.5,
"y": 549.4,
"width": 198.78,
"text": "Education City • 2024 CUSTOM_EDU_DATE"
}
],
"CUSTOM_PROJECT_DATE": [
{
"x": 28,
"y": 470.03,
"width": 150.61,
"text": "CUSTOM_PROJECT_DATE 2024"
}
],
"CUSTOM_AWARD_DATE": [
{
"x": 426.92,
"y": 390.66,
"width": 140.36,
"text": "CUSTOM_AWARD_DATE 2024"
}
],
"CUSTOM_CERT_DATE": [
{
"x": 28,
"y": 295.54,
"width": 131.06,
"text": "CUSTOM_CERT_DATE 2024"
}
],
"CUSTOM_VOLUNTEER_DATE": [
{
"x": 28,
"y": 105.3,
"width": 164.86,
"text": "CUSTOM_VOLUNTEER_DATE 2024"
}
],
"LOCATION_ORDER": [
{
"x": 452.35,
"y": 664.87,
"width": 114.93,
"text": "Oslo LOCATION_ORDER"
}
]
}
}
@@ -0,0 +1,5 @@
export function assertPdfDownloadReceived(downloadStatus: string): asserts downloadStatus is "received" {
if (downloadStatus !== "received") {
throw new Error(`PDF download diagnostic did not receive a download: ${downloadStatus}`);
}
}
@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import { assertPdfDownloadReceived } from "./offline-font-diagnostic";
import { classifyRasterInk, classifyRasterMeasurements, locatePdfMarkerBoxes } from "./offline-font-markers";
const marker = { name: "cjk", marker: "简体中文" } as const;
const item = (str: string, x = 10, y = 100, width = str.length * 5) => ({
str,
x,
y,
width,
height: 12,
});
describe("locatePdfMarkerBoxes", () => {
it("rejects duplicate marker sources instead of selecting an arbitrary crop", () => {
const result = locatePdfMarkerBoxes([item(marker.marker), item(marker.marker, 10, 70)], [marker], 200);
expect(result).toEqual([{ ...marker, box: null }]);
});
it("unions text items when one marker is split across PDF text items", () => {
const result = locatePdfMarkerBoxes([item("简体"), item("中文", 20)], [marker], 200);
expect(result[0]?.box).toEqual({ x: 10, y: 88, width: 20, height: 12 });
});
it("rejects text-item neighbors that could contaminate marker-local raster evidence", () => {
const result = locatePdfMarkerBoxes([item(`prefix${marker.marker}suffix`)], [marker], 200);
expect(result).toEqual([{ ...marker, box: null }]);
});
});
describe("classifyRasterInk", () => {
it("does not treat a blank crop as visible", () => {
expect(classifyRasterInk({ inkPixels: 0, interiorInk: 0, trimmedWidth: 0, trimmedHeight: 0 })).toBe("blank");
});
it("does not treat tofu-like outline as visible", () => {
expect(classifyRasterInk({ inkPixels: 100, interiorInk: 0, trimmedWidth: 20, trimmedHeight: 20 })).toBe(
"tofu-like",
);
});
it("classifies raw browser measurements through shared raster classifier", () => {
expect(
classifyRasterMeasurements([
{ name: "blank", located: true, inkPixels: 0, interiorInk: 0, trimmedWidth: 0, trimmedHeight: 0 },
{ name: "tofu", located: true, inkPixels: 100, interiorInk: 0, trimmedWidth: 20, trimmedHeight: 20 },
{ name: "visible", located: true, inkPixels: 100, interiorInk: 20, trimmedWidth: 20, trimmedHeight: 20 },
{ name: "missing", located: false, inkPixels: 0, interiorInk: 0, trimmedWidth: 0, trimmedHeight: 0 },
]),
).toEqual([
{ name: "blank", status: "blank", inkPixels: 0, trimmedWidth: 0, trimmedHeight: 0 },
{ name: "tofu", status: "tofu-like", inkPixels: 100, trimmedWidth: 20, trimmedHeight: 20 },
{ name: "visible", status: "visible", inkPixels: 100, trimmedWidth: 20, trimmedHeight: 20 },
{ name: "missing", status: "not-located", inkPixels: 0, trimmedWidth: 0, trimmedHeight: 0 },
]);
});
});
describe("assertPdfDownloadReceived", () => {
it("fails diagnostic when browser PDF download errors", () => {
expect(() => assertPdfDownloadReceived("download-error")).toThrow(
"PDF download diagnostic did not receive a download",
);
});
it("accepts received browser PDF download", () => {
expect(() => assertPdfDownloadReceived("received")).not.toThrow();
});
});
+133
View File
@@ -0,0 +1,133 @@
export type PdfTextItem = {
str: string;
x: number;
y: number;
width: number;
height: number;
};
export type PdfMarker = {
name: string;
marker: string;
};
type PdfMarkerBox = {
x: number;
y: number;
width: number;
height: number;
};
export type PdfMarkerLocation<TMarker extends PdfMarker = PdfMarker> = TMarker & {
box: PdfMarkerBox | null;
};
export type RasterInkMeasurements = {
inkPixels: number;
interiorInk: number;
trimmedWidth: number;
trimmedHeight: number;
};
export type RasterGlyphStatus = "visible" | "blank" | "tofu-like";
export type RasterGlyphMeasurement<TName extends string = string> = RasterInkMeasurements & {
name: TName;
located: boolean;
};
export type RasterGlyphEvidence<TName extends string = string> = Omit<RasterInkMeasurements, "interiorInk"> & {
name: TName;
status: RasterGlyphStatus | "not-located";
};
type TextItemRange = PdfTextItem & {
start: number;
end: number;
};
const textItemRanges = (textItems: PdfTextItem[]): TextItemRange[] => {
let offset = 0;
return textItems.map((item) => {
const start = offset;
offset += item.str.length;
return { ...item, start, end: offset };
});
};
const findMarkerOccurrences = (text: string, marker: string): number[] => {
if (marker.length === 0) return [];
const occurrences: number[] = [];
let start = 0;
while (start <= text.length - marker.length) {
const match = text.indexOf(marker, start);
if (match === -1) break;
occurrences.push(match);
start = match + marker.length;
}
return occurrences;
};
export const locatePdfMarkerBoxes = <TMarker extends PdfMarker>(
textItems: PdfTextItem[],
markers: readonly TMarker[],
pageHeight: number,
): PdfMarkerLocation<TMarker>[] => {
const ranges = textItemRanges(textItems);
const text = textItems.map((item) => item.str).join("");
return markers.map((marker) => {
const [start] = findMarkerOccurrences(text, marker.marker);
const end = start === undefined ? undefined : start + marker.marker.length;
if (start === undefined || end === undefined || findMarkerOccurrences(text, marker.marker).length !== 1) {
return { ...marker, box: null };
}
const coveredRanges = ranges.filter((range) => range.start < end && range.end > start);
const firstRange = coveredRanges[0];
const lastRange = coveredRanges.at(-1);
if (!firstRange || !lastRange) return { ...marker, box: null };
const firstLocalStart = start - firstRange.start;
const lastLocalEnd = end - lastRange.start;
const leadingText = firstRange.str.slice(0, firstLocalStart);
const trailingText = lastRange.str.slice(lastLocalEnd);
if (leadingText.trim() || trailingText.trim()) return { ...marker, box: null };
const left = Math.min(...coveredRanges.map((range) => range.x));
const top = Math.min(...coveredRanges.map((range) => pageHeight - range.y - Math.max(range.height, 1)));
const right = Math.max(...coveredRanges.map((range) => range.x + Math.max(range.width, 1)));
const bottom = Math.max(...coveredRanges.map((range) => pageHeight - range.y));
return {
...marker,
box: {
x: left,
y: top,
width: Math.max(right - left, 1),
height: Math.max(bottom - top, 1),
},
};
});
};
export const classifyRasterInk = ({
inkPixels,
interiorInk,
trimmedWidth,
trimmedHeight,
}: RasterInkMeasurements): RasterGlyphStatus => {
if (inkPixels === 0) return "blank";
const interiorRatio = interiorInk / Math.max(inkPixels, 1);
return interiorRatio < 0.08 && trimmedWidth >= 8 && trimmedHeight >= 8 ? "tofu-like" : "visible";
};
export const classifyRasterMeasurements = <TName extends string>(
measurements: readonly RasterGlyphMeasurement<TName>[],
): RasterGlyphEvidence<TName>[] =>
measurements.map(({ name, located, interiorInk, ...measurements }) => ({
...measurements,
name,
status: located ? classifyRasterInk({ ...measurements, interiorInk }) : "not-located",
}));
+68
View File
@@ -0,0 +1,68 @@
import type { Page } from "@playwright/test";
import { Pool } from "pg";
export const offlineFontScriptSamples = [
{ name: "latin-punctuation", marker: "Latin punctuation • — “quotes” €" },
{ name: "cjk", marker: "简体中文" },
{ name: "arabic", marker: "العربية" },
{ name: "hebrew", marker: "עברית" },
{ name: "thai", marker: "ไทย" },
{ name: "emoji", marker: "Emoji 🚀" },
] as const;
const offlineFontFixtureSummary = offlineFontScriptSamples.map(({ marker }) => `<p>${marker}</p>`).join("");
export type OfflineFontResumeFixture = {
builderURL: string;
slug: string;
username: string;
};
/** Seeds one stable multilingual document so each diagnostic surface uses identical glyph input. */
export async function seedOfflineFontResume(page: Page): Promise<OfflineFontResumeFixture> {
const builderURL = page.url();
const resumeId = new URL(builderURL).pathname.split("/").at(-1);
const databaseURL = process.env.DATABASE_URL;
if (!resumeId || !databaseURL) throw new Error("Offline font fixture requires builder URL and DATABASE_URL.");
const pool = new Pool({ connectionString: databaseURL });
try {
const patches: Array<{ path: string[]; value: unknown }> = [
{ path: ["picture", "hidden"], value: true },
{ path: ["basics", "name"], value: "Offline Font Diagnostic Fixture" },
{ path: ["basics", "headline"], value: "Offline font diagnostic" },
// Keep each marker in one dedicated block so raster evidence can map each script to a local PDF crop.
{ path: ["summary", "content"], value: offlineFontFixtureSummary },
{ path: ["metadata", "typography", "body", "fontFamily"], value: "IBM Plex Serif" },
{ path: ["metadata", "typography", "body", "fontWeights"], value: ["400", "700"] },
{ path: ["metadata", "typography", "heading", "fontFamily"], value: "IBM Plex Serif" },
{ path: ["metadata", "typography", "heading", "fontWeights"], value: ["400", "700"] },
];
for (const patch of patches) {
await pool.query(
`update "resume"
set data = jsonb_set(data, $2::text[], $3::jsonb, true),
updated_at = now()
where id = $1`,
[resumeId, patch.path, JSON.stringify(patch.value)],
);
}
await pool.query('update "resume" set is_public = true, updated_at = now() where id = $1', [resumeId]);
const result = await pool.query<{ slug: string; user_id: string }>(
'select slug, user_id from "resume" where id = $1',
[resumeId],
);
const row = result.rows[0];
if (!row) throw new Error("Offline font fixture resume was not found after seeding.");
const owner = await pool.query<{ username: string }>('select username from "user" where id = $1', [row.user_id]);
const ownerUsername = owner.rows[0]?.username;
if (!ownerUsername) throw new Error("Offline font fixture owner was not found after seeding.");
return { builderURL, slug: row.slug, username: ownerUsername };
} finally {
await pool.end();
}
}
+446
View File
@@ -0,0 +1,446 @@
import type { Browser, BrowserContext, Page, TestInfo } from "@playwright/test";
import type {
RasterGlyphEvidence as MarkerRasterGlyphEvidence,
RasterGlyphMeasurement as MarkerRasterGlyphMeasurement,
} from "../fixtures/offline-font-markers";
import { readFile, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { assertPdfDownloadReceived } from "../fixtures/offline-font-diagnostic";
import { classifyRasterMeasurements, locatePdfMarkerBoxes } from "../fixtures/offline-font-markers";
import { offlineFontScriptSamples, seedOfflineFontResume } from "../fixtures/offline-fonts";
import { createSampleResumeFromDashboard, openSidebarSection } from "../fixtures/resume";
import { expect, test } from "../fixtures/test";
const diagnosticEnabled = process.env.OFFLINE_FONT_DIAGNOSTIC === "1";
const serverRestartFlag = process.env.OFFLINE_FONT_DIAGNOSTIC_SERVER_RESTARTED === "1";
type BlockedRequest = {
hostname: string;
path: string;
};
type ColdContext = {
context: BrowserContext;
blockedRequests: BlockedRequest[];
};
type PdfMarkers = Record<(typeof offlineFontScriptSamples)[number]["name"], boolean>;
type RasterGlyphEvidence = MarkerRasterGlyphEvidence<(typeof offlineFontScriptSamples)[number]["name"]>;
type RasterGlyphMeasurement = MarkerRasterGlyphMeasurement<(typeof offlineFontScriptSamples)[number]["name"]>;
type PdfRasterEvidence = {
rasterDataUrl: string;
textLayerMarkers: PdfMarkers;
referenceGlyphs: RasterGlyphEvidence[];
previewGlyphs: RasterGlyphEvidence[];
};
test.describe("offline font diagnostic", () => {
test.describe.configure({ mode: "serial" });
test.skip(!diagnosticEnabled, "Set OFFLINE_FONT_DIAGNOSTIC=1 to run network diagnostics.");
test.setTimeout(120_000);
async function createColdContext(browser: Browser, page: Page, testInfo: TestInfo): Promise<ColdContext> {
const baseURL = String(testInfo.project.use.baseURL ?? "http://localhost:3000");
const allowedOrigin = new URL(baseURL).origin;
const context = await browser.newContext({
baseURL,
serviceWorkers: "block",
storageState: await page.context().storageState(),
});
const blockedRequests: BlockedRequest[] = [];
await context.route("**/*", async (route) => {
const requestURL = new URL(route.request().url());
if (requestURL.origin === allowedOrigin || requestURL.protocol === "data:" || requestURL.protocol === "blob:") {
await route.continue();
return;
}
blockedRequests.push({ hostname: requestURL.hostname, path: requestURL.pathname });
await route.abort("blockedbyclient");
});
return { context, blockedRequests };
}
function extractedMarkerResult(text: string): PdfMarkers {
return Object.fromEntries(
offlineFontScriptSamples.map((sample) => [sample.name, text.includes(sample.marker)]),
) as PdfMarkers;
}
function networkStatus(blockedRequests: BlockedRequest[]) {
return blockedRequests.length > 0 ? "network-error" : "no-browser-network-error";
}
async function readPdfText(bytes: Uint8Array) {
const loadingTask = getDocument({ data: bytes, useSystemFonts: false });
try {
const document = await loadingTask.promise;
const pages: string[] = [];
for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) {
const pdfPage = await document.getPage(pageNumber);
pages.push(
(await pdfPage.getTextContent()).items.flatMap((item) => ("str" in item ? [item.str] : [])).join(""),
);
}
return pages.join("\n");
} finally {
await loadingTask.destroy();
}
}
async function capturePdfBytes(page: Page) {
await page.addInitScript(() => {
const read = Blob.prototype.arrayBuffer;
Blob.prototype.arrayBuffer = async function () {
const buffer = await read.call(this);
const bytes = new Uint8Array(buffer);
if (String.fromCharCode(...bytes.subarray(0, 5)) === "%PDF-") {
(window as Window & { offlineFontPdfBytes?: number[] }).offlineFontPdfBytes = Array.from(bytes);
}
return buffer;
};
});
}
async function renderPdfRasterEvidence(page: Page, bytes: Uint8Array): Promise<PdfRasterEvidence> {
const require = createRequire(`${process.cwd()}/package.json`);
await page.route("**/__offline_font_pdfjs/*", async (route) => {
const worker = new URL(route.request().url()).pathname.endsWith("worker.mjs");
await route.fulfill({
contentType: "text/javascript",
path: require.resolve(`pdfjs-dist/legacy/build/${worker ? "pdf.worker.mjs" : "pdf.mjs"}`),
});
});
const textEvidence = await page.evaluate(
async ({ bytes }) => {
const moduleUrl = `${location.origin}/__offline_font_pdfjs/pdf.mjs`;
const pdfjs: typeof import("pdfjs-dist/legacy/build/pdf.mjs") = await import(moduleUrl);
pdfjs.GlobalWorkerOptions.workerSrc = `${location.origin}/__offline_font_pdfjs/worker.mjs`;
const loadingTask = pdfjs.getDocument({ data: Uint8Array.from(bytes), useSystemFonts: false });
try {
const pdfDocument = await loadingTask.promise;
const pdfPage = await pdfDocument.getPage(1);
const textContent = await pdfPage.getTextContent();
return {
pageHeight: pdfPage.getViewport({ scale: 1 }).height,
textItems: textContent.items.flatMap((item) =>
"str" in item
? [
{
str: item.str,
x: item.transform[4] ?? 0,
y: item.transform[5] ?? 0,
width: item.width,
height: Math.max(item.height, Math.abs(item.transform[3] ?? 0), 1),
},
]
: [],
),
};
} finally {
await loadingTask.destroy();
}
},
{ bytes: Array.from(bytes) },
);
const boxes = locatePdfMarkerBoxes(textEvidence.textItems, offlineFontScriptSamples, textEvidence.pageHeight);
const textLayerMarkers = extractedMarkerResult(textEvidence.textItems.map(({ str }) => str).join(""));
const rasterEvidence = await page.evaluate(
async ({ bytes, boxes }) => {
const moduleUrl = `${location.origin}/__offline_font_pdfjs/pdf.mjs`;
const pdfjs: typeof import("pdfjs-dist/legacy/build/pdf.mjs") = await import(moduleUrl);
pdfjs.GlobalWorkerOptions.workerSrc = `${location.origin}/__offline_font_pdfjs/worker.mjs`;
const loadingTask = pdfjs.getDocument({ data: Uint8Array.from(bytes), useSystemFonts: false });
try {
const pdfDocument = await loadingTask.promise;
const pdfPage = await pdfDocument.getPage(1);
const baseViewport = pdfPage.getViewport({ scale: 1 });
const rasterScale = 4;
const viewport = pdfPage.getViewport({ scale: rasterScale });
const raster = globalThis.document.createElement("canvas");
raster.width = Math.ceil(viewport.width);
raster.height = Math.ceil(viewport.height);
const rasterContext = raster.getContext("2d");
if (!rasterContext) throw new Error("Missing PDF raster context.");
await pdfPage.render({
canvas: raster,
canvasContext: rasterContext,
viewport,
annotationMode: pdfjs.AnnotationMode.DISABLE,
background: "white",
}).promise;
function measure(canvas: HTMLCanvasElement, scale: number): RasterGlyphMeasurement[] {
const context = canvas.getContext("2d");
if (!context) throw new Error("Missing PDF preview raster context.");
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
return boxes.map(({ name, box }) => {
if (!box)
return {
name,
located: false,
inkPixels: 0,
interiorInk: 0,
trimmedWidth: 0,
trimmedHeight: 0,
};
const markerLeft = box.x * scale;
const markerTop = box.y * scale;
const markerRight = (box.x + box.width) * scale;
const markerBottom = (box.y + box.height) * scale;
const padding = 2 * scale;
const left = Math.max(0, Math.floor(box.x * scale - padding));
const top = Math.max(0, Math.floor(box.y * scale - padding));
const right = Math.min(canvas.width, Math.ceil((box.x + box.width) * scale + padding));
const bottom = Math.min(canvas.height, Math.ceil((box.y + box.height) * scale + padding));
let inkPixels = 0;
let minX = right;
let minY = bottom;
let maxX = left;
let maxY = top;
for (let y = top; y < bottom; y += 1) {
for (let x = left; x < right; x += 1) {
// Scan with padding for antialiasing, but count ink only inside marker box.
// Neighboring glyphs must never make blank/tofu marker evidence pass.
if (x < markerLeft || x >= markerRight || y < markerTop || y >= markerBottom) continue;
const index = (y * pixels.width + x) * 4;
const red = pixels.data[index] ?? 255;
const green = pixels.data[index + 1] ?? 255;
const blue = pixels.data[index + 2] ?? 255;
if (red >= 245 && green >= 245 && blue >= 245) continue;
inkPixels += 1;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
if (inkPixels === 0) {
return { name, located: true, inkPixels, interiorInk: 0, trimmedWidth: 0, trimmedHeight: 0 };
}
let interiorInk = 0;
for (
let y = minY + Math.floor((maxY - minY + 1) * 0.2);
y < maxY - Math.floor((maxY - minY + 1) * 0.2);
y += 1
) {
for (
let x = minX + Math.floor((maxX - minX + 1) * 0.2);
x < maxX - Math.floor((maxX - minX + 1) * 0.2);
x += 1
) {
const index = (y * pixels.width + x) * 4;
if (
(pixels.data[index] ?? 255) < 245 ||
(pixels.data[index + 1] ?? 255) < 245 ||
(pixels.data[index + 2] ?? 255) < 245
)
interiorInk += 1;
}
}
const trimmedWidth = maxX - minX + 1;
const trimmedHeight = maxY - minY + 1;
return { name, located: true, inkPixels, interiorInk, trimmedWidth, trimmedHeight };
});
}
const preview = document.querySelector<HTMLCanvasElement>('[aria-hidden="false"] canvas');
return {
rasterDataUrl: raster.toDataURL(),
referenceGlyphs: measure(raster, rasterScale),
previewGlyphs: preview ? measure(preview, preview.width / baseViewport.width) : [],
};
} finally {
await loadingTask.destroy();
}
},
{ bytes: Array.from(bytes), boxes },
);
return {
...rasterEvidence,
referenceGlyphs: classifyRasterMeasurements(rasterEvidence.referenceGlyphs),
previewGlyphs: classifyRasterMeasurements(rasterEvidence.previewGlyphs),
textLayerMarkers,
};
}
async function report(testInfo: TestInfo, name: string, reportData: Record<string, unknown>) {
const body = JSON.stringify({
version: 1,
fixture: "offline-font-scripts-v1",
surface: name,
...reportData,
});
expect(body).not.toMatch(/https?:\/\/|[?&](token|secret|password|auth)=/i);
await testInfo.attach(`${name}.json`, { body, contentType: "application/json" });
console.log(`[offline-fonts] ${body}`);
}
test("captures cold font picker preview requests", async ({ browser, authPage: seedPage }, testInfo) => {
await createSampleResumeFromDashboard(seedPage, testInfo);
const fixture = await seedOfflineFontResume(seedPage);
const cold = await createColdContext(browser, seedPage, testInfo);
const page = await cold.context.newPage();
try {
await page.goto(fixture.builderURL);
await openSidebarSection(page, "Typography");
await page.getByRole("combobox").first().click();
await expect(page.getByRole("option").first()).toBeVisible();
await page.waitForTimeout(1_000);
await report(testInfo, "picker-preview", {
cache: "new-browser-context",
blockedExternalFontRequests: cold.blockedRequests,
networkStatus: networkStatus(cold.blockedRequests),
glyphStatus: "not-applicable-picker-only",
});
} finally {
await cold.context.close();
}
});
test("captures cold builder PDF preview and classifies glyph/network results", async ({
browser,
authPage: seedPage,
}, testInfo) => {
await createSampleResumeFromDashboard(seedPage, testInfo);
const fixture = await seedOfflineFontResume(seedPage);
const cold = await createColdContext(browser, seedPage, testInfo);
const page = await cold.context.newPage();
await capturePdfBytes(page);
try {
await page.goto(fixture.builderURL);
await page.waitForTimeout(5_000);
const previewCanvas = page.locator('[aria-hidden="false"] canvas').first();
const canvasVisible = await previewCanvas.isVisible().catch(() => false);
const pdfBytes = await page.evaluate(
() => (window as Window & { offlineFontPdfBytes?: number[] }).offlineFontPdfBytes,
);
expect(pdfBytes).toBeDefined();
const rasterEvidence = pdfBytes ? await renderPdfRasterEvidence(page, Uint8Array.from(pdfBytes)) : null;
if (rasterEvidence) {
await testInfo.attach("builder-preview-raster.png", {
body: Buffer.from(rasterEvidence.rasterDataUrl.split(",")[1] ?? "", "base64"),
contentType: "image/png",
});
expect(rasterEvidence.previewGlyphs).toHaveLength(offlineFontScriptSamples.length);
expect(rasterEvidence.previewGlyphs.every(({ status }) => status === "visible")).toBe(true);
}
expect(canvasVisible).toBe(true);
await report(testInfo, "builder-preview", {
cache: "new-browser-context",
blockedExternalFontRequests: cold.blockedRequests,
networkStatus: networkStatus(cold.blockedRequests),
canvasVisible,
textLayerMarkers: rasterEvidence?.textLayerMarkers ?? "not-extracted",
glyphStatus: rasterEvidence?.previewGlyphs ?? "not-rasterized",
});
} finally {
await cold.context.close();
}
});
test("captures cold browser PDF download and classifies extracted glyphs", async ({
browser,
authPage: seedPage,
}, testInfo) => {
await createSampleResumeFromDashboard(seedPage, testInfo);
const fixture = await seedOfflineFontResume(seedPage);
const cold = await createColdContext(browser, seedPage, testInfo);
const page = await cold.context.newPage();
let markerResultValue: PdfMarkers | null = null;
let rasterEvidence: PdfRasterEvidence | null = null;
let downloadStatus = "not-started";
let rasterEvidenceStatus = "not-attempted";
try {
await page.goto(fixture.builderURL);
await openSidebarSection(page, "Export");
await page.getByRole("button", { name: /Choose PDF, DOCX, Markdown, or JSON/ }).click();
const downloadPromise = page.waitForEvent("download", { timeout: 20_000 });
await page.getByRole("button", { name: "Download PDF", exact: true }).click();
const download = await downloadPromise;
downloadStatus = "received";
const path = testInfo.outputPath("offline-font-browser-download.pdf");
await download.saveAs(path);
const bytes = new Uint8Array(await readFile(path));
try {
markerResultValue = extractedMarkerResult(await readPdfText(bytes));
rasterEvidence = await renderPdfRasterEvidence(page, bytes);
await testInfo.attach("browser-download-raster.png", {
body: Buffer.from(rasterEvidence.rasterDataUrl.split(",")[1] ?? "", "base64"),
contentType: "image/png",
});
rasterEvidenceStatus = "received";
} catch {
rasterEvidenceStatus = "unresolved-raster-evidence-error";
}
} catch {
downloadStatus = "download-error";
} finally {
await report(testInfo, "browser-download", {
cache: "new-browser-context",
blockedExternalFontRequests: cold.blockedRequests,
networkStatus: networkStatus(cold.blockedRequests),
downloadStatus,
rasterEvidenceStatus,
textLayerMarkers: markerResultValue ?? "not-extracted",
glyphStatus: rasterEvidence?.referenceGlyphs ?? "not-rasterized",
});
await cold.context.close();
}
assertPdfDownloadReceived(downloadStatus);
expect(rasterEvidenceStatus).toBe("received");
expect(rasterEvidence).not.toBeNull();
if (!rasterEvidence) return;
expect(rasterEvidence.referenceGlyphs).toHaveLength(offlineFontScriptSamples.length);
expect(rasterEvidence.referenceGlyphs.every(({ status }) => status === "visible")).toBe(true);
});
test("exercises restarted-server PDF and records server observability boundary", async ({
browser,
authPage: seedPage,
}, testInfo) => {
await createSampleResumeFromDashboard(seedPage, testInfo);
const fixture = await seedOfflineFontResume(seedPage);
const cold = await createColdContext(browser, seedPage, testInfo);
const page = await cold.context.newPage();
let responseStatus = "not-requested";
let markerResultValue: PdfMarkers | null = null;
try {
await page.goto(fixture.builderURL);
if (!serverRestartFlag) {
responseStatus = "blocked-before-request";
} else {
const response = await page.request.get(
`/api/resumes/${encodeURIComponent(fixture.username)}/${encodeURIComponent(fixture.slug)}/pdf`,
);
responseStatus = String(response.status());
if (response.ok()) {
const bytes = await response.body();
await writeFile(testInfo.outputPath("offline-font-server.pdf"), bytes);
markerResultValue = extractedMarkerResult(await readPdfText(new Uint8Array(bytes)));
}
}
} finally {
await report(testInfo, "server-pdf", {
cache: "new-browser-context; server-process-state-is-external",
serverRestartFlag,
serverGateStatus: "unresolved-external-host-level-blocker",
blockedExternalFontRequests: cold.blockedRequests,
networkStatus: "server-outbound-requests-unobservable-from-playwright",
responseStatus,
textLayerMarkers: markerResultValue ?? "not-extracted",
glyphStatus: "not-rasterized-server-surface",
limitation:
"Server outbound request capture and verifiable restart identity remain unresolved external host-level blockers; the caller flag is not restart proof. Playwright route interception sees browser requests only; do not treat this run as a cold-network gate.",
});
await cold.context.close();
}
});
});
+3 -1
View File
@@ -89,7 +89,9 @@
"FLAG_SHOW_SPONSORS",
"FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI",
"FLAG_ALLOW_UNSAFE_AI_BASE_URL",
"AI_TEST_TIMEOUT_MS"
"AI_TEST_TIMEOUT_MS",
"OFFLINE_FONT_DIAGNOSTIC",
"OFFLINE_FONT_DIAGNOSTIC_SERVER_RESTARTED"
],
"tasks": {
"transit": {