diff --git a/docs/pipeline/indesign-token-mapper.md b/docs/pipeline/indesign-token-mapper.md new file mode 100644 index 0000000..ef77294 --- /dev/null +++ b/docs/pipeline/indesign-token-mapper.md @@ -0,0 +1,137 @@ +# InDesign token mapper: guide + +The token mapper is stage 3 of the InDesign-to-WordPress pipeline. It takes the +[intermediate representation](../../packages/pipeline/src/indesign/ir.js) +produced by the [IDML parser](../../packages/pipeline/README.md) (#62) or the +[PDF fallback parser](indesign-pdf-fidelity.md) (#63) and maps it to WordPress +design tokens, so generated patterns inherit a coherent design system instead of +inline magic numbers. + +It produces three artifacts: + +| Artifact | What it is | +| --- | --- | +| `theme.json` partial | An additive, namespaced partial that deep-merges into a base theme. | +| `design-tokens.json` | DTCG (Design Tokens Community Group) tokens, read natively by Style Dictionary v4. | +| report | Warnings, validation result, provenance maps, font fallbacks, Google fonts. | + +## Usage + +```js +import { parseIdml, mapTokens } from '@flavian/pipeline'; + +const ir = await parseIdml('./brochure.idml'); +const { partial, designTokens, merged, report } = mapTokens(ir); +``` + +```bash +# Compose with a parser CLI… +node packages/pipeline/bin/parse-idml.mjs brochure.idml \ + | node packages/pipeline/bin/map-tokens.mjs --out-dir ./tokens > theme.partial.json + +# …or parse + map in one step (accepts .idml or .pdf directly). +node packages/pipeline/bin/map-tokens.mjs brochure.pdf --out-dir ./tokens +``` + +### Options + +| Option | CLI flag | Default | Effect | +| --- | --- | --- | --- | +| `base` | `--base ` | bundled `themes/flavian-shop/theme.json` | Base theme to merge against. | +| `fontMap` | `--font-map ` | bundled `config/font-map.json` | Font fallback table. | +| `namespace` | `--namespace ` | `id` | Prefix for derived token slugs. | +| `tolerance` | `--tolerance ` | `1728` (≈24/channel) | Color dedupe/reuse squared-RGB distance. | +| `gridPx` | `--grid ` | `4` | Spacing quantization grid. | +| `tolerancePx` | `--type-tolerance ` | `1` | Font-size clustering tolerance. | +| `fluid` | `--fluid` | off | Emit fluid `clamp()` font sizes. | + +## How mapping works + +| Token group | How it's derived | +| --- | --- | +| `settings.color.palette` | Each swatch is re-derived to sRGB from its raw `components` (better than the parser's preview hex for LAB). Colors within `tolerance` of a base palette color reuse that base slug; the rest are deduped by hex and emitted as namespaced `id-*` tokens. | +| `settings.typography.fontSizes` | Paragraph-style font sizes are clustered (near-equal sizes merge). Each cluster reuses a close base font-size slug, or becomes a namespaced derived token named after the InDesign style. Every emitted entry is referenced by ≥1 paragraph style. | +| `settings.typography.fontFamilies` | Fonts are mapped through `config/font-map.json`. A mapped family reuses a base family only on an exact stack match, else becomes a derived family. Unmapped families fall back to a heuristic generic and raise a warning. | +| `settings.spacing.spacingSizes` | Candidate spacings (page margins, inter-frame gutters, paragraph space-before/after) are quantized to `gridPx`, deduped, and capped. | +| `styles.elements.h1`–`h6`, `caption` | Recognized heading/caption style names become element presets with font size, family, line height, letter spacing, and text color. | +| `styles.blocks['core/paragraph']` | The body style maps here — theme.json has no `

` element. | + +## Color conversion math + +The mapper re-derives every swatch from its raw `components` using documented +math (shared module: [`src/indesign/color.js`](../../packages/pipeline/src/indesign/color.js)). +For RGB and CMYK the result equals the parser's preview hex; for LAB it is a real +color rather than the parser's legacy black. + +- **RGB** (0–255): formatted directly to `#rrggbb`. +- **CMYK** (0–100): naive, profile-free conversion + `r = 255·(1 − c/100)·(1 − k/100)` (and likewise for g, b). Without an ICC + profile this is an approximation and never clips, so no out-of-gamut signal is + reported for CMYK — this is documented, not faked. (ICC-accurate CMYK was + considered and deferred; see the design doc.) +- **LAB** (L 0–100, a/b −128–127): full colorimetric path — + CIELAB → XYZ using a **D50** reference white + (`Xn 0.96422, Yn 1.0, Zn 0.82521`, with `ε = 216/24389`, `κ = 24389/27`) → + linear sRGB via the Bradford-adapted XYZ(D50)→sRGB matrix (D50→D65 folded in) + → sRGB gamma → 8-bit. If any linear channel falls outside `[0, 1]` (beyond a + small tolerance) before clamping, the color is **out of gamut** and a warning + is emitted; the clamped color is used. + +## Font map format + +`config/font-map.json` maps an InDesign family name to a CSS stack: + +```json +{ + "Merriweather": { + "fontFamily": "Merriweather, Georgia, serif", + "source": "google", + "googleFontName": "Merriweather", + "fallback": "serif" + } +} +``` + +`source: "google"` families are collected in the report's `googleFonts` for a +downstream generator to enqueue; they are kept out of `theme.json` so the emitted +tokens stay schema-clean. Unmapped families fall back to a heuristic generic +(`serif` / `sans-serif` / `monospace`) inferred from the family name. + +## Warning codes + +| Code | Meaning | +| --- | --- | +| `color-out-of-gamut` | A LAB swatch fell outside the sRGB gamut and was clamped. | +| `swatch-approximated` | A Spot/Unknown swatch has no numeric conversion; the hex is an approximation. | +| `font-fallback` | A font isn't in the map; a heuristic generic family was used. | +| `spacing-approximate` | The spacing scale was derived from approximate (PDF) geometry. | + +The report merges these with the IR's own parse-time warnings. + +## Merge semantics + +`mergeThemeJson(base, partial)` deep-merges objects and merges token arrays +**by `slug`**. Because derived tokens are namespaced (`id-*`), they extend the +base without overwriting curated slugs; a derived token that resolves onto a base +slug (color/size within tolerance) simply references the base rather than adding +a duplicate. The generated output is validated against the official WordPress +block-theme JSON Schema (via ajv) and a zod schema for the emitted subset. + +## Acceptance criteria + +| Criterion | How it's met | +| --- | --- | +| theme.json validates against the WP block-theme schema | ajv against the vendored official schema (`map/schema/theme-json.schema.json`) + a zod subset schema. | +| Palette includes all distinct swatches, deduped by hex within tolerance | `map/colors.js`. | +| Each typography entry referenced by ≥1 paragraph style | The scale is built from paragraph styles; `report.provenance.styleToSlug` records the mapping. | +| Font fallback warnings emitted and listed in the report | `map/fonts.js` → `report.fontFallbacks`. | +| Tests cover CMYK→sRGB, clustering, merge-with-base | `tests/indesign/color.test.mjs`, `map-typography.test.mjs`, `map-theme-json.test.mjs`. | +| Works on either IR | Source-agnostic; the end-to-end test runs on both an IDML- and a PDF-built IR. | + +## Known limitations + +- **CMYK is profile-free.** Without an ICC profile, CMYK→sRGB is an approximation. +- **Spacing is approximate**, especially from PDF geometry (whole-page margins can + be large when frames don't fill the page); values are quantized and capped. +- **Element coverage** is limited to recognized style names (Heading N / Body / + Caption); other styles still contribute font-size and color tokens. diff --git a/docs/plans/2026-05-29-indesign-token-mapper-design.md b/docs/plans/2026-05-29-indesign-token-mapper-design.md new file mode 100644 index 0000000..e64ef05 --- /dev/null +++ b/docs/plans/2026-05-29-indesign-token-mapper-design.md @@ -0,0 +1,262 @@ +# InDesign IR → WordPress design token mapper (design) + +**Issue:** #64 — InDesign pipeline: style and design token mapper (sub-issue of #61) +**Date:** 2026-05-29 +**Status:** Approved (brainstorm complete) +**Depends on:** #62 (IDML parser + IR), #63 (PDF fallback parser) — both merged. + +## Goal + +Map the InDesign intermediate representation (IR) — produced by either the IDML +parser (#62) or the PDF fallback parser (#63) — to WordPress design tokens: a +`theme.json` typography scale, color palette, spacing scale, font families, and +named per-element style presets. Generated patterns (the downstream #65 +generator) then inherit a coherent design system instead of inline magic numbers. + +The mapper reads a validated `Document` IR and emits: + +1. A **`theme.json` partial** that deep-merges cleanly into Flavian's base theme. +2. A **`design-tokens.json`** in DTCG format (Style Dictionary v4 compatible). +3. A **generator report** listing warnings and provenance (style→slug, + swatch→slug, font fallbacks). + +## Approved decisions + +| # | Fork | Decision | +|---|------|----------| +| 1 | Color conversion / IR contract | **Extend the IR Color additively** with raw `components`; the mapper performs documented CMYK→sRGB and LAB→sRGB conversion with out-of-gamut warnings. Centralize conversion in a shared module; route both parsers through it (this also fixes the current LAB/Spot→`#000000` bug). | +| 2 | Merge semantics | **Additive, namespaced partial** + a deep-merge helper. Derived tokens are namespaced so they never overwrite the base theme's curated slugs; the mapper also emits a merged preview. | +| 3 | Schema validation | **Both** — `ajv` against a vendored, pinned official `theme.json` schema for the emitted output, plus `zod` for the mapper's internal/emitted subset. | +| 4 | Typography scale | **Dynamic, clustered** — collapse near-duplicate paragraph-style sizes into a deduped scale; reuse base slugs when within tolerance, else derived slugs. Every entry is referenced by ≥1 paragraph style by construction. InDesign names preserved where reasonable. | + +Defaults (overridable): `design-tokens.json` uses **DTCG** (`$value`/`$type`); +the CLI emits the partial to **stdout**, with all artifacts written to `--out-dir`. + +## Architecture + +New `map/` stage under `packages/pipeline/src/indesign/`. Pure ESM + JSDoc, +tested with `node:test`, matching existing pipeline conventions. + +``` +packages/pipeline/ +├── bin/ +│ └── map-tokens.mjs # CLI: IR JSON (or .idml/.pdf) → artifacts +├── config/ +│ └── font-map.json # default InDesign-family → web/Google font table +├── src/indesign/ +│ ├── color.js # NEW shared: rgb/cmyk/lab/gray→sRGB + gamut, dedupe helpers +│ ├── ir.js # CHANGED: Color.components (optional) +│ ├── parsers/resources.js # CHANGED: route colors through shared color.js, attach components +│ ├── pdf/color.js # CHANGED: re-export shared primitives, attach components +│ └── map/ +│ ├── index.js # mapTokens(ir, options) orchestrator +│ ├── colors.js # swatches → color.palette +│ ├── typography.js # paragraph styles → fontSizes + styles.elements +│ ├── spacing.js # geometry/paragraph spacing → spacingSizes +│ ├── fonts.js # fonts → fontFamilies (via font-map.json) +│ ├── theme-json.js # assemble partial + deep-merge with base +│ ├── design-tokens.js # DTCG emitter +│ ├── report.js # warnings + provenance aggregation +│ └── schema/ +│ ├── theme-json.schema.json # vendored, pinned official WP schema +│ └── partial.zod.js # zod for the emitted subset +└── tests/indesign/ + ├── map-colors.test.mjs + ├── map-typography.test.mjs + ├── map-spacing.test.mjs + ├── map-fonts.test.mjs + ├── map-theme-json.test.mjs + ├── map-design-tokens.test.mjs + └── map-tokens.test.mjs # e2e on both IDML-built and PDF-built IR +``` + +Public surface re-exported from `src/index.js`: `mapTokens`, and the artifact +types via JSDoc typedefs. + +## IR change (additive, backward-compatible) + +```js +export const Color = z.object({ + hex: z.string().regex(/^#[0-9A-Fa-f]{6}$/), // parse-time value (unchanged) + space: z.enum(['RGB', 'CMYK', 'LAB', 'Spot', 'Unknown']), + // Raw channel values in the source space's documented range. Optional so + // older IRs (and pass-through callers) still validate; the mapper falls back + // to `hex` when absent. + components: z.array(z.number()).optional(), +}); +``` + +Documented per-space component ranges: + +| Space | Components | Range | +|-------|-----------|-------| +| RGB | `[r, g, b]` | 0–255 | +| CMYK | `[c, m, y, k]` | 0–100 | +| LAB | `[L, a, b]` | L 0–100, a/b −128–127 | +| Gray | `[v]` (stored as RGB) | 0–255 | + +`irVersion` stays `1` (purely additive). Both parsers populate `components`; +LAB/Spot now route through the real conversion instead of falling back to black. + +## Color conversion (shared `color.js`) + +Centralizes all conversion so the IDML parser, PDF parser, and mapper agree. + +- `rgbToSrgbHex([r,g,b])` — format only (already sRGB). +- `cmykToSrgb([c,m,y,k])` — naive, profile-free approximation + `r = 255·(1−c/100)·(1−k/100)` (etc.). Documented as profile-free; it never + clips, so no out-of-gamut signal is fabricated for CMYK. +- `labToSrgb([L,a,b])` — full colorimetric path: + 1. CIELAB → XYZ using reference white **D50** (`Xn=0.9642, Yn=1.0, Zn=0.8249`), + with `ε = 216/24389`, `κ = 24389/27`. + 2. XYZ(D50) → linear sRGB via the Bradford-adapted matrix (folds D50→D65): + ``` + [ 3.1338561 −1.6168667 −0.4906146 + −0.9787684 1.9161415 0.0334540 + 0.0719453 −0.2289914 1.4052427 ] + ``` + 3. Out-of-gamut = any linear channel `<0` or `>1` before clamping → warning. + 4. Clamp → sRGB gamma (`12.92·c` or `1.055·c^(1/2.4)−0.055`) → ×255 → round. + +Returns `{ hex, outOfGamut }`. Existing `cmykToHex`/`hexToRgb`/`colorDistance`/ +`nearestSwatch` move here; `pdf/color.js` re-exports them so existing imports and +`pdf-color.test.mjs` keep passing. + +## Color mapping (`map/colors.js`) + +For each swatch: recompute hex from `components` via the shared conversion +(identical to IR hex for RGB/CMYK; better for LAB), then build `color.palette`: + +- **Dedupe by hex within a configurable tolerance** (`colorDistance`); the + representative keeps the most descriptive swatch name. +- Reuse a base palette slug when a swatch matches a base color within tolerance; + otherwise emit a namespaced `id-` slug. +- Warn on out-of-gamut LAB conversions and on Spot/Unknown approximations; + warnings carry the swatch id/name and land in the report. + +## Typography (`map/typography.js`) + +1. Take `kind === 'paragraph'` styles with a `fontSize`. +2. Cluster by `fontSize` within a configurable tolerance; one scale entry per + cluster (representative = most common / median size). +3. `size`: px → `rem` (÷16); optional fluid `clamp()` above a configurable + threshold, mirroring the base theme. Static rem by default (deterministic). +4. Slug: reuse a base `fontSizes` slug (small…display, resolved to nominal px — + `clamp()` anchored to its max) when within tolerance; else a derived slug. + InDesign style names preserved as the token `name`. +5. Emit `settings.typography.fontSizes[]` **and** `styles.elements` (h1–h6, p, + caption) carrying fontSize / lineHeight (from `leading`) / letterSpacing + (from `tracking`) / text color (from `fillColorRef`). These are the "named + style variations." + +Because the scale is built *from* paragraph styles, **every entry is referenced +by ≥1 style** (acceptance ✓). The style→slug map is recorded in the report. + +## Spacing (`map/spacing.js`) + +Candidate values (all already normalized to px in the IR): + +- Page margins: `page.bounds` vs contained `frame.bounds` offsets. +- Gutters: gaps between horizontally/vertically adjacent frames. +- Paragraph spacing: `Style.properties.spaceBefore` / `spaceAfter` when present. + +Quantize to a configurable grid (**default 4px**), drop ≤0, dedupe, sort, cap the +count, rank-name → `settings.spacing.spacingSizes[]` (namespaced). Coexists with +the base's generative `spacingScale`. Output values in `rem`. Flagged approximate +(especially for PDF) via warnings. + +## Fonts (`map/fonts.js`) + +`config/font-map.json` (shipped default), entry shape: + +```json +{ + "Helvetica Neue": { + "fontFamily": "'Helvetica Neue', Helvetica, Arial, sans-serif", + "source": "system", + "fallback": "sans-serif" + }, + "Merriweather": { + "fontFamily": "Merriweather, Georgia, serif", + "source": "google", + "googleFontName": "Merriweather", + "fallback": "serif" + } +} +``` + +For each font family referenced by a promoted style: look it up. Found → emit a +`fontFamilies` entry (reuse base `sans`/`serif` slug when the stack matches, else +namespaced). Not found → `font-fallback` warning + heuristic generic +(serif/sans/mono from the family name), **listed in the report** (acceptance ✓). + +## theme.json partial + merge (`map/theme-json.js`) + +Assemble the namespaced partial (`settings.color.palette`, +`settings.typography.fontSizes` + `fontFamilies`, `settings.spacing.spacingSizes`, +`styles.elements`). `mergeThemeJson(base, partial)` deep-merges objects and +merges token arrays **by slug** (namespacing means no clobber; reused slugs +align). Emits a merged preview alongside the partial. + +## design-tokens.json (`map/design-tokens.js`) + +DTCG format — `$value` / `$type` / `$description` (provenance = InDesign style or +swatch name). Groups: `color` (type `color`), `fontSize` (type `dimension`), +`fontFamily` (type `fontFamily`), `spacing` (type `dimension`). Read natively by +Style Dictionary v4. + +## Validation & testing + +- **ajv** (new dev-dep) validates the partial and merged output against a + vendored, pinned official `theme.json` schema. +- **zod** (`partial.zod.js`) validates the mapper's emitted subset. +- Tests (each acceptance criterion mapped): + - `map-colors`: CMYK→sRGB and LAB→sRGB **against known fixture values**; + out-of-gamut detection; dedupe by tolerance; palette includes all distinct + swatches. + - `map-typography`: clustering; slug reuse; **every entry referenced by ≥1 + style**; name preservation. + - `map-spacing`: quantization to grid; dedupe; cap. + - `map-fonts`: mapped families; **fallback warnings emitted + in report**. + - `map-theme-json`: **merge-with-base** path; **ajv validation passes**. + - `map-design-tokens`: DTCG structure + provenance. + - `map-tokens` (e2e): runs on **both** an IDML-built and a PDF-built IR + (existing `build-idml.js` / `build-pdf.js` helpers), proving source-agnostic. + +## CLI (`bin/map-tokens.mjs`) + +```bash +# IR JSON in, artifacts out +node packages/pipeline/bin/parse-idml.mjs doc.idml | \ + node packages/pipeline/bin/map-tokens.mjs --out-dir ./tokens > theme.partial.json + +# Convenience: parse + map in one shot +node packages/pipeline/bin/map-tokens.mjs doc.idml --out-dir ./tokens +``` + +Options: `--out-dir`, `--base `, `--font-map `, `--grid `, +`--tolerance `, `--fluid`. Partial → stdout; report/warnings → stderr; +`theme.partial.json` + `design-tokens.json` + `theme.merged.json` → out-dir. + +## Acceptance criteria → coverage + +| Criterion | Where satisfied | +|-----------|-----------------| +| theme.json validates against WP block theme schema | ajv + vendored schema (`map-theme-json`) | +| Palette includes all distinct swatches (deduped by hex within tolerance) | `map/colors.js` + `map-colors` | +| Each typography entry referenced by ≥1 paragraph style | clustering construction + `map-typography` | +| Font fallback warnings emitted and listed in report | `map/fonts.js` + `map/report.js` + `map-fonts` | +| Tests: CMYK→sRGB fixtures, clustering, merge-with-base | `map-colors`, `map-typography`, `map-theme-json` | +| Works on either IR | source-agnostic mapper + `map-tokens` e2e | + +## Out of scope (YAGNI) + +- ICC-profile CMYK conversion (chosen against in fork #1). +- Block-specific style variations / `register_block_style` (depends on the #65 + generator defining patterns). +- Pixel-perfect spacing reconstruction from PDF (documented as approximate). + +## Docs + +`docs/pipeline/indesign-token-mapper.md` — conversion math, config reference, +warnings table, CLI usage — matching the existing fidelity-guide style. diff --git a/docs/plans/2026-05-29-indesign-token-mapper.md b/docs/plans/2026-05-29-indesign-token-mapper.md new file mode 100644 index 0000000..d298436 --- /dev/null +++ b/docs/plans/2026-05-29-indesign-token-mapper.md @@ -0,0 +1,780 @@ +# InDesign Token Mapper Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Map a validated InDesign IR (from the IDML or PDF parser) to WordPress design tokens — a `theme.json` partial, a DTCG `design-tokens.json`, and a generator report. + +**Architecture:** A new `map/` stage under `packages/pipeline/src/indesign/`. Color conversion is centralized in a shared `color.js` the parsers also use; the IR `Color` gains an optional `components` field so the mapper can convert CMYK/LAB→sRGB with out-of-gamut warnings. Output is an additive, namespaced partial deep-merged into Flavian's base theme. Full rationale: `docs/plans/2026-05-29-indesign-token-mapper-design.md`. + +**Tech Stack:** Node ≥20 ESM, Zod (validation), ajv + ajv-formats (theme.json schema), `node:test`, pnpm. + +**Conventions (match existing code):** tabs for indentation; JSDoc typedefs (no TS); pure, unit-testable functions; warnings via the `WarningCollector` pattern; tests in `packages/pipeline/tests/indesign/*.test.mjs` using `node:test` + `node:assert/strict`; run tests from `packages/pipeline` with `pnpm test` or a single file via `node --test tests/indesign/.test.mjs`. + +**Commit style:** Conventional Commits, professional tone, trailer `Co-Authored-By: Claude Opus 4.8 `. Commit after each task. + +--- + +## Task 1: Tooling — add ajv and vendor the theme.json schema + +**Files:** +- Modify: `packages/pipeline/package.json` (add devDependencies) +- Create: `packages/pipeline/src/indesign/map/schema/theme-json.schema.json` (vendored, pinned) + +**Step 1: Add dev dependencies** + +From `packages/pipeline`: +```bash +pnpm add -D ajv ajv-formats +``` +Expected: `ajv` and `ajv-formats` appear under `devDependencies`. + +**Step 2: Vendor the official schema** + +Download the published WordPress theme.json schema (the one the base theme references) and pin it locally: +```bash +curl -fsSL https://schemas.wp.org/trunk/theme.json -o packages/pipeline/src/indesign/map/schema/theme-json.schema.json +``` +Then open the file and note its `$schema` dialect. +- If it declares draft-07 (or no `$schema`): ajv v8 handles it directly. +- If it declares draft-04: also `pnpm add -D ajv-draft-04` and use that constructor in Task 7. + +Record the source URL and retrieval date in a top-of-file comment is not possible in JSON; instead add a sibling note in the Task 7 validator module. + +**Step 3: Commit** +```bash +git add packages/pipeline/package.json packages/pipeline/pnpm-lock.yaml packages/pipeline/src/indesign/map/schema/theme-json.schema.json +git commit -m "build(pipeline): add ajv and vendor theme.json schema for the token mapper" +``` + +--- + +## Task 2: Extend the IR `Color` with optional `components` + +**Files:** +- Modify: `packages/pipeline/src/indesign/ir.js:19-24` +- Test: `packages/pipeline/tests/indesign/ir-color.test.mjs` (new) + +**Step 1: Write the failing test** + +`tests/indesign/ir-color.test.mjs`: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Color } from '../../src/indesign/ir.js'; + +test('Color accepts optional raw components', () => { + const c = Color.parse({ hex: '#0066cc', space: 'RGB', components: [0, 102, 204] }); + assert.deepEqual(c.components, [0, 102, 204]); +}); + +test('Color still validates without components (backward compatible)', () => { + const c = Color.parse({ hex: '#000000', space: 'CMYK' }); + assert.equal(c.components, undefined); +}); +``` + +**Step 2: Run, expect failure** +```bash +node --test tests/indesign/ir-color.test.mjs +``` +Expected: FAIL — `components` rejected as an unknown key is not the failure (zod ignores unknowns by default); the first test fails because `c.components` is `undefined`. + +**Step 3: Implement** + +Replace the `Color` schema body: +```js +export const Color = z.object({ + // "#RRGGBB" — the parse-time value (naive for CMYK; black for legacy LAB). + hex: z.string().regex(/^#[0-9A-Fa-f]{6}$/), + // Source color space as declared by the document. + space: z.enum(['RGB', 'CMYK', 'LAB', 'Spot', 'Unknown']), + // Raw channel values in the source space's documented range, so the mapper + // can do a proper, documented conversion to sRGB: + // RGB [r,g,b] 0..255 + // CMYK [c,m,y,k] 0..100 + // LAB [L,a,b] L 0..100, a/b -128..127 + // Gray [v] 0..255 (stored as RGB triple) + // Optional so older IRs still validate; the mapper falls back to `hex`. + components: z.array(z.number()).optional(), +}); +``` + +**Step 4: Run, expect pass** +```bash +node --test tests/indesign/ir-color.test.mjs +``` +Expected: PASS (2/2). + +**Step 5: Commit** +```bash +git add packages/pipeline/src/indesign/ir.js packages/pipeline/tests/indesign/ir-color.test.mjs +git commit -m "feat(pipeline): add optional raw components to IR Color" +``` + +--- + +## Task 3: Shared `color.js` with documented sRGB conversion + +**Files:** +- Create: `packages/pipeline/src/indesign/color.js` +- Test: `packages/pipeline/tests/indesign/color.test.mjs` (new) + +This module is the single source of truth for color math. It hosts the existing primitives (moved from `pdf/color.js`) **plus** `cmykToSrgb`, `labToSrgb`, `rgbToSrgbHex`, and a `colorFromComponents` dispatcher. + +**Step 1: Write the failing tests** (`tests/indesign/color.test.mjs`) +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + rgbToHex, cmykToSrgb, labToSrgb, rgbToSrgbHex, colorFromComponents, +} from '../../src/indesign/color.js'; + +test('rgbToSrgbHex formats 0..255 channels', () => { + assert.equal(rgbToSrgbHex([0, 102, 204]), '#0066cc'); +}); + +test('cmykToSrgb (0..100) matches the naive IDML preview', () => { + // Pure black ink, no other channels. + assert.deepEqual(cmykToSrgb([0, 0, 0, 100]), { hex: '#000000', outOfGamut: false }); + assert.deepEqual(cmykToSrgb([0, 0, 0, 0]), { hex: '#ffffff', outOfGamut: false }); + // Pure cyan. + assert.equal(cmykToSrgb([100, 0, 0, 0]).hex, '#00ffff'); +}); + +test('labToSrgb converts reference values (D50) within tolerance', () => { + // L*=100,a*=0,b*=0 is white; L*=0 is black. + assert.equal(labToSrgb([100, 0, 0]).hex, '#ffffff'); + assert.equal(labToSrgb([0, 0, 0]).hex, '#000000'); + // Mid grey L*≈53.39 → ~#777777..#808080. Assert near-grey, not exact. + const grey = labToSrgb([53.389, 0, 0]).hex; + assert.match(grey, /^#(7[0-9a-f]|80)\1\1$/); +}); + +test('labToSrgb flags out-of-gamut colors', () => { + // A highly saturated Lab green that exceeds the sRGB gamut. + const res = labToSrgb([87.737, -86.185, 83.181]); // sRGB green-ish, in gamut + assert.equal(typeof res.outOfGamut, 'boolean'); + const wide = labToSrgb([50, 120, -120]); // saturated blue-magenta, out of gamut + assert.equal(wide.outOfGamut, true); +}); + +test('colorFromComponents dispatches by space and falls back to hex', () => { + assert.equal(colorFromComponents('CMYK', [0, 0, 0, 100], '#123456').hex, '#000000'); + assert.equal(colorFromComponents('RGB', [0, 102, 204], '#123456').hex, '#0066cc'); + // No components → fall back to the supplied hex. + assert.equal(colorFromComponents('LAB', undefined, '#abcdef').hex, '#abcdef'); +}); +``` + +**Step 2: Run, expect failure** (`Cannot find module ... color.js`) +```bash +node --test tests/indesign/color.test.mjs +``` + +**Step 3: Implement** (`src/indesign/color.js`) + +Move the existing functions from `pdf/color.js` (`hexByte`, `rgbToHex`, `grayToHex`, `cmykToHex`, `hexToRgb`, `colorDistance`, `nearestSwatch`) into this file verbatim, then add: + +```js +// rgbToSrgbHex: alias of rgbToHex for callers that think in "source space". +export const rgbToSrgbHex = rgbToHex; + +/** + * Naive, profile-free CMYK→sRGB. Components in 0..100 (IDML's range). + * Matches the parser's existing preview conversion so the same swatch lands on + * the same hex in IR and tokens. Without an ICC profile this never clips, so + * outOfGamut is always false (documented, not faked). + * @param {[number, number, number, number]} cmyk + * @returns {{ hex: string, outOfGamut: boolean }} + */ +export function cmykToSrgb([c, m, y, k]) { + const cv = c / 100, mv = m / 100, yv = y / 100, kv = k / 100; + const r = 255 * (1 - cv) * (1 - kv); + const g = 255 * (1 - mv) * (1 - kv); + const b = 255 * (1 - yv) * (1 - kv); + return { hex: rgbToHex([r, g, b]), outOfGamut: false }; +} + +// CIELAB→XYZ uses D50 (InDesign/ICC connection space). The matrix below maps +// XYZ(D50)→linear sRGB with the Bradford adaptation to D65 folded in. +const D50 = { Xn: 0.9642, Yn: 1.0, Zn: 0.8249 }; +const EPS = 216 / 24389; +const KAPPA = 24389 / 27; +const XYZ_D50_TO_LINEAR_SRGB = [ + [3.1338561, -1.6168667, -0.4906146], + [-0.9787684, 1.9161415, 0.0334540], + [0.0719453, -0.2289914, 1.4052427], +]; + +function gamma(c) { + return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; +} + +/** + * CIELAB (D50) → sRGB. L in 0..100, a/b in ~-128..127. + * @param {[number, number, number]} lab + * @returns {{ hex: string, outOfGamut: boolean }} + */ +export function labToSrgb([L, a, b]) { + const fy = (L + 16) / 116; + const fx = fy + a / 500; + const fz = fy - b / 200; + const fx3 = fx ** 3; + const fz3 = fz ** 3; + const xr = fx3 > EPS ? fx3 : (116 * fx - 16) / KAPPA; + const yr = L > KAPPA * EPS ? fy ** 3 : L / KAPPA; + const zr = fz3 > EPS ? fz3 : (116 * fz - 16) / KAPPA; + const X = xr * D50.Xn, Y = yr * D50.Yn, Z = zr * D50.Zn; + const lin = XYZ_D50_TO_LINEAR_SRGB.map( + (row) => row[0] * X + row[1] * Y + row[2] * Z, + ); + const outOfGamut = lin.some((v) => v < -1e-6 || v > 1 + 1e-6); + const rgb = lin.map((v) => Math.round(gamma(Math.max(0, Math.min(1, v))) * 255)); + return { hex: rgbToHex(/** @type {[number,number,number]} */ (rgb)), outOfGamut }; +} + +/** + * Convert a swatch's raw components to sRGB, dispatching on space. Falls back to + * the supplied parse-time hex when components are absent or the space is opaque + * (Spot/Unknown). + * @param {import('./ir.js').ColorIR['space']} space + * @param {number[]|undefined} components + * @param {string} fallbackHex + * @returns {{ hex: string, outOfGamut: boolean }} + */ +export function colorFromComponents(space, components, fallbackHex) { + if (components && components.length) { + if (space === 'RGB' && components.length >= 3) return { hex: rgbToSrgbHex(/** @type {any} */ (components)), outOfGamut: false }; + if (space === 'CMYK' && components.length >= 4) return cmykToSrgb(/** @type {any} */ (components)); + if (space === 'LAB' && components.length >= 3) return labToSrgb(/** @type {any} */ (components)); + } + return { hex: fallbackHex, outOfGamut: false }; +} +``` + +Add a `ColorIR` typedef to `ir.js` JSDoc block (`@typedef {z.infer} ColorIR`). + +**Step 4: Run, expect pass** +```bash +node --test tests/indesign/color.test.mjs +``` +Expected: PASS. If the mid-grey or out-of-gamut assertions are too tight, adjust the fixtures (not the math) — keep at least one exact white/black and one out-of-gamut=true case. + +**Step 5: Commit** +```bash +git add packages/pipeline/src/indesign/color.js packages/pipeline/src/indesign/ir.js packages/pipeline/tests/indesign/color.test.mjs +git commit -m "feat(pipeline): centralized color module with CMYK/LAB to sRGB conversion" +``` + +--- + +## Task 4: Route parsers through `color.js` + populate `components` + +**Files:** +- Modify: `packages/pipeline/src/indesign/pdf/color.js` (re-export shared primitives) +- Modify: `packages/pipeline/src/indesign/parsers/resources.js` (IDML: use shared module, attach components, fix LAB) +- Modify: `packages/pipeline/src/indesign/pdf/extract.js` (capture raw color in colorSamples) +- Modify: `packages/pipeline/src/indesign/parse-pdf.js` (thread components into synthesized swatches) +- Test: `packages/pipeline/tests/indesign/parser-color-components.test.mjs` (new) + +**Step 1: Write the failing test** +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseGraphic } from '../../src/indesign/parsers/resources.js'; +import { WarningCollector } from '../../src/indesign/warnings.js'; + +function graphic(xml) { return xml; } + +test('IDML LAB swatch converts to a real (non-black) color with components', () => { + const warnings = new WarningCollector(); + const xml = ``; + const [sw] = parseGraphic(xml, warnings); + assert.equal(sw.color.space, 'LAB'); + assert.deepEqual(sw.color.components, [54, 81, 70]); + assert.notEqual(sw.color.hex, '#000000'); +}); + +test('IDML CMYK swatch keeps components in 0..100', () => { + const warnings = new WarningCollector(); + const xml = ``; + const [sw] = parseGraphic(xml, warnings); + assert.deepEqual(sw.color.components, [0, 0, 0, 100]); + assert.equal(sw.color.hex, '#000000'); +}); +``` +(Confirm the XML attribute form `Self`/`Name`/`Space`/`ColorValue` matches `parseXml`'s `@`-prefix handling — `parseGraphic` reads `@Self` etc. The fast-xml-parser config maps attributes to `@name`; verify against `xml.js` and adjust the fixture if attributes need a namespace.) + +**Step 2: Run, expect failure** +```bash +node --test tests/indesign/parser-color-components.test.mjs +``` + +**Step 3: Implement** + +`pdf/color.js` — replace the bodies with re-exports so existing imports/tests keep working: +```js +// Re-exported from the shared color module so the PDF extractor and the mapper +// share one implementation. See ../color.js. +export { rgbToHex, grayToHex, cmykToHex, hexToRgb, colorDistance, nearestSwatch } from '../color.js'; +``` +> `cmykToHex` keeps its existing 0..1 signature for the PDF operator path — keep that function in `color.js` alongside the new `cmykToSrgb` (0..100). Document both ranges. + +`parsers/resources.js` — rewrite `toIrColor` to attach components and convert via the shared module: +```js +import { rgbToSrgbHex, cmykToSrgb, labToSrgb } from '../color.js'; +// ... +function toIrColor(spaceRaw, values, warnings, id) { + const space = normalizeColorSpace(spaceRaw); + if (space === 'RGB' && values.length === 3) { + return { hex: rgbToSrgbHex(values), space, components: values }; + } + if (space === 'CMYK' && values.length === 4) { + return { hex: cmykToSrgb(values).hex, space, components: values }; + } + if (space === 'LAB' && values.length === 3) { + const { hex, outOfGamut } = labToSrgb(values); + if (outOfGamut) { + warnings.add('color-out-of-gamut', `Swatch ${id} LAB color is outside the sRGB gamut; clamped`, { file: 'Resources/Graphic.xml', id }); + } + return { hex, space, components: values }; + } + warnings.add('color-fallback', `Swatch ${id} has unsupported color space "${spaceRaw}" with ${values.length} values; defaulted to black`, { file: 'Resources/Graphic.xml', id }); + return { hex: '#000000', space: 'Unknown' }; +} +``` +Delete the now-unused local `rgbToHex`/`cmykToHexApprox` (or keep `normalizeColorSpace`). Keep `normalizeColorSpace`. + +`pdf/extract.js` — capture the raw color, not just hex. Track a `fillColor` object next to `fillHex`: +```js +let fillColor = { space: 'RGB', components: [0, 0, 0] }; +// setFillRGBColor: +fillHex = rgbToHex([args[0], args[1], args[2]]); +fillColor = { space: 'RGB', components: [args[0], args[1], args[2]] }; +// setFillGray: +fillHex = grayToHex(args[0]); +fillColor = { space: 'RGB', components: [args[0], args[0], args[0]] }; +// setFillCMYKColor (pdfjs gives 0..1 → store 0..100 to match IR convention): +fillHex = cmykToHex([args[0], args[1], args[2], args[3]]); +fillColor = { space: 'CMYK', components: [args[0] * 100, args[1] * 100, args[2] * 100, args[3] * 100] }; +// in the showText sample push: +colorSamples.push({ fontSizePt: currentSize, hex: fillHex, space: fillColor.space, components: fillColor.components, glyphs }); +``` +Update the JSDoc `colorSamples` shape accordingly. + +`parse-pdf.js` — thread components into the synthesized (non-snapped) swatch: +```js +swatches.push({ id, name: sample.hex.toUpperCase(), color: { hex: sample.hex, space: sample.space ?? 'RGB', components: sample.components } }); +``` + +**Step 4: Run the full pipeline test suite, expect pass** +```bash +pnpm test +``` +Expected: all existing tests still pass (including `pdf-color.test.mjs`, `pdf-roundtrip.test.mjs`) plus the new parser-color test. If `pdf-roundtrip` asserts swatch color equality, components are additive and should not break it; if it deep-equals whole color objects, update the expected fixture to include components. + +**Step 5: Commit** +```bash +git add packages/pipeline/src/indesign/pdf/color.js packages/pipeline/src/indesign/parsers/resources.js packages/pipeline/src/indesign/pdf/extract.js packages/pipeline/src/indesign/parse-pdf.js packages/pipeline/tests/indesign/parser-color-components.test.mjs +git commit -m "feat(pipeline): populate raw color components and fix LAB swatch conversion" +``` + +--- + +## Task 5: Color mapping module (`map/colors.js`) + +**Files:** +- Create: `packages/pipeline/src/indesign/map/colors.js` +- Test: `packages/pipeline/tests/indesign/map-colors.test.mjs` + +**Behavior:** `mapColors(swatches, { basePalette, tolerance, namespace, warnings }) → { palette, swatchToSlug }`. +- Recompute hex via `colorFromComponents(space, components, hex)`. +- Dedupe by hex within `tolerance` (squared RGB distance via `colorDistance`); first/most-named wins. +- Reuse a base slug when a swatch is within `tolerance` of a base color; else namespaced slug `slugify(name)` prefixed with `namespace` when it would collide. +- Emit `color-out-of-gamut` (already from parser for LAB) and `swatch-approximated` (Spot/Unknown) warnings into the report. + +**Step 1: Failing test** (representative) +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mapColors } from '../../src/indesign/map/colors.js'; + +const base = [{ slug: 'primary', color: '#0b5cff', name: 'Primary' }]; + +test('includes all distinct swatches, deduped within tolerance', () => { + const swatches = [ + { id: 's1', name: 'Brand Blue', color: { hex: '#0066cc', space: 'RGB', components: [0, 102, 204] } }, + { id: 's2', name: 'Brand Blue Dup', color: { hex: '#0265cb', space: 'RGB', components: [2, 101, 203] } }, + { id: 's3', name: 'Ink', color: { hex: '#111111', space: 'CMYK', components: [0, 0, 0, 93] } }, + ]; + const { palette, swatchToSlug } = mapColors(swatches, { basePalette: base, tolerance: 24 * 24 * 3 }); + // s1 and s2 collapse to one entry; ink is separate → 2 derived entries. + assert.equal(palette.length, 2); + assert.equal(swatchToSlug.s1, swatchToSlug.s2); +}); + +test('reuses a base slug when close, instead of duplicating', () => { + const swatches = [{ id: 's1', name: 'Almost Primary', color: { hex: '#0b5dff', space: 'RGB', components: [11, 93, 255] } }]; + const { palette, swatchToSlug } = mapColors(swatches, { basePalette: base, tolerance: 24 * 24 * 3 }); + assert.equal(swatchToSlug.s1, 'primary'); + assert.equal(palette.length, 0); // nothing new to add; it maps onto the base +}); +``` + +**Step 2–4:** Run (fail) → implement `mapColors` + a small `slugify` helper (lowercase, `[^a-z0-9]+`→`-`, trim) → run (pass). + +**Step 5: Commit** `feat(pipeline): map IDML swatches to a deduped theme.json color palette`. + +--- + +## Task 6: Typography module (`map/typography.js`) + +**Files:** +- Create: `packages/pipeline/src/indesign/map/typography.js` +- Test: `packages/pipeline/tests/indesign/map-typography.test.mjs` + +**Behavior:** `mapTypography(styles, { baseFontSizes, swatchToSlug, fontToSlug, tolerancePx, fluidThresholdPx, namespace }) → { fontSizes, elements, styleToSlug }`. +- Filter `kind === 'paragraph'` with a numeric `fontSize`. +- Cluster sizes within `tolerancePx` (default 1px); representative = rounded mean. +- px→rem (`size / 16` with up to 3 decimals); if `>= fluidThresholdPx` and `fluid` enabled, emit a `clamp()` mirroring the base pattern (min = 0.85×, preferred = `Nvw`, max = size). +- Reuse base `fontSizes` slug when the representative px is within `tolerancePx` of the base slug's nominal px (resolve base sizes: parse `rem`→px×16; for `clamp(min,pref,max)` use the `max`). +- Else derived slug from the InDesign style name (slugified, namespaced on collision). Token `name` = InDesign style name. +- `elements`: map common heading/body style names to h1–h6/p/caption when recognizable; otherwise attach by size rank. Each element entry carries `fontSize` (var ref), `lineHeight` (from `leading/fontSize`), `letterSpacing` (from `tracking/1000`em), and text color (from `fillColorRef`→`swatchToSlug`). +- `styleToSlug`: every input paragraph style → its scale slug (proves "each entry referenced by ≥1 style"). + +**Step 1: Failing test** (representative) +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mapTypography } from '../../src/indesign/map/typography.js'; + +const baseFontSizes = [ + { slug: 'base', size: '1rem' }, // 16px + { slug: 'display', size: 'clamp(2.25rem, 5vw, 3.5rem)' }, // 56px max +]; + +test('clusters near-equal sizes and references every entry by a style', () => { + const styles = [ + { id: 'p1', name: 'Body', kind: 'paragraph', fontSize: 16 }, + { id: 'p2', name: 'Body Alt', kind: 'paragraph', fontSize: 16.4 }, + { id: 'p3', name: 'Title', kind: 'paragraph', fontSize: 56 }, + ]; + const { fontSizes, styleToSlug } = mapTypography(styles, { baseFontSizes, tolerancePx: 1 }); + // 16/16.4 reuse base 'base'; 56 reuses base 'display' → no new entries. + assert.equal(styleToSlug.p1, 'base'); + assert.equal(styleToSlug.p2, 'base'); + assert.equal(styleToSlug.p3, 'display'); + for (const entry of fontSizes) { + const referenced = Object.values(styleToSlug).includes(entry.slug); + assert.ok(referenced, `font size ${entry.slug} must be referenced by a style`); + } +}); + +test('creates a derived slug for a size with no base match', () => { + const styles = [{ id: 'p1', name: 'Lead', kind: 'paragraph', fontSize: 21 }]; + const { fontSizes, styleToSlug } = mapTypography(styles, { baseFontSizes, tolerancePx: 1, namespace: 'id' }); + assert.equal(fontSizes.length, 1); + assert.equal(styleToSlug.p1, fontSizes[0].slug); + assert.equal(fontSizes[0].name, 'Lead'); +}); +``` + +**Step 2–4:** Run (fail) → implement (factor out a `resolveBasePx(sizeString)` helper that parses `rem`/`px`/`clamp(...)`) → run (pass). + +**Step 5: Commit** `feat(pipeline): cluster paragraph styles into a theme.json typography scale`. + +--- + +## Task 7: theme.json assembly, deep-merge, and validation (`map/theme-json.js` + `schema/partial.zod.js`) + +**Files:** +- Create: `packages/pipeline/src/indesign/map/theme-json.js` +- Create: `packages/pipeline/src/indesign/map/schema/partial.zod.js` +- Test: `packages/pipeline/tests/indesign/map-theme-json.test.mjs` + +**Behavior:** +- `assemblePartial({ palette, fontSizes, fontFamilies, spacingSizes, elements }) → themeJsonPartial` (version 3, `settings`/`styles` only for the keys we emit). +- `mergeThemeJson(base, partial) → merged` — recursive object merge; token arrays under `color.palette`, `typography.fontSizes`, `typography.fontFamilies`, `spacing.spacingSizes` merge **by `slug`** (partial overrides/extends; namespacing prevents clobber). +- `validateThemeJson(themeJson) → { valid, errors }` — ajv against the vendored schema (configure dialect per Task 1) **and** the zod partial schema. Add a top-of-file comment recording the schema source URL + retrieval date. + +**Step 1: Failing tests** +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { assemblePartial, mergeThemeJson, validateThemeJson } from '../../src/indesign/map/theme-json.js'; + +const base = JSON.parse(readFileSync(new URL('../../../../themes/flavian-shop/theme.json', import.meta.url))); + +test('assembled partial validates against the WordPress schema', () => { + const partial = assemblePartial({ + palette: [{ slug: 'id-brand-blue', color: '#0066cc', name: 'Brand Blue' }], + fontSizes: [{ slug: 'id-lead', size: '1.3125rem', name: 'Lead' }], + fontFamilies: [], spacingSizes: [], elements: {}, + }); + const { valid, errors } = validateThemeJson(partial); + assert.ok(valid, JSON.stringify(errors)); +}); + +test('merge keeps base tokens and adds namespaced derived tokens', () => { + const partial = assemblePartial({ + palette: [{ slug: 'id-brand-blue', color: '#0066cc', name: 'Brand Blue' }], + fontSizes: [], fontFamilies: [], spacingSizes: [], elements: {}, + }); + const merged = mergeThemeJson(base, partial); + const slugs = merged.settings.color.palette.map((p) => p.slug); + assert.ok(slugs.includes('primary')); // base preserved + assert.ok(slugs.includes('id-brand-blue')); // derived added + assert.ok(validateThemeJson(merged).valid); +}); +``` + +**Step 2–4:** Run (fail) → implement. ajv setup: +```js +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; +import schema from './schema/theme-json.schema.json' with { type: 'json' }; +const ajv = new Ajv({ allErrors: true, strict: false }); +addFormats(ajv); +const validate = ajv.compile(schema); +``` +(If the schema is draft-04, swap to `ajv-draft-04`.) Run (pass). + +**Step 5: Commit** `feat(pipeline): assemble, deep-merge, and validate theme.json partials`. + +--- + +## Task 8: Spacing module (`map/spacing.js`) + +**Files:** +- Create: `packages/pipeline/src/indesign/map/spacing.js` +- Test: `packages/pipeline/tests/indesign/map-spacing.test.mjs` + +**Behavior:** `mapSpacing(ir, { gridPx, maxSizes, namespace, warnings }) → { spacingSizes }`. +- Collect candidate px values: page-margin offsets (`page.bounds` vs contained `frame.bounds`), inter-frame gaps (sort frames per spread, adjacent gaps), paragraph `properties.SpaceBefore`/`SpaceAfter`/`spaceBefore`/`spaceAfter` (convert via `lengthToPx` when stringy, else assume already px). +- Quantize to `gridPx` (default 4), drop ≤0, dedupe, sort ascending, cap to `maxSizes` (default 8), rank-name (`slug = ${namespace}-space-${i*10}`, `size = px/16 + 'rem'`). +- Add a `spacing-approximate` warning when values came from PDF geometry. + +**Step 1: Failing test** +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mapSpacing } from '../../src/indesign/map/spacing.js'; + +test('quantizes observed gaps to the grid and dedupes', () => { + const ir = { + dpi: 96, + spreads: [{ + id: 'sp1', source: 's', pages: [{ id: 'pg', bounds: { x: 0, y: 0, width: 600, height: 800 } }], + frames: [ + { kind: 'text', id: 'f1', bounds: { x: 48, y: 50, width: 500, height: 100 } }, + { kind: 'text', id: 'f2', bounds: { x: 48, y: 170, width: 500, height: 100 } }, // 20px gap + ], + }], + styles: [{ id: 'p1', name: 'Body', kind: 'paragraph', properties: { SpaceAfter: '12pt' } }], + masterSpreads: [], swatches: [], fonts: [], stories: [], + }; + const { spacingSizes } = mapSpacing(ir, { gridPx: 4 }); + const rems = spacingSizes.map((s) => s.size); + assert.ok(rems.includes('1.25rem')); // 20px → 1.25rem + assert.ok(spacingSizes.length <= 8); +}); +``` + +**Step 2–4:** Run (fail) → implement → run (pass). + +**Step 5: Commit** `feat(pipeline): derive a quantized spacing scale from IR geometry`. + +--- + +## Task 9: Font mapping (`map/fonts.js` + `config/font-map.json`) + +**Files:** +- Create: `packages/pipeline/config/font-map.json` +- Create: `packages/pipeline/src/indesign/map/fonts.js` +- Test: `packages/pipeline/tests/indesign/map-fonts.test.mjs` + +**`config/font-map.json`** — seed with common families: +```json +{ + "Helvetica": { "fontFamily": "Helvetica, Arial, sans-serif", "source": "system", "fallback": "sans-serif" }, + "Helvetica Neue": { "fontFamily": "'Helvetica Neue', Helvetica, Arial, sans-serif", "source": "system", "fallback": "sans-serif" }, + "Arial": { "fontFamily": "Arial, Helvetica, sans-serif", "source": "system", "fallback": "sans-serif" }, + "Georgia": { "fontFamily": "Georgia, 'Times New Roman', serif", "source": "system", "fallback": "serif" }, + "Times": { "fontFamily": "'Times New Roman', Times, serif", "source": "system", "fallback": "serif" }, + "Times New Roman": { "fontFamily": "'Times New Roman', Times, serif", "source": "system", "fallback": "serif" }, + "Courier": { "fontFamily": "'Courier New', Courier, monospace", "source": "system", "fallback": "monospace" }, + "Merriweather": { "fontFamily": "Merriweather, Georgia, serif", "source": "google", "googleFontName": "Merriweather", "fallback": "serif" }, + "Roboto": { "fontFamily": "Roboto, system-ui, sans-serif", "source": "google", "googleFontName": "Roboto", "fallback": "sans-serif" }, + "Open Sans": { "fontFamily": "'Open Sans', system-ui, sans-serif", "source": "google", "googleFontName": "Open Sans", "fallback": "sans-serif" }, + "Lato": { "fontFamily": "Lato, system-ui, sans-serif", "source": "google", "googleFontName": "Lato", "fallback": "sans-serif" }, + "Montserrat": { "fontFamily": "Montserrat, system-ui, sans-serif", "source": "google", "googleFontName": "Montserrat", "fallback": "sans-serif" } +} +``` + +**Behavior:** `mapFonts(fonts, { fontMap, baseFontFamilies, namespace, warnings }) → { fontFamilies, fontToSlug }`. +- For each distinct family: look up in `fontMap`. Found → reuse a base family slug when its stack matches a base entry; else namespaced slug, `name` = family, `fontFamily` = mapped stack (+ `googleFontName`/`source` carried for the generator's webfont step in a `_provenance`-free way — keep theme.json entries schema-clean; record google info in the report instead). +- Not found → `font-fallback` warning + heuristic generic (`/serif/i`→serif, `/mono|courier|consol/i`→monospace, else sans-serif), map onto the matching base slug (`sans`/`serif`) when present. +- `loadFontMap(path)` helper reads + JSON-parses the config, defaulting to the shipped file. + +**Step 1: Failing test** +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mapFonts } from '../../src/indesign/map/fonts.js'; + +const baseFamilies = [ + { slug: 'sans', name: 'Sans', fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" }, + { slug: 'serif', name: 'Serif', fontFamily: "Georgia, 'Times New Roman', serif" }, +]; +const fontMap = { Georgia: { fontFamily: "Georgia, 'Times New Roman', serif", source: 'system', fallback: 'serif' } }; + +test('maps a known family and warns + falls back for an unknown one', () => { + const warnings = []; + const fonts = [ + { id: 'f1', family: 'Georgia', style: 'Regular' }, + { id: 'f2', family: 'Bell Gothic', style: 'Bold' }, + ]; + const { fontToSlug } = mapFonts(fonts, { fontMap, baseFontFamilies: baseFamilies, warnings: { add: (code, msg) => warnings.push({ code, msg }) } }); + assert.equal(fontToSlug.f1, 'serif'); // matches base serif stack + assert.ok(warnings.some((w) => w.code === 'font-fallback' && /Bell Gothic/.test(w.msg))); + assert.equal(fontToSlug.f2, 'sans'); // heuristic generic +}); +``` + +**Step 2–4:** Run (fail) → implement → run (pass). + +**Step 5: Commit** `feat(pipeline): map InDesign fonts to web families with a fallback table`. + +--- + +## Task 10: DTCG design-tokens emitter (`map/design-tokens.js`) + +**Files:** +- Create: `packages/pipeline/src/indesign/map/design-tokens.js` +- Test: `packages/pipeline/tests/indesign/map-design-tokens.test.mjs` + +**Behavior:** `toDesignTokens({ palette, fontSizes, fontFamilies, spacingSizes }) → dtcg` with groups `color`/`fontSize`/`fontFamily`/`spacing`, each leaf `{ $value, $type, $description }`. `$type`: color→`color`, fontSize/spacing→`dimension`, fontFamily→`fontFamily`. + +**Step 1: Failing test** +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { toDesignTokens } from '../../src/indesign/map/design-tokens.js'; + +test('emits DTCG-shaped tokens with type and value', () => { + const dtcg = toDesignTokens({ + palette: [{ slug: 'id-brand', color: '#0066cc', name: 'Brand' }], + fontSizes: [{ slug: 'id-lead', size: '1.3125rem', name: 'Lead' }], + fontFamilies: [], spacingSizes: [], + }); + assert.equal(dtcg.color['id-brand'].$value, '#0066cc'); + assert.equal(dtcg.color['id-brand'].$type, 'color'); + assert.equal(dtcg.fontSize['id-lead'].$type, 'dimension'); +}); +``` + +**Step 2–4:** Run (fail) → implement → run (pass). + +**Step 5: Commit** `feat(pipeline): emit Style Dictionary (DTCG) design tokens`. + +--- + +## Task 11: Orchestrator + report (`map/index.js` + `map/report.js`) + +**Files:** +- Create: `packages/pipeline/src/indesign/map/report.js` +- Create: `packages/pipeline/src/indesign/map/index.js` +- Modify: `packages/pipeline/src/indesign/index.js` (export `mapTokens`) +- Test: `packages/pipeline/tests/indesign/map-tokens.test.mjs` + +**Behavior:** `mapTokens(ir, options) → { partial, designTokens, merged, report }`. +- Order: colors → fonts → typography (needs swatchToSlug + fontToSlug) → spacing → assemble partial → merge with base → design tokens → report. +- `report`: `{ warnings: [...irWarnings, ...mapperWarnings], provenance: { swatchToSlug, styleToSlug, fontToSlug }, fontFallbacks: [...], outOfGamut: [...] }`. +- `options`: `{ base, fontMap, tolerance, gridPx, fluid, namespace }` with sensible defaults (base = bundled flavian-shop theme via `loadBaseTheme()`; namespace derived from `ir.meta.name` slug or `'id'`). + +**Step 1: Failing e2e test** — build an IR with the IDML helper *and* the PDF helper, run `mapTokens` on each, assert artifacts and acceptance invariants: +```js +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mapTokens } from '../../src/indesign/map/index.js'; +import { parseIdmlBuffer } from '../../src/indesign/parse-idml.js'; +import { buildHappyPath } from './helpers/build-idml.js'; // confirm exact export name + +test('maps an IDML-derived IR into validated tokens', () => { + const ir = parseIdmlBuffer(buildHappyPath()); + const { partial, designTokens, merged, report } = mapTokens(ir); + assert.ok(merged.settings.color.palette.length > 0); + // Every typography entry is referenced by a paragraph style. + const referenced = new Set(Object.values(report.provenance.styleToSlug)); + for (const e of partial.settings.typography?.fontSizes ?? []) assert.ok(referenced.has(e.slug)); + assert.ok(designTokens.color); +}); +``` +(Confirm helper export names by reading `tests/indesign/helpers/build-idml.js` and `build-pdf.js`; add a PDF-IR variant of the test — the PDF helper is async via `parsePdfBuffer`.) + +**Step 2–4:** Run (fail) → implement orchestrator + report → run (pass). + +**Step 5: Commit** `feat(pipeline): mapTokens orchestrator producing theme.json, tokens, and report`. + +--- + +## Task 12: CLI (`bin/map-tokens.mjs`) + +**Files:** +- Create: `packages/pipeline/bin/map-tokens.mjs` +- Modify: `packages/pipeline/package.json` (`bin` entry, optional) +- Test: covered via the library; optionally a smoke test that spawns the CLI. + +**Behavior:** Read IR JSON from a path or stdin (`-`); if the path ends `.idml`/`.pdf`, parse it first. Options `--out-dir`, `--base`, `--font-map`, `--grid`, `--tolerance`, `--fluid`, `--quiet`, `-h`. Write the partial to stdout; warnings/report summary to stderr; when `--out-dir` is set, write `theme.partial.json`, `design-tokens.json`, `theme.merged.json`, and `report.json`. Mirror the arg-parsing + exit-code style of `bin/parse-idml.mjs`. + +**Step 1–4:** Implement; verify manually: +```bash +node packages/pipeline/bin/parse-idml.mjs | node packages/pipeline/bin/map-tokens.mjs --out-dir ./tmp-tokens +``` +Expected: partial JSON on stdout; four files in `./tmp-tokens`; report summary on stderr. (Use a fixture-built IR if no real IDML is handy: `node -e` to emit one, or pipe a saved IR JSON.) + +**Step 5: Commit** `feat(pipeline): add map-tokens CLI`. + +--- + +## Task 13: Docs + final verification + PR + +**Files:** +- Create: `docs/pipeline/indesign-token-mapper.md` +- Modify: `packages/pipeline/README.md` (link the new stage), `CLAUDE.md` quick-reference if warranted. + +**Steps:** +1. Write `docs/pipeline/indesign-token-mapper.md` matching the fidelity-guide style: overview, CLI usage, conversion math (CMYK note + LAB pipeline), config (`font-map.json`), warnings table (`color-out-of-gamut`, `font-fallback`, `spacing-approximate`, `swatch-approximated`), merge semantics, and acceptance-criteria mapping. +2. Run the full suite + confirm clean: + ```bash + pnpm --filter @flavian/pipeline test + ``` + Expected: all tests pass. +3. Verify acceptance criteria one by one against the design doc table. +4. Commit docs: `docs(pipeline): document the InDesign token mapper`. +5. Push the branch and open a PR (per project convention — always push + PR when finishing): + ```bash + git push -u origin 64-indesign-pipeline-style-and-design-token-mapper-paragraphcharacter-styles-swatches-themejson + gh pr create --fill --base main + ``` + PR body: summary, the four design decisions, acceptance-criteria checklist, test plan, and `Closes #64`. End with the Claude Code attribution line. + +--- + +## Acceptance criteria → task coverage + +| Criterion | Task(s) | +|-----------|---------| +| theme.json validates against WP schema | 1, 7 | +| Palette includes all distinct swatches, deduped by hex tolerance | 5 | +| Each typography entry referenced by ≥1 paragraph style | 6, 11 | +| Font fallback warnings emitted + in report | 9, 11 | +| Tests: CMYK→sRGB fixtures, clustering, merge-with-base | 3, 6, 7 | +| Works on either IR (IDML or PDF) | 4, 11 | + +## Risks / watch-items +- **Schema dialect:** the vendored WP schema may be draft-04 → use `ajv-draft-04` (Task 1/7). +- **fast-xml-parser attribute keys:** confirm the `@`-prefix + namespace handling when writing the IDML color fixture (Task 4) by checking `parsers/xml.js`. +- **pdf-roundtrip fixture:** adding `components` is additive but check any deep-equality on color objects (Task 4). +- **Base theme path** resolution in tests uses `import.meta.url`; keep the relative depth correct (`packages/pipeline/tests/indesign` → repo `themes/flavian-shop/theme.json`). diff --git a/packages/pipeline/README.md b/packages/pipeline/README.md index 144fb50..4476ead 100644 --- a/packages/pipeline/README.md +++ b/packages/pipeline/README.md @@ -4,7 +4,7 @@ Conversion pipeline for InDesign (and future) sources into WordPress FSE themes. ## Status -This package ships the **IDML parser** (sub-issue #62) and the **PDF fallback parser** (sub-issue #63) of the InDesign-to-WordPress epic. Both emit the same intermediate representation. Downstream stages — style + token mapper (#64), output generator (#65) — will land as separate PRs. The IR shape produced here is the contract those stages consume. +This package ships the **IDML parser** (sub-issue #62), the **PDF fallback parser** (sub-issue #63), and the **style + token mapper** (sub-issue #64) of the InDesign-to-WordPress epic. The two parsers emit the same intermediate representation; the mapper turns that IR into WordPress design tokens (a `theme.json` partial, DTCG `design-tokens.json`, and a report). The output generator (#65) will land as a separate PR. IDML is the primary path (full access to stories, frames, styles, swatches, masters). PDF is a lossy fallback for when only the exported PDF is available, or as a verification source against IDML output — see [`docs/pipeline/indesign-pdf-fidelity.md`](../../docs/pipeline/indesign-pdf-fidelity.md). @@ -14,13 +14,17 @@ IDML is the primary path (full access to stories, frames, styles, swatches, mast packages/pipeline/ ├── bin/ │ ├── parse-idml.mjs CLI: IDML → validated IR JSON on stdout -│ └── parse-pdf.mjs CLI: PDF → reconstructed IR JSON on stdout +│ ├── parse-pdf.mjs CLI: PDF → reconstructed IR JSON on stdout +│ └── map-tokens.mjs CLI: IR (or .idml/.pdf) → theme.json + design tokens +├── config/ +│ └── font-map.json InDesign family → web/Google font fallback table └── src/ ├── index.js Re-exports the InDesign surface └── indesign/ ├── ir.js zod schemas + JSDoc typedefs for the IR ├── parse-idml.js IDML entry: unzips + orchestrates + cross-refs + validates ├── parse-pdf.js PDF entry: extracts + clusters + classifies + validates + ├── color.js Shared color math: RGB/CMYK/LAB → sRGB + gamut, nearest-swatch ├── units.js pt/pc/mm/cm/in → px at configurable DPI ├── warnings.js Non-fatal warning collector ├── parsers/ IDML XML decoders @@ -29,14 +33,25 @@ packages/pipeline/ │ ├── resources.js Graphic.xml + Fonts.xml + Styles.xml │ ├── stories.js Stories/Story_*.xml → text runs │ └── spreads.js Spreads/*.xml + MasterSpreads/*.xml - └── pdf/ PDF reconstruction modules - ├── pdfjs.js Lazy pdfjs-dist loader (headless, extraction-only) - ├── extract.js Per-page: text runs, fonts, colors, images, vector flag - ├── cluster.js Glyph runs → lines → frames; column detection (pure) - ├── classify.js Font-size buckets → heading/body/caption styles (pure) - ├── color.js RGB/gray/CMYK → hex; nearest-swatch matching (pure) - ├── png.js Decoded pixels → PNG via node:zlib (pure) - └── assets.js Write extracted images to the asset cache + ├── pdf/ PDF reconstruction modules + │ ├── pdfjs.js Lazy pdfjs-dist loader (headless, extraction-only) + │ ├── extract.js Per-page: text runs, fonts, colors, images, vector flag + │ ├── cluster.js Glyph runs → lines → frames; column detection (pure) + │ ├── classify.js Font-size buckets → heading/body/caption styles (pure) + │ ├── color.js Re-exports the shared color helpers for the PDF path + │ ├── png.js Decoded pixels → PNG via node:zlib (pure) + │ └── assets.js Write extracted images to the asset cache + └── map/ IR → WordPress design tokens (token mapper) + ├── index.js mapTokens orchestrator → { partial, designTokens, merged, report } + ├── colors.js Swatches → color palette (convert, dedupe, reuse base) + ├── typography.js Paragraph styles → font-size scale + element/block presets + ├── spacing.js Geometry + paragraph spacing → quantized spacing scale + ├── fonts.js Fonts → font families via config/font-map.json + ├── theme-json.js Assemble partial, deep-merge with base, validate + ├── design-tokens.js DTCG / Style Dictionary emitter + ├── report.js Warnings + provenance aggregation + ├── slug.js Namespaced slug helpers + └── schema/ Vendored WP theme.json schema + zod subset ``` ## Quick start @@ -80,6 +95,41 @@ node packages/pipeline/bin/parse-pdf.mjs brochure.pdf --asset-dir ./assets > ir. PDF reconstruction is lossy by design. See [`docs/pipeline/indesign-pdf-fidelity.md`](../../docs/pipeline/indesign-pdf-fidelity.md) for how each IR element is derived, the full list of fidelity-warning codes, and the round-trip tolerances against IDML. +## Token mapper + +Map a parsed IR (from either parser) into WordPress design tokens: a `theme.json` +partial that merges into a base theme, a Style-Dictionary-compatible +`design-tokens.json`, and a report. + +```js +import { parseIdml, mapTokens } from '@flavian/pipeline'; + +const ir = await parseIdml('./brochure.idml'); +const { partial, designTokens, merged, report } = mapTokens(ir, { + // base, // base theme object/path (default: themes/flavian-shop/theme.json) + // fontMap, // font map object/path (default: config/font-map.json) + // namespace, // derived-token slug prefix (default: 'id') + // tolerance, // color dedupe/reuse squared distance + // gridPx, // spacing grid (default: 4) + // tolerancePx, // typography size clustering tolerance (default: 1) +}); + +if (!report.valid) console.error(report.validationErrors); +for (const msg of report.fontFallbacks) console.warn(msg); +``` + +From the command line (composes with the parser CLIs, or parses directly): + +```bash +node packages/pipeline/bin/parse-idml.mjs brochure.idml \ + | node packages/pipeline/bin/map-tokens.mjs --out-dir ./tokens > theme.partial.json + +# or parse + map in one step +node packages/pipeline/bin/map-tokens.mjs brochure.idml --out-dir ./tokens +``` + +See [`docs/pipeline/indesign-token-mapper.md`](../../docs/pipeline/indesign-token-mapper.md) for the conversion math (CMYK/LAB → sRGB), the font-map format, the warning codes, merge semantics, and how the acceptance criteria are met. + ## IR shape The intermediate representation is described in [`src/indesign/ir.js`](src/indesign/ir.js). At the top level: @@ -89,7 +139,7 @@ The intermediate representation is described in [`src/indesign/ir.js`](src/indes irVersion: 1, meta: { idmlVersion: '16.0', name: 'Brochure' }, dpi: 96, - swatches: [{ id, name, color: { hex, space } }], + swatches: [{ id, name, color: { hex, space, components } }], fonts: [{ id, family, style, postScriptName }], styles: [{ id, name, kind, fontSize, leading, tracking, fontRef, fillColorRef, properties }], stories: [{ id, source, runs: [{ text, paragraphStyleRef, characterStyleRef }] }], diff --git a/packages/pipeline/bin/map-tokens.mjs b/packages/pipeline/bin/map-tokens.mjs new file mode 100644 index 0000000..a3b1779 --- /dev/null +++ b/packages/pipeline/bin/map-tokens.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +// CLI: map an InDesign IR to WordPress design tokens. +// +// flavian-map-tokens [options] +// +// Reads a validated IR JSON (from parse-idml / parse-pdf) on a path or stdin, +// or parses an .idml/.pdf directly. Prints the theme.json partial on stdout and +// a report summary on stderr. With --out-dir, also writes the full artifact set. +// +// Options: +// --out-dir

Write theme.partial.json, theme.merged.json, +// design-tokens.json, and report.json here. +// --base Base theme.json to merge against (default: flavian-shop). +// --font-map Font map JSON (default: bundled config/font-map.json). +// --namespace Derived-token slug prefix (default: id). +// --grid Spacing quantization grid (default: 4). +// --tolerance Color dedupe/reuse squared-distance tolerance. +// --type-tolerance Typography size clustering tolerance (default: 1). +// --dpi DPI when parsing .idml/.pdf directly (default: 96). +// --fluid Emit fluid clamp() font sizes. +// --quiet Suppress the stderr report summary. +// -h, --help Show this help. + +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import { parseIdml } from '../src/indesign/parse-idml.js'; +import { parsePdf } from '../src/indesign/parse-pdf.js'; +import { mapTokens } from '../src/indesign/map/index.js'; + +const args = process.argv.slice(2); +const opts = { fluid: false, quiet: false }; +let inputPath; + +function wantsValue(flag) { + const next = args[i + 1]; + if (next === undefined || next.startsWith('-')) { + process.stderr.write(`${flag} requires a value\n`); + process.exit(2); + } + return next; +} + +let i = 0; +for (; i < args.length; i += 1) { + const arg = args[i]; + switch (arg) { + case '--out-dir': opts.outDir = wantsValue(arg); i += 1; break; + case '--base': opts.base = wantsValue(arg); i += 1; break; + case '--font-map': opts.fontMap = wantsValue(arg); i += 1; break; + case '--namespace': opts.namespace = wantsValue(arg); i += 1; break; + case '--grid': opts.gridPx = Number(wantsValue(arg)); i += 1; break; + case '--tolerance': opts.tolerance = Number(wantsValue(arg)); i += 1; break; + case '--type-tolerance': opts.tolerancePx = Number(wantsValue(arg)); i += 1; break; + case '--dpi': opts.dpi = Number(wantsValue(arg)); i += 1; break; + case '--fluid': opts.fluid = true; break; + case '--quiet': opts.quiet = true; break; + case '-h': case '--help': printUsage(); process.exit(0); break; + default: + if (!inputPath && !arg.startsWith('-')) { + inputPath = arg; + } else { + process.stderr.write(`Unknown argument: ${arg}\n`); + printUsage(); + process.exit(2); + } + } +} + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) chunks.push(chunk); + return Buffer.concat(chunks).toString('utf8'); +} + +async function loadIr() { + const dpiOpt = opts.dpi !== undefined ? { dpi: opts.dpi } : undefined; + if (inputPath && /\.idml$/i.test(inputPath)) return parseIdml(inputPath, dpiOpt); + if (inputPath && /\.pdf$/i.test(inputPath)) return parsePdf(inputPath, dpiOpt); + const text = inputPath && inputPath !== '-' ? await fs.readFile(inputPath, 'utf8') : await readStdin(); + return JSON.parse(text); +} + +try { + const ir = await loadIr(); + const { partial, designTokens, merged, report } = mapTokens(ir, { + base: opts.base, + fontMap: opts.fontMap, + namespace: opts.namespace, + gridPx: opts.gridPx, + tolerance: opts.tolerance, + tolerancePx: opts.tolerancePx, + fluid: opts.fluid, + }); + + if (opts.outDir) { + await fs.mkdir(opts.outDir, { recursive: true }); + const write = (name, value) => fs.writeFile(path.join(opts.outDir, name), `${JSON.stringify(value, null, 2)}\n`); + await Promise.all([ + write('theme.partial.json', partial), + write('theme.merged.json', merged), + write('design-tokens.json', designTokens), + write('report.json', report), + ]); + } + + if (!opts.quiet) { + const lines = [ + `theme.json valid: ${report.valid}`, + `swatches: ${report.counts.swatches} fonts: ${report.counts.fonts} styles: ${report.counts.styles}`, + ]; + if (report.fontFallbacks.length) lines.push(`font fallbacks: ${report.fontFallbacks.length}`); + if (report.outOfGamut.length) lines.push(`out-of-gamut colors: ${report.outOfGamut.length}`); + if (report.googleFonts.length) lines.push(`google fonts: ${report.googleFonts.map((g) => g.name).join(', ')}`); + if (!report.valid) lines.push(`validation errors: ${report.validationErrors.length}`); + process.stderr.write(`${lines.join('\n')}\n`); + } + + process.stdout.write(`${JSON.stringify(partial, null, 2)}\n`); + process.exit(report.valid ? 0 : 1); +} catch (err) { + process.stderr.write(`error: ${err.message}\n`); + process.exit(1); +} + +function printUsage() { + process.stderr.write( + [ + 'Usage: flavian-map-tokens [options]', + '', + 'Options:', + ' --out-dir Write all artifacts (partial, merged, tokens, report) here', + ' --base Base theme.json to merge against', + ' --font-map Font map JSON (default: bundled config/font-map.json)', + ' --namespace Derived-token slug prefix (default: id)', + ' --grid Spacing quantization grid (default: 4)', + ' --tolerance Color dedupe/reuse squared-distance tolerance', + ' --type-tolerance Typography size clustering tolerance (default: 1)', + ' --dpi DPI when parsing .idml/.pdf directly (default: 96)', + ' --fluid Emit fluid clamp() font sizes', + ' --quiet Suppress the stderr report summary', + ' -h, --help Show this help', + '', + ].join('\n'), + ); +} diff --git a/packages/pipeline/config/font-map.json b/packages/pipeline/config/font-map.json new file mode 100644 index 0000000..0c88f89 --- /dev/null +++ b/packages/pipeline/config/font-map.json @@ -0,0 +1,25 @@ +{ + "Helvetica": { "fontFamily": "Helvetica, Arial, sans-serif", "source": "system", "fallback": "sans-serif" }, + "Helvetica Neue": { "fontFamily": "'Helvetica Neue', Helvetica, Arial, sans-serif", "source": "system", "fallback": "sans-serif" }, + "Arial": { "fontFamily": "Arial, Helvetica, sans-serif", "source": "system", "fallback": "sans-serif" }, + "Verdana": { "fontFamily": "Verdana, Geneva, sans-serif", "source": "system", "fallback": "sans-serif" }, + "Tahoma": { "fontFamily": "Tahoma, Geneva, sans-serif", "source": "system", "fallback": "sans-serif" }, + "Trebuchet MS": { "fontFamily": "'Trebuchet MS', Helvetica, sans-serif", "source": "system", "fallback": "sans-serif" }, + "Gill Sans": { "fontFamily": "'Gill Sans', 'Gill Sans MT', Calibri, sans-serif", "source": "system", "fallback": "sans-serif" }, + "Futura": { "fontFamily": "Futura, 'Century Gothic', sans-serif", "source": "system", "fallback": "sans-serif" }, + "Georgia": { "fontFamily": "Georgia, 'Times New Roman', serif", "source": "system", "fallback": "serif" }, + "Times": { "fontFamily": "'Times New Roman', Times, serif", "source": "system", "fallback": "serif" }, + "Times New Roman": { "fontFamily": "'Times New Roman', Times, serif", "source": "system", "fallback": "serif" }, + "Garamond": { "fontFamily": "Garamond, 'EB Garamond', Georgia, serif", "source": "system", "fallback": "serif" }, + "Baskerville": { "fontFamily": "Baskerville, 'Libre Baskerville', Georgia, serif", "source": "system", "fallback": "serif" }, + "Courier": { "fontFamily": "'Courier New', Courier, monospace", "source": "system", "fallback": "monospace" }, + "Courier New": { "fontFamily": "'Courier New', Courier, monospace", "source": "system", "fallback": "monospace" }, + "Merriweather": { "fontFamily": "Merriweather, Georgia, serif", "source": "google", "googleFontName": "Merriweather", "fallback": "serif" }, + "Playfair Display": { "fontFamily": "'Playfair Display', Georgia, serif", "source": "google", "googleFontName": "Playfair Display", "fallback": "serif" }, + "Roboto": { "fontFamily": "Roboto, system-ui, sans-serif", "source": "google", "googleFontName": "Roboto", "fallback": "sans-serif" }, + "Open Sans": { "fontFamily": "'Open Sans', system-ui, sans-serif", "source": "google", "googleFontName": "Open Sans", "fallback": "sans-serif" }, + "Lato": { "fontFamily": "Lato, system-ui, sans-serif", "source": "google", "googleFontName": "Lato", "fallback": "sans-serif" }, + "Montserrat": { "fontFamily": "Montserrat, system-ui, sans-serif", "source": "google", "googleFontName": "Montserrat", "fallback": "sans-serif" }, + "Source Sans Pro": { "fontFamily": "'Source Sans Pro', system-ui, sans-serif", "source": "google", "googleFontName": "Source Sans Pro", "fallback": "sans-serif" }, + "Poppins": { "fontFamily": "Poppins, system-ui, sans-serif", "source": "google", "googleFontName": "Poppins", "fallback": "sans-serif" } +} diff --git a/packages/pipeline/package.json b/packages/pipeline/package.json index ba2dcf6..9a1b92e 100644 --- a/packages/pipeline/package.json +++ b/packages/pipeline/package.json @@ -23,5 +23,9 @@ }, "engines": { "node": ">=20.0.0" + }, + "devDependencies": { + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1" } } diff --git a/packages/pipeline/src/indesign/color.js b/packages/pipeline/src/indesign/color.js new file mode 100644 index 0000000..23261af --- /dev/null +++ b/packages/pipeline/src/indesign/color.js @@ -0,0 +1,191 @@ +// Shared color module: the single source of truth for color math across the +// IDML parser, the PDF parser, and the token mapper. +// +// Two families of conversion live here: +// 1. Device formatting/snapping used by the PDF operator path +// (rgbToHex/grayToHex/cmykToHex take pdfjs-shaped inputs). +// 2. Documented source-space → sRGB conversion used by the mapper +// (rgbToSrgbHex/cmykToSrgb/labToSrgb take IR component ranges and report +// out-of-gamut). See colorFromComponents for the dispatcher. + +/** + * @param {number} n + * @returns {string} two-digit lowercase hex + */ +function hexByte(n) { + return Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'); +} + +/** + * @param {[number, number, number]} rgb 0..255 per channel + * @returns {string} + */ +export function rgbToHex([r, g, b]) { + return `#${hexByte(r)}${hexByte(g)}${hexByte(b)}`; +} + +/** + * @param {number} gray 0..255 + * @returns {string} + */ +export function grayToHex(gray) { + return rgbToHex([gray, gray, gray]); +} + +/** + * DeviceCMYK (0..1) → hex. The naive conversion the PDF operator path uses; + * kept range-compatible with pdfjs (0..1). For the mapper's IR-range CMYK + * (0..100) use cmykToSrgb instead. + * + * @param {[number, number, number, number]} cmyk 0..1 per channel + * @returns {string} + */ +export function cmykToHex([c, m, y, k]) { + const r = 255 * (1 - c) * (1 - k); + const g = 255 * (1 - m) * (1 - k); + const b = 255 * (1 - y) * (1 - k); + return rgbToHex([r, g, b]); +} + +/** + * @param {string} hex "#rrggbb" + * @returns {[number, number, number]} + */ +export function hexToRgb(hex) { + const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); + if (!m) return [0, 0, 0]; + return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)]; +} + +/** + * Squared Euclidean distance in RGB. Squared is enough for "which is closest" + * and avoids a sqrt per comparison. + * + * @param {string} a "#rrggbb" + * @param {string} b "#rrggbb" + * @returns {number} + */ +export function colorDistance(a, b) { + const [r1, g1, b1] = hexToRgb(a); + const [r2, g2, b2] = hexToRgb(b); + return (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2; +} + +/** + * Find the closest swatch in a palette, within a tolerance. + * + * @param {string} hex "#rrggbb" + * @param {Array} palette + * @param {number} [maxDistance] Squared-distance cutoff (default ~24/channel). + * @returns {import('./ir.js').SwatchIR | null} + */ +export function nearestSwatch(hex, palette, maxDistance = 24 * 24 * 3) { + let best = null; + let bestDist = Infinity; + for (const swatch of palette) { + const dist = colorDistance(hex, swatch.color.hex); + if (dist < bestDist) { + bestDist = dist; + best = swatch; + } + } + return best && bestDist <= maxDistance ? best : null; +} + +// --- Source-space → sRGB conversion (mapper) ------------------------------- + +/** + * Format an IR-range RGB triple (0..255) as an sRGB hex string. Alias of + * rgbToHex for callers that think in terms of "source space". + * + * @param {[number, number, number]} rgb + * @returns {string} + */ +export const rgbToSrgbHex = rgbToHex; + +/** + * Naive, profile-free CMYK → sRGB. Components in 0..100 (the IR's range), which + * matches the parser's preview conversion so the same swatch lands on the same + * hex in both the IR and the tokens. Without an ICC profile this conversion + * never clips, so outOfGamut is always false (documented, not faked). + * + * @param {[number, number, number, number]} cmyk 0..100 per channel + * @returns {{ hex: string, outOfGamut: boolean }} + */ +export function cmykToSrgb([c, m, y, k]) { + const cv = c / 100, mv = m / 100, yv = y / 100, kv = k / 100; + const r = 255 * (1 - cv) * (1 - kv); + const g = 255 * (1 - mv) * (1 - kv); + const b = 255 * (1 - yv) * (1 - kv); + return { hex: rgbToHex([r, g, b]), outOfGamut: false }; +} + +// CIELAB → XYZ uses D50 (the ICC/InDesign connection-space white). The matrix +// below maps XYZ(D50) → linear sRGB with the Bradford adaptation to D65 folded +// in (Lindbloom), so it is paired with the matching D50 white point below. +const D50 = { Xn: 0.96422, Yn: 1.0, Zn: 0.82521 }; +const EPS = 216 / 24389; +const KAPPA = 24389 / 27; +const GAMUT_TOLERANCE = 1e-4; +const XYZ_D50_TO_LINEAR_SRGB = [ + [3.1338561, -1.6168667, -0.4906146], + [-0.9787684, 1.9161415, 0.0334540], + [0.0719453, -0.2289914, 1.4052427], +]; + +function srgbGamma(c) { + return c <= 0.0031308 ? 12.92 * c : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; +} + +/** + * CIELAB (D50) → sRGB. L in 0..100, a/b in roughly -128..127. Reports whether + * the color fell outside the sRGB gamut (a linear channel < 0 or > 1 before + * clamping), so the mapper can warn that the hex is an approximation. + * + * @param {[number, number, number]} lab + * @returns {{ hex: string, outOfGamut: boolean }} + */ +export function labToSrgb([L, a, b]) { + const fy = (L + 16) / 116; + const fx = fy + a / 500; + const fz = fy - b / 200; + const fx3 = fx ** 3; + const fz3 = fz ** 3; + const xr = fx3 > EPS ? fx3 : (116 * fx - 16) / KAPPA; + const yr = L > KAPPA * EPS ? fy ** 3 : L / KAPPA; + const zr = fz3 > EPS ? fz3 : (116 * fz - 16) / KAPPA; + const X = xr * D50.Xn; + const Y = yr * D50.Yn; + const Z = zr * D50.Zn; + const lin = XYZ_D50_TO_LINEAR_SRGB.map((row) => row[0] * X + row[1] * Y + row[2] * Z); + const outOfGamut = lin.some((v) => v < -GAMUT_TOLERANCE || v > 1 + GAMUT_TOLERANCE); + const rgb = /** @type {[number, number, number]} */ ( + lin.map((v) => srgbGamma(Math.max(0, Math.min(1, v))) * 255) + ); + return { hex: rgbToHex(rgb), outOfGamut }; +} + +/** + * Convert a swatch's raw components to sRGB, dispatching on color space. Falls + * back to the supplied parse-time hex when components are absent or the space is + * opaque (Spot/Unknown — no documented numeric conversion). + * + * @param {import('./ir.js').ColorIR['space']} space + * @param {number[] | undefined} components + * @param {string} fallbackHex + * @returns {{ hex: string, outOfGamut: boolean }} + */ +export function colorFromComponents(space, components, fallbackHex) { + if (components && components.length) { + if (space === 'RGB' && components.length >= 3) { + return { hex: rgbToSrgbHex(/** @type {any} */ (components)), outOfGamut: false }; + } + if (space === 'CMYK' && components.length >= 4) { + return cmykToSrgb(/** @type {any} */ (components)); + } + if (space === 'LAB' && components.length >= 3) { + return labToSrgb(/** @type {any} */ (components)); + } + } + return { hex: fallbackHex, outOfGamut: false }; +} diff --git a/packages/pipeline/src/indesign/index.js b/packages/pipeline/src/indesign/index.js index e76cd88..02adad7 100644 --- a/packages/pipeline/src/indesign/index.js +++ b/packages/pipeline/src/indesign/index.js @@ -3,3 +3,4 @@ export { parsePdf, parsePdfBuffer } from './parse-pdf.js'; export * as ir from './ir.js'; export { WarningCollector } from './warnings.js'; export { lengthToPx, ptToPx, roundPx } from './units.js'; +export { mapTokens } from './map/index.js'; diff --git a/packages/pipeline/src/indesign/ir.js b/packages/pipeline/src/indesign/ir.js index 859d2ea..8e4347e 100644 --- a/packages/pipeline/src/indesign/ir.js +++ b/packages/pipeline/src/indesign/ir.js @@ -17,10 +17,19 @@ import { z } from 'zod'; export const IdmlId = z.string().min(1); export const Color = z.object({ - // "#RRGGBB" once normalized; CMYK values are converted in the mapper. + // "#RRGGBB" — the parse-time value (naive for CMYK; legacy black for LAB + // when no components were captured). The mapper re-derives from `components`. hex: z.string().regex(/^#[0-9A-Fa-f]{6}$/), // Source color space as declared by IDML, kept for the mapper to flag. space: z.enum(['RGB', 'CMYK', 'LAB', 'Spot', 'Unknown']), + // Raw channel values in the source space's documented range, so the mapper + // can do a proper, documented conversion to sRGB: + // RGB [r,g,b] 0..255 + // CMYK [c,m,y,k] 0..100 + // LAB [L,a,b] L 0..100, a/b -128..127 + // Gray [v] 0..255 (stored as an RGB triple) + // Optional so older IRs still validate; the mapper falls back to `hex`. + components: z.array(z.number()).optional(), }); export const Swatch = z.object({ @@ -166,6 +175,7 @@ export const Document = z.object({ * @typedef {z.infer} StoryIR * @typedef {z.infer} StyleIR * @typedef {z.infer} SwatchIR + * @typedef {z.infer} ColorIR * @typedef {z.infer} FontIR * @typedef {z.infer} MasterSpreadIR * @typedef {z.infer} ParseWarningIR diff --git a/packages/pipeline/src/indesign/map/colors.js b/packages/pipeline/src/indesign/map/colors.js new file mode 100644 index 0000000..08bb062 --- /dev/null +++ b/packages/pipeline/src/indesign/map/colors.js @@ -0,0 +1,92 @@ +// Map IR swatches to a theme.json color palette. +// +// For each swatch we re-derive sRGB from its raw components (documented math, +// better than the parser's preview hex for LAB), then either reuse a close base +// palette slug or add a deduped, namespaced derived token. Out-of-gamut and +// opaque-space (Spot/Unknown) swatches raise warnings the report surfaces. + +import { colorFromComponents, colorDistance } from '../color.js'; +import { namespacedSlug } from './slug.js'; + +const DEFAULT_TOLERANCE = 24 * 24 * 3; // squared RGB distance, ~24/channel + +/** + * @param {string} hex + * @param {Array<{ slug: string, hex: string }>} candidates + * @param {number} tolerance + * @returns {{ slug: string, hex: string } | null} + */ +function nearestByHex(hex, candidates, tolerance) { + let best = null; + let bestDist = Infinity; + for (const c of candidates) { + const d = colorDistance(hex, c.hex); + if (d < bestDist) { + bestDist = d; + best = c; + } + } + return best && bestDist <= tolerance ? best : null; +} + +/** + * @typedef {Object} MapColorsOptions + * @property {Array<{ slug: string, color: string, name?: string }>} [basePalette] + * @property {number} [tolerance] Squared RGB distance for dedupe + base reuse. + * @property {string} [namespace] Prefix for derived slugs (default 'id'). + * @property {{ add: (code: string, message: string, context?: object) => void }} [warnings] + * + * @param {Array} swatches + * @param {MapColorsOptions} [options] + * @returns {{ palette: Array<{ slug: string, color: string, name: string }>, swatchToSlug: Record }} + */ +export function mapColors(swatches, options = {}) { + const { + basePalette = [], + tolerance = DEFAULT_TOLERANCE, + namespace = 'id', + warnings, + } = options; + const warn = warnings && typeof warnings.add === 'function' + ? (code, message, context) => warnings.add(code, message, context) + : () => {}; + + /** @type {Array<{ slug: string, color: string, name: string }>} */ + const palette = []; + /** @type {Record} */ + const swatchToSlug = {}; + const used = new Set(basePalette.map((p) => p.slug)); + const baseCandidates = basePalette.map((p) => ({ slug: p.slug, hex: p.color })); + + for (const sw of swatches) { + const { hex, outOfGamut } = colorFromComponents(sw.color.space, sw.color.components, sw.color.hex); + if (outOfGamut) { + warn('color-out-of-gamut', `Swatch ${sw.id} (${sw.name}) is outside the sRGB gamut; using the clamped color`, { id: sw.id }); + } + if (sw.color.space === 'Spot' || sw.color.space === 'Unknown') { + warn('swatch-approximated', `Swatch ${sw.id} (${sw.name}) uses ${sw.color.space} color; the hex is an approximation`, { id: sw.id }); + } + + // 1. Reuse a base slug when the color is close to a curated base color. + const baseMatch = nearestByHex(hex, baseCandidates, tolerance); + if (baseMatch) { + swatchToSlug[sw.id] = baseMatch.slug; + continue; + } + + // 2. Dedupe against already-derived entries. + const dup = nearestByHex(hex, palette.map((p) => ({ slug: p.slug, hex: p.color })), tolerance); + if (dup) { + swatchToSlug[sw.id] = dup.slug; + continue; + } + + // 3. New, namespaced derived token. + const slug = namespacedSlug(sw.name, namespace, used, 'color'); + used.add(slug); + palette.push({ slug, color: hex, name: sw.name }); + swatchToSlug[sw.id] = slug; + } + + return { palette, swatchToSlug }; +} diff --git a/packages/pipeline/src/indesign/map/design-tokens.js b/packages/pipeline/src/indesign/map/design-tokens.js new file mode 100644 index 0000000..8bd6a89 --- /dev/null +++ b/packages/pipeline/src/indesign/map/design-tokens.js @@ -0,0 +1,43 @@ +// Emit Style Dictionary (DTCG) design tokens from the mapped token groups. +// +// Uses the Design Tokens Community Group draft format ($value/$type/$description) +// which Style Dictionary v4 reads natively. $description carries the original +// InDesign swatch/style name as provenance. + +/** + * @param {Object} groups + * @param {Array<{ slug: string, color: string, name?: string }>} [groups.palette] + * @param {Array<{ slug: string, size: string|number, name?: string }>} [groups.fontSizes] + * @param {Array<{ slug: string, fontFamily: string, name?: string }>} [groups.fontFamilies] + * @param {Array<{ slug: string, size: string|number, name?: string }>} [groups.spacingSizes] + * @returns {Record>} + */ +export function toDesignTokens(groups = {}) { + const { + palette = [], + fontSizes = [], + fontFamilies = [], + spacingSizes = [], + } = groups; + + const dtcg = {}; + + const group = (key, items, type, valueOf) => { + if (!items.length) return; + dtcg[key] = {}; + for (const item of items) { + dtcg[key][item.slug] = { + $value: valueOf(item), + $type: type, + $description: item.name ?? item.slug, + }; + } + }; + + group('color', palette, 'color', (t) => t.color); + group('fontSize', fontSizes, 'dimension', (t) => t.size); + group('fontFamily', fontFamilies, 'fontFamily', (t) => t.fontFamily); + group('spacing', spacingSizes, 'dimension', (t) => t.size); + + return dtcg; +} diff --git a/packages/pipeline/src/indesign/map/fonts.js b/packages/pipeline/src/indesign/map/fonts.js new file mode 100644 index 0000000..89ddf6f --- /dev/null +++ b/packages/pipeline/src/indesign/map/fonts.js @@ -0,0 +1,136 @@ +// Map IR fonts to theme.json font families via a configurable fallback table +// (config/font-map.json). +// +// A mapped family reuses a base family slug when its CSS stack matches one, +// otherwise it becomes a namespaced derived family. Unmapped families fall back +// to a heuristic generic (serif/sans-serif/monospace) and raise a font-fallback +// warning the report lists. Google-sourced fonts are returned separately so a +// downstream generator can enqueue the web fonts (kept out of theme.json to +// keep the emitted tokens schema-clean). + +import { readFileSync } from 'node:fs'; +import { namespacedSlug } from './slug.js'; + +const DEFAULT_FONT_MAP_URL = new URL('../../../config/font-map.json', import.meta.url); + +const GENERIC_STACKS = { + 'sans-serif': "system-ui, -apple-system, 'Segoe UI', Roboto, Arial, sans-serif", + serif: "Georgia, 'Times New Roman', serif", + monospace: "ui-monospace, SFMono-Regular, Menlo, monospace", +}; + +/** + * Load the font map JSON. Defaults to the shipped table. + * @param {string | URL} [path] + * @returns {Record} + */ +export function loadFontMap(path) { + return JSON.parse(readFileSync(path ?? DEFAULT_FONT_MAP_URL, 'utf8')); +} + +/** Case-insensitive lookup into the font map. */ +function lookupFontMap(fontMap, family) { + if (fontMap[family]) return fontMap[family]; + const lower = family.toLowerCase(); + for (const key of Object.keys(fontMap)) { + if (key.toLowerCase() === lower) return fontMap[key]; + } + return null; +} + +/** Guess a CSS generic family from a font family name. */ +function heuristicGeneric(family) { + if (/mono|courier|consol/i.test(family)) return 'monospace'; + if (/serif|georgia|times|garamond|baskerville|minion|caslon|playfair/i.test(family)) return 'serif'; + return 'sans-serif'; +} + +/** Normalize a CSS stack for equality comparison. */ +function normalizeStack(stack) { + return String(stack).toLowerCase().replace(/['"\s]/g, ''); +} + +/** The last (generic) family in a CSS stack. */ +function lastFamily(stack) { + const parts = String(stack).split(','); + return parts[parts.length - 1].trim().toLowerCase(); +} + +/** + * @typedef {Object} MapFontsOptions + * @property {Record} [fontMap] + * @property {Array<{ slug: string, name?: string, fontFamily: string }>} [baseFontFamilies] + * @property {string} [namespace] + * @property {{ add: (code: string, message: string, context?: object) => void }} [warnings] + * + * @param {Array} fonts + * @param {MapFontsOptions} [options] + * @returns {{ fontFamilies: Array<{ slug: string, name: string, fontFamily: string }>, fontToSlug: Record, googleFonts: Array<{ slug: string, name: string }> }} + */ +export function mapFonts(fonts, options = {}) { + const { + fontMap = {}, + baseFontFamilies = [], + namespace = 'id', + warnings, + } = options; + const warn = warnings && typeof warnings.add === 'function' + ? (code, message, context) => warnings.add(code, message, context) + : () => {}; + + /** @type {Array<{ slug: string, name: string, fontFamily: string }>} */ + const fontFamilies = []; + /** @type {Record} */ + const fontToSlug = {}; + /** @type {Array<{ slug: string, name: string }>} */ + const googleFonts = []; + const used = new Set(baseFontFamilies.map((b) => b.slug)); + const familyToSlug = new Map(); + + for (const font of fonts) { + const family = font.family; + if (!family) continue; + if (familyToSlug.has(family)) { + fontToSlug[font.id] = familyToSlug.get(family); + continue; + } + + const mapped = lookupFontMap(fontMap, family); + let stack; + let fallback; + let source; + let googleName; + if (mapped) { + stack = mapped.fontFamily; + fallback = mapped.fallback ?? lastFamily(mapped.fontFamily); + source = mapped.source; + googleName = mapped.googleFontName; + } else { + fallback = heuristicGeneric(family); + stack = GENERIC_STACKS[fallback]; + warn('font-fallback', `Font "${family}" is not in the font map; falling back to ${fallback}`, {}); + } + + // Reuse a base family whose stack matches exactly. For unmapped fonts that + // fell back to a generic, also reuse a base family with the same generic, + // so the heuristic fallback lands on a curated family rather than a + // near-duplicate. Mapped fonts keep their own family unless the stack + // matches a base exactly. + const baseMatch = baseFontFamilies.find((b) => normalizeStack(b.fontFamily) === normalizeStack(stack)) + ?? (mapped ? undefined : baseFontFamilies.find((b) => lastFamily(b.fontFamily) === fallback)); + + let slug; + if (baseMatch) { + slug = baseMatch.slug; + } else { + slug = namespacedSlug(family, namespace, used, 'font'); + used.add(slug); + fontFamilies.push({ slug, name: family, fontFamily: stack }); + if (source === 'google' && googleName) googleFonts.push({ slug, name: googleName }); + } + familyToSlug.set(family, slug); + fontToSlug[font.id] = slug; + } + + return { fontFamilies, fontToSlug, googleFonts }; +} diff --git a/packages/pipeline/src/indesign/map/index.js b/packages/pipeline/src/indesign/map/index.js new file mode 100644 index 0000000..b44c02f --- /dev/null +++ b/packages/pipeline/src/indesign/map/index.js @@ -0,0 +1,106 @@ +// Token mapper orchestrator: a validated InDesign IR (from either parser) → +// WordPress design tokens. +// +// mapTokens(ir, options) → { partial, designTokens, merged, report } +// +// partial additive, namespaced theme.json partial +// merged base theme deep-merged with the partial (preview) +// designTokens DTCG / Style Dictionary tokens +// report warnings, validation, provenance, font fallbacks + +import { readFileSync } from 'node:fs'; + +import { WarningCollector } from '../warnings.js'; +import { slugify } from './slug.js'; +import { mapColors } from './colors.js'; +import { mapFonts, loadFontMap } from './fonts.js'; +import { mapTypography } from './typography.js'; +import { mapSpacing } from './spacing.js'; +import { assemblePartial, mergeThemeJson, validateThemeJson } from './theme-json.js'; +import { toDesignTokens } from './design-tokens.js'; +import { buildReport } from './report.js'; + +const DEFAULT_BASE_THEME_URL = new URL('../../../../../themes/flavian-shop/theme.json', import.meta.url); + +/** Load a base theme from an object, a path, or the bundled default. */ +function loadBaseTheme(base) { + if (base && typeof base === 'object') return base; + return JSON.parse(readFileSync(base ?? DEFAULT_BASE_THEME_URL, 'utf8')); +} + +/** + * @typedef {Object} MapTokensOptions + * @property {object|string} [base] Base theme object or path (default: bundled flavian-shop). + * @property {object|string} [fontMap] Font map object or path (default: bundled config). + * @property {string} [namespace] Derived-token slug prefix (default 'id'). + * @property {number} [tolerance] Color squared-distance tolerance. + * @property {number} [tolerancePx] Typography size clustering tolerance (px). + * @property {number} [gridPx] Spacing quantization grid (px). + * @property {boolean} [fluid] Emit fluid clamp() font sizes. + * + * @param {import('../ir.js').DocumentIR} ir + * @param {MapTokensOptions} [options] + * @returns {{ partial: object, designTokens: object, merged: object, report: object }} + */ +export function mapTokens(ir, options = {}) { + const { + base, + fontMap, + namespace = 'id', + tolerance, + tolerancePx, + gridPx, + fluid, + } = options; + + const baseTheme = loadBaseTheme(base); + const warnings = new WarningCollector(); + + const basePalette = baseTheme?.settings?.color?.palette ?? []; + const baseFontSizes = baseTheme?.settings?.typography?.fontSizes ?? []; + const baseFontFamilies = baseTheme?.settings?.typography?.fontFamilies ?? []; + + const { palette, swatchToSlug } = mapColors(ir.swatches ?? [], { + basePalette, + tolerance, + namespace, + warnings, + }); + + const resolvedFontMap = fontMap && typeof fontMap === 'object' ? fontMap : loadFontMap(fontMap); + const { fontFamilies, fontToSlug, googleFonts } = mapFonts(ir.fonts ?? [], { + fontMap: resolvedFontMap, + baseFontFamilies, + namespace, + warnings, + }); + + const { fontSizes, elements, blocks, styleToSlug } = mapTypography(ir.styles ?? [], { + baseFontSizes, + swatchToSlug, + fontToSlug, + tolerancePx, + fluid, + namespace, + }); + + const { spacingSizes } = mapSpacing(ir, { gridPx, namespace, warnings }); + + const partial = assemblePartial({ palette, fontSizes, fontFamilies, spacingSizes, elements, blocks }); + const merged = mergeThemeJson(baseTheme, partial); + const designTokens = toDesignTokens({ palette, fontSizes, fontFamilies, spacingSizes }); + + const validation = validateThemeJson(merged); + const report = buildReport({ + ir, + warnings: warnings.list(), + validation, + googleFonts, + provenance: { swatchToSlug, styleToSlug, fontToSlug }, + }); + + return { partial, designTokens, merged, report }; +} + +// slugify is re-exported for callers that want to derive a namespace from a name. +export { slugify }; diff --git a/packages/pipeline/src/indesign/map/report.js b/packages/pipeline/src/indesign/map/report.js new file mode 100644 index 0000000..eab4a05 --- /dev/null +++ b/packages/pipeline/src/indesign/map/report.js @@ -0,0 +1,38 @@ +// Aggregate the generator report: IR + mapper warnings, validation result, +// provenance maps, font fallbacks, out-of-gamut colors, and Google fonts to +// enqueue downstream. + +/** + * @param {Object} input + * @param {import('../ir.js').DocumentIR} [input.ir] + * @param {Array} [input.warnings] Mapper warnings. + * @param {{ valid: boolean, errors: Array }} [input.validation] + * @param {Array<{ slug: string, name: string }>} [input.googleFonts] + * @param {{ swatchToSlug?: object, styleToSlug?: object, fontToSlug?: object }} [input.provenance] + * @returns {object} + */ +export function buildReport({ ir = {}, warnings = [], validation = { valid: true, errors: [] }, googleFonts = [], provenance = {} } = {}) { + const all = [...(ir.warnings ?? []), ...warnings]; + const byCode = (code) => all.filter((w) => w.code === code); + + return { + valid: validation.valid, + validationErrors: validation.errors ?? [], + warnings: all, + fontFallbacks: byCode('font-fallback').map((w) => w.message), + outOfGamut: byCode('color-out-of-gamut').map((w) => w.message), + approximations: [...byCode('swatch-approximated'), ...byCode('spacing-approximate'), ...byCode('pdf-fallback')].map((w) => w.message), + googleFonts, + provenance: { + swatchToSlug: provenance.swatchToSlug ?? {}, + styleToSlug: provenance.styleToSlug ?? {}, + fontToSlug: provenance.fontToSlug ?? {}, + }, + counts: { + swatches: (ir.swatches ?? []).length, + fonts: (ir.fonts ?? []).length, + styles: (ir.styles ?? []).length, + warnings: all.length, + }, + }; +} diff --git a/packages/pipeline/src/indesign/map/schema/partial.zod.js b/packages/pipeline/src/indesign/map/schema/partial.zod.js new file mode 100644 index 0000000..c56905c --- /dev/null +++ b/packages/pipeline/src/indesign/map/schema/partial.zod.js @@ -0,0 +1,37 @@ +// Zod schema for the subset of theme.json the mapper emits. Validates the token +// shapes we generate while passing through everything else (so it also accepts a +// full base theme merged with our partial). Complements the ajv check against +// the official schema. + +import { z } from 'zod'; + +const ColorToken = z.object({ + slug: z.string(), + color: z.string(), + name: z.string().optional(), +}).passthrough(); + +const SizeToken = z.object({ + slug: z.string(), + size: z.union([z.string(), z.number()]), + name: z.string().optional(), +}).passthrough(); + +const FamilyToken = z.object({ + slug: z.string(), + fontFamily: z.string(), + name: z.string().optional(), +}).passthrough(); + +export const partialThemeSchema = z.object({ + version: z.literal(3).optional(), + settings: z.object({ + color: z.object({ palette: z.array(ColorToken).optional() }).passthrough().optional(), + typography: z.object({ + fontSizes: z.array(SizeToken).optional(), + fontFamilies: z.array(FamilyToken).optional(), + }).passthrough().optional(), + spacing: z.object({ spacingSizes: z.array(SizeToken).optional() }).passthrough().optional(), + }).passthrough().optional(), + styles: z.object({}).passthrough().optional(), +}).passthrough(); diff --git a/packages/pipeline/src/indesign/map/schema/theme-json.schema.json b/packages/pipeline/src/indesign/map/schema/theme-json.schema.json new file mode 100644 index 0000000..6171b84 --- /dev/null +++ b/packages/pipeline/src/indesign/map/schema/theme-json.schema.json @@ -0,0 +1,3371 @@ +{ + "title": "JSON schema for WordPress block theme global settings and styles", + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "//": { + "explainer": "https://developer.wordpress.org/themes/global-settings-and-styles/", + "createTheme": "https://developer.wordpress.org/themes/", + "reference": "https://developer.wordpress.org/block-editor/how-to-guides/themes/global-settings-and-styles/" + }, + "refComplete": { + "type": "object", + "properties": { + "ref": { + "description": "A reference to another property value. e.g. `styles.color.text`", + "type": "string" + } + }, + "additionalProperties": false + }, + "settingsAppearanceToolsProperties": { + "type": "object", + "properties": { + "appearanceTools": { + "description": "Setting that enables the following UI tools:\n\n- background: backgroundImage, backgroundSize\n- border: color, radius, style, width\n- color: link, heading, button, caption\n- dimensions: aspectRatio, height, minHeight, minWidth, width\n- position: sticky\n- spacing: blockGap, margin, padding\n- typography: lineHeight", + "type": "boolean", + "default": false + } + } + }, + "settingsBackgroundProperties": { + "type": "object", + "properties": { + "background": { + "description": "Settings related to background.", + "type": "object", + "properties": { + "backgroundImage": { + "description": "Allow users to set a background image.", + "type": "boolean", + "default": false + }, + "backgroundSize": { + "description": "Allow users to set values related to the size of a background image, including size, position, and repeat controls.", + "type": "boolean", + "default": false + }, + "gradient": { + "description": "Allow users to set a gradient background.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + } + } + }, + "settingsBorderProperties": { + "type": "object", + "properties": { + "border": { + "description": "Settings related to borders.", + "type": "object", + "properties": { + "color": { + "description": "Allow users to set custom border colors.", + "type": "boolean", + "default": false + }, + "radius": { + "description": "Allow users to set custom border radius.", + "type": "boolean", + "default": false + }, + "style": { + "description": "Allow users to set custom border styles.", + "type": "boolean", + "default": false + }, + "width": { + "description": "Allow users to set custom border widths.", + "type": "boolean", + "default": false + }, + "radiusSizes": { + "description": "Border radius size presets for the border radius selector.\nGenerates a custom property (`--wp--preset--border-radius--{slug}`) per preset value.", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Name of the border radius size preset, translatable.", + "type": "string" + }, + "slug": { + "description": "Unique identifier for the border raidus size preset.", + "type": "string" + }, + "size": { + "description": "CSS border-radius value, including units.", + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + } + }, + "settingsColorProperties": { + "type": "object", + "properties": { + "color": { + "description": "Settings related to colors.", + "type": "object", + "properties": { + "background": { + "description": "Allow users to set background colors.", + "type": "boolean", + "default": true + }, + "custom": { + "description": "Allow users to select custom colors.", + "type": "boolean", + "default": true + }, + "customDuotone": { + "description": "Allow users to create custom duotone filters.", + "type": "boolean", + "default": true + }, + "customGradient": { + "description": "Allow users to create custom gradients.", + "type": "boolean", + "default": true + }, + "defaultDuotone": { + "description": "Allow users to choose filters from the default duotone filter presets.", + "type": "boolean", + "default": true + }, + "defaultGradients": { + "description": "Allow users to choose colors from the default gradients.", + "type": "boolean", + "default": true + }, + "defaultPalette": { + "description": "Allow users to choose colors from the default palette.", + "type": "boolean", + "default": true + }, + "duotone": { + "description": "Duotone presets for the duotone picker.\nDoesn't generate classes or properties.", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Name of the duotone preset, translatable.", + "type": "string" + }, + "slug": { + "description": "Kebab-case unique identifier for the duotone preset.", + "type": "string" + }, + "colors": { + "description": "List of colors from dark to light.", + "type": "array", + "items": { + "description": "CSS hex or rgb string.", + "type": "string" + } + } + }, + "required": [ "name", "slug", "colors" ], + "additionalProperties": false + } + }, + "gradients": { + "description": "Gradient presets for the gradient picker.\nGenerates a single class (`.has-{slug}-background`) and custom property (`--wp--preset--gradient--{slug}`) per preset value.", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Name of the gradient preset, translatable.", + "type": "string" + }, + "slug": { + "description": "Kebab-case unique identifier for the gradient preset.", + "type": "string" + }, + "gradient": { + "description": "CSS gradient string.", + "type": "string" + } + }, + "required": [ "name", "slug", "gradient" ], + "additionalProperties": false + } + }, + "link": { + "description": "Allow users to set link colors in a block.", + "type": "boolean", + "default": false + }, + "palette": { + "description": "Color palette presets for the color picker.\nGenerates three classes (`.has-{slug}-color`, `.has-{slug}-background-color`, and `.has-{slug}-border-color`) and a single custom property (`--wp--preset--color--{slug}`) per preset value.", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Name of the color preset, translatable.", + "type": "string" + }, + "slug": { + "description": "Kebab-case unique identifier for the color preset.", + "type": "string" + }, + "color": { + "description": "CSS hex or rgb(a) string.", + "type": "string" + } + }, + "required": [ "name", "slug", "color" ], + "additionalProperties": false + } + }, + "text": { + "description": "Allow users to set text colors in a block.", + "type": "boolean", + "default": true + }, + "heading": { + "description": "Allow users to set heading colors in a block.", + "type": "boolean", + "default": true + }, + "button": { + "description": "Allow users to set button colors in a block.", + "type": "boolean", + "default": true + }, + "caption": { + "description": "Allow users to set caption colors in a block.", + "type": "boolean", + "default": true + } + }, + "additionalProperties": false + } + } + }, + "settingsDimensionsProperties": { + "type": "object", + "properties": { + "dimensions": { + "description": "Settings related to dimensions.", + "type": "object", + "properties": { + "aspectRatio": { + "description": "Allow users to set an aspect ratio.", + "type": "boolean", + "default": false + }, + "defaultAspectRatios": { + "description": "Allow users to choose aspect ratios from the default set of aspect ratios.", + "type": "boolean", + "default": true + }, + "aspectRatios": { + "description": "Allow users to define aspect ratios for some blocks.", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Name of the aspect ratio preset.", + "type": "string" + }, + "slug": { + "description": "Kebab-case unique identifier for the aspect ratio preset.", + "type": "string" + }, + "ratio": { + "description": "Aspect ratio expressed as a division or decimal.", + "type": "string" + } + } + } + }, + "height": { + "description": "Allow users to set custom height.", + "type": "boolean", + "default": false + }, + "minHeight": { + "description": "Allow users to set custom minimum height.", + "type": "boolean", + "default": false + }, + "minWidth": { + "description": "Allow users to set custom minimum width.", + "type": "boolean", + "default": false + }, + "width": { + "description": "Allow users to set custom width.", + "type": "boolean", + "default": false + }, + "dimensionSizes": { + "description": "Dimension size presets for dimension block supports.\nGenerates a custom property (`--wp--preset--dimension--{slug}`) per preset value.", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Name of the dimension size preset, translatable.", + "type": "string" + }, + "slug": { + "description": "Unique identifier for the dimension size preset.", + "type": "string" + }, + "size": { + "description": "CSS dimension value, including units.", + "type": "string" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + } + }, + "settingsLayoutProperties": { + "type": "object", + "properties": { + "layout": { + "description": "Settings related to layout.", + "type": "object", + "properties": { + "contentSize": { + "description": "Sets the max-width of the content.", + "type": "string" + }, + "wideSize": { + "description": "Sets the max-width of wide (`.alignwide`) content. Also used as the maximum viewport when calculating fluid font sizes", + "type": "string" + }, + "allowEditing": { + "description": "Disable the layout UI controls.", + "type": "boolean", + "default": true + }, + "allowCustomContentAndWideSize": { + "description": "Enable or disable the custom content and wide size controls.", + "type": "boolean", + "default": true + } + }, + "additionalProperties": false + } + } + }, + "settingsLightboxProperties": { + "type": "object", + "properties": { + "lightbox": { + "description": "Settings related to the lightbox.", + "type": "object", + "properties": { + "enabled": { + "description": "Defines whether the lightbox is enabled or not.", + "type": "boolean" + }, + "allowEditing": { + "description": "Defines whether to show the Lightbox UI in the block editor. If set to `false`, the user won't be able to change the lightbox settings in the block editor.", + "type": "boolean" + } + }, + "additionalProperties": false + } + } + }, + "settingsPositionProperties": { + "type": "object", + "properties": { + "position": { + "description": "Settings related to position.", + "type": "object", + "properties": { + "sticky": { + "description": "Allow users to set sticky position.", + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + } + } + }, + "settingsShadowProperties": { + "type": "object", + "properties": { + "shadow": { + "description": "Settings related to shadows.", + "type": "object", + "properties": { + "defaultPresets": { + "description": "Allow users to choose shadows from the default shadow presets.", + "type": "boolean", + "default": true + }, + "presets": { + "description": "Shadow presets for the shadow picker.\nGenerates a single custom property (`--wp--preset--shadow--{slug}`) per preset value.", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Name of the shadow preset, translatable.", + "type": "string" + }, + "slug": { + "description": "Kebab-case unique identifier for the shadow preset.", + "type": "string" + }, + "shadow": { + "description": "CSS box-shadow value", + "type": "string" + } + }, + "required": [ "name", "slug", "shadow" ], + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + } + }, + "settingsSpacingProperties": { + "type": "object", + "properties": { + "spacing": { + "description": "Settings related to spacing.", + "type": "object", + "properties": { + "blockGap": { + "description": "Enables `--wp--style--block-gap` to be generated from styles.spacing.blockGap.\nA value of `null` instead of `false` further disables layout styles from being generated.", + "oneOf": [ + { "type": "boolean" }, + { "type": "null" } + ], + "default": null + }, + "margin": { + "description": "Allow users to set a custom margin.", + "type": "boolean", + "default": false + }, + "padding": { + "description": "Allow users to set a custom padding.", + "type": "boolean", + "default": false + }, + "units": { + "description": "List of units the user can use for spacing values.", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "default": [ "px", "em", "rem", "vh", "vw", "%" ] + }, + "customSpacingSize": { + "description": "Allow users to set custom space sizes.", + "type": "boolean", + "default": true + }, + "defaultSpacingSizes": { + "description": "Allow users to choose space sizes from the default space size presets.", + "type": "boolean", + "default": true + }, + "spacingSizes": { + "description": "Space size presets for the space size selector.\nGenerates a custom property (`--wp--preset--spacing--{slug}`) per preset value.", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Name of the space size preset, translatable.", + "type": "string" + }, + "slug": { + "description": "Unique identifier for the space size preset. For best cross theme compatibility these should be in the form '10','20','30','40','50','60', etc. with '50' representing the 'Medium' size step. If all slugs begin with a number they will be merged with default and user slugs and sorted numerically.", + "type": "string" + }, + "size": { + "description": "CSS space-size value, including units.", + "type": "string" + } + }, + "additionalProperties": false + } + }, + "spacingScale": { + "description": "Settings to auto-generate space size presets for the space size selector.\nGenerates a custom property (--wp--preset--spacing--{slug}`) per preset value.", + "type": "object", + "properties": { + "operator": { + "description": "With + or * depending on whether scale is generated by increment or multiplier.", + "type": "string", + "enum": [ "+", "*" ], + "default": "*" + }, + "increment": { + "description": "The amount to increment each step by.", + "type": "number", + "exclusiveMinimum": 0, + "default": 1.5 + }, + "steps": { + "description": "Number of steps to generate in scale.", + "type": "integer", + "minimum": 1, + "maximum": 10, + "default": 7 + }, + "mediumStep": { + "description": "The value to medium setting in the scale.", + "type": "number", + "exclusiveMinimum": 0, + "default": 1.5 + }, + "unit": { + "description": "Unit that the scale uses, eg. rem, em, px.", + "type": "string", + "enum": [ + "px", + "em", + "rem", + "%", + "vw", + "svw", + "lvw", + "dvw", + "vh", + "svh", + "lvh", + "dvh", + "vi", + "svi", + "lvi", + "dvi", + "vb", + "svb", + "lvb", + "dvb", + "vmin", + "svmin", + "lvmin", + "dvmin", + "vmax", + "svmax", + "lvmax", + "dvmax" + ], + "default": "rem" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + } + }, + "settingsTypographyProperties": { + "type": "object", + "properties": { + "typography": { + "description": "Settings related to typography.", + "type": "object", + "properties": { + "defaultFontSizes": { + "description": "Allow users to choose font sizes from the default font size presets.", + "type": "boolean", + "default": true + }, + "customFontSize": { + "description": "Allow users to set custom font sizes.", + "type": "boolean", + "default": true + }, + "fontStyle": { + "description": "Allow users to set custom font styles.", + "type": "boolean", + "default": true + }, + "fontWeight": { + "description": "Allow users to set custom font weights.", + "type": "boolean", + "default": true + }, + "fluid": { + "description": "Enables fluid typography and allows users to set global fluid typography parameters.", + "oneOf": [ + { + "type": "object", + "properties": { + "minFontSize": { + "description": "Allow users to set a global minimum font size boundary in px, rem or em. Custom font sizes below this value will not be clamped, and all calculated minimum font sizes will be, at a minimum, this value.", + "type": "string" + }, + "maxViewportWidth": { + "description": "Allow users to set custom a max viewport width in px, rem or em, used to set the maximum size boundary of a fluid font size.", + "type": "string" + }, + "minViewportWidth": { + "description": "Allow users to set a custom min viewport width in px, rem or em, used to set the minimum size boundary of a fluid font size.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "boolean" + } + ], + "default": false + }, + "letterSpacing": { + "description": "Allow users to set custom letter spacing.", + "type": "boolean", + "default": true + }, + "lineHeight": { + "description": "Allow users to set custom line height.", + "type": "boolean", + "default": false + }, + "textIndent": { + "description": "Allow users to set custom line indent.", + "oneOf": [ + { + "type": "boolean", + "enum": [ false ] + }, + { + "type": "string", + "enum": [ "subsequent", "all" ] + } + ], + "default": "subsequent" + }, + "textAlign": { + "description": "Allow users to set the text align.", + "type": "boolean", + "default": true + }, + "textColumns": { + "description": "Allow users to set the number of text columns.", + "type": "boolean", + "default": false + }, + "textDecoration": { + "description": "Allow users to set custom text decorations.", + "type": "boolean", + "default": true + }, + "writingMode": { + "description": "Allow users to set the writing mode.", + "type": "boolean", + "default": false + }, + "textTransform": { + "description": "Allow users to set custom text transforms.", + "type": "boolean", + "default": true + }, + "dropCap": { + "description": "Enable drop cap.", + "type": "boolean", + "default": true + }, + "fontSizes": { + "description": "Font size presets for the font size selector.\nGenerates a single class (`.has-{slug}-color`) and custom property (`--wp--preset--font-size--{slug}`) per preset value.", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Name of the font size preset, translatable.", + "type": "string" + }, + "slug": { + "description": "Kebab-case unique identifier for the font size preset.", + "type": "string" + }, + "size": { + "description": "CSS font-size value, including units.", + "type": "string" + }, + "fluid": { + "description": "Specifies the minimum and maximum font size value of a fluid font size. Set to `false` to bypass fluid calculations and use the static `size` value.", + "oneOf": [ + { + "type": "object", + "properties": { + "min": { + "description": "A min font size for fluid font size calculations in px, rem or em.", + "type": "string" + }, + "max": { + "description": "A max font size for fluid font size calculations in px, rem or em.", + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "boolean" + } + ] + } + }, + "additionalProperties": false + } + }, + "fontFamilies": { + "description": "Font family presets for the font family selector.\nGenerates a single custom property (`--wp--preset--font-family--{slug}`) per preset value.", + "type": "array", + "items": { + "description": "Font family preset", + "type": "object", + "properties": { + "name": { + "description": "Name of the font family preset, translatable.", + "type": "string" + }, + "slug": { + "description": "Kebab-case unique identifier for the font family preset.", + "type": "string" + }, + "fontFamily": { + "description": "CSS font-family value.", + "type": "string" + }, + "fontFace": { + "description": "Array of font-face declarations.", + "type": "array", + "items": { + "type": "object", + "properties": { + "fontFamily": { + "description": "CSS font-family value.", + "type": "string", + "default": "" + }, + "fontStyle": { + "description": "CSS font-style value.", + "type": "string", + "default": "normal" + }, + "fontWeight": { + "description": "List of available font weights, separated by a space.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "integer" + } + ], + "default": "400" + }, + "fontDisplay": { + "description": "CSS font-display value.", + "type": "string", + "enum": [ + "auto", + "block", + "fallback", + "swap", + "optional" + ], + "default": "fallback" + }, + "src": { + "description": "Paths or URLs to the font files.", + "oneOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "default": [] + }, + "fontStretch": { + "description": "CSS font-stretch value.", + "type": "string" + }, + "ascentOverride": { + "description": "CSS ascent-override value.", + "type": "string" + }, + "descentOverride": { + "description": "CSS descent-override value.", + "type": "string" + }, + "fontVariant": { + "description": "CSS font-variant value.", + "type": "string" + }, + "fontFeatureSettings": { + "description": "CSS font-feature-settings value.", + "type": "string" + }, + "fontVariationSettings": { + "description": "CSS font-variation-settings value.", + "type": "string" + }, + "lineGapOverride": { + "description": "CSS line-gap-override value.", + "type": "string" + }, + "sizeAdjust": { + "description": "CSS size-adjust value.", + "type": "string" + }, + "unicodeRange": { + "description": "CSS unicode-range value.", + "type": "string" + } + }, + "required": [ "fontFamily", "src" ], + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + } + }, + "settingsCustomProperties": { + "type": "object", + "properties": { + "custom": { + "$ref": "#/definitions/settingsCustomAdditionalProperties" + } + } + }, + "settingsProperties": { + "allOf": [ + { "$ref": "#/definitions/settingsAppearanceToolsProperties" }, + { "$ref": "#/definitions/settingsBackgroundProperties" }, + { "$ref": "#/definitions/settingsBorderProperties" }, + { "$ref": "#/definitions/settingsColorProperties" }, + { "$ref": "#/definitions/settingsDimensionsProperties" }, + { "$ref": "#/definitions/settingsLayoutProperties" }, + { "$ref": "#/definitions/settingsLightboxProperties" }, + { "$ref": "#/definitions/settingsPositionProperties" }, + { "$ref": "#/definitions/settingsShadowProperties" }, + { "$ref": "#/definitions/settingsSpacingProperties" }, + { "$ref": "#/definitions/settingsTypographyProperties" }, + { "$ref": "#/definitions/settingsCustomProperties" } + ] + }, + "settingsPropertyNames": { + "enum": [ + "appearanceTools", + "background", + "border", + "color", + "dimensions", + "layout", + "lightbox", + "position", + "shadow", + "spacing", + "typography", + "custom" + ] + }, + "settingsPropertiesComplete": { + "allOf": [ + { + "$ref": "#/definitions/settingsProperties" + }, + { + "type": "object", + "propertyNames": { + "$ref": "#/definitions/settingsPropertyNames" + } + } + ] + }, + "settingsBlocksPropertiesComplete": { + "description": "Settings defined on a per-block basis.", + "type": "object", + "properties": { + "core/accordion": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/accordion-heading": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/accordion-item": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/accordion-panel": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/archives": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/audio": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/avatar": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/block": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/breadcrumbs": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/button": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/buttons": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/calendar": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/categories": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/code": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/column": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/columns": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comment-author-avatar": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comment-author-name": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comment-content": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comment-date": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comment-edit-link": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comment-reply-link": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comments": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comments-pagination": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comments-pagination-next": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comments-pagination-numbers": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comments-pagination-previous": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comments-title": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/comment-template": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/cover": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/details": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/embed": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/file": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/footnotes": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/freeform": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/gallery": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/group": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/heading": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/home-link": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/html": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/icon": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/image": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/latest-comments": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/latest-posts": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/list": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/list-item": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/loginout": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/math": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/media-text": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/missing": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/more": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/navigation": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/navigation-link": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/navigation-submenu": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/nextpage": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/page-list": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/page-list-item": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/paragraph": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-author": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-author-biography": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-author-name": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-comment": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-comments-count": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-comments-form": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-comments-link": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-content": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-date": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-excerpt": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-featured-image": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-navigation-link": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-template": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-terms": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-time-to-read": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/post-title": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/preformatted": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/pullquote": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/query": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/query-no-results": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/query-pagination": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/query-pagination-next": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/query-pagination-numbers": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/query-pagination-previous": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/query-title": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/query-total": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/quote": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/read-more": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/rss": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/search": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/separator": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/shortcode": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/site-logo": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/site-tagline": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/site-title": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/social-link": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/social-links": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/spacer": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/table": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/tag-cloud": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/template-part": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/term-count": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/term-description": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/term-name": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/term-template": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/terms-query": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/text-columns": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/verse": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/video": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/widget-area": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/legacy-widget": { + "$ref": "#/definitions/settingsPropertiesComplete" + }, + "core/widget-group": { + "$ref": "#/definitions/settingsPropertiesComplete" + } + }, + "patternProperties": { + "^[a-z][a-z0-9-]*/[a-z][a-z0-9-]*$": { + "$ref": "#/definitions/settingsPropertiesComplete" + } + }, + "additionalProperties": false + }, + "settingsCustomAdditionalProperties": { + "description": "Generate custom CSS custom properties of the form `--wp--custom--{key}--{nested-key}: {value};`. `camelCased` keys are transformed to `kebab-case` as to follow the CSS property naming schema. Keys at different depth levels are separated by `--`, so keys should not include `--` in the name.", + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "number" + }, + { + "$ref": "#/definitions/settingsCustomAdditionalProperties" + } + ] + } + }, + "stylesProperties": { + "type": "object", + "properties": { + "background": { + "description": "Background styles.", + "type": "object", + "properties": { + "backgroundImage": { + "description": "Sets the `background-image` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" }, + { + "type": "object", + "properties": { + "url": { + "description": "A URL to an image file, or a path to a file relative to the theme root directory, and prefixed with `file:`, e.g., 'file:./path/to/file.png'.", + "type": "string" + } + }, + "additionalProperties": false + } + ] + }, + "backgroundPosition": { + "description": "Sets the `background-position` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "backgroundRepeat": { + "description": "Sets the `background-repeat` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "backgroundSize": { + "description": "Sets the `background-size` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "backgroundAttachment": { + "description": "Sets the `background-attachment` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + } + }, + "additionalProperties": false + }, + "border": { + "description": "Border styles.", + "type": "object", + "properties": { + "color": { + "description": "Sets the `border-color` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "radius": { + "description": "Sets the `border-radius` CSS property.", + "anyOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" }, + { + "type": "object", + "properties": { + "topLeft": { + "description": "Sets the `border-top-left-radius` CSS property.", + "oneOf": [ + { "type": "string" }, + { + "$ref": "#/definitions/refComplete" + } + ] + }, + "topRight": { + "description": "Sets the `border-top-right-radius` CSS property.", + "oneOf": [ + { "type": "string" }, + { + "$ref": "#/definitions/refComplete" + } + ] + }, + "bottomLeft": { + "description": "Sets the `border-bottom-left-radius` CSS property.", + "oneOf": [ + { "type": "string" }, + { + "$ref": "#/definitions/refComplete" + } + ] + }, + "bottomRight": { + "description": "Sets the `border-bottom-right-radius` CSS property.", + "oneOf": [ + { "type": "string" }, + { + "$ref": "#/definitions/refComplete" + } + ] + } + } + } + ] + }, + "style": { + "description": "Sets the `border-style` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "width": { + "description": "Sets the `border-width` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "top": { + "type": "object", + "properties": { + "color": { + "description": "Sets the `border-top-color` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "style": { + "description": "Sets the `border-top-style` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "width": { + "description": "Sets the `border-top-width` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + } + }, + "additionalProperties": false + }, + "right": { + "type": "object", + "properties": { + "color": { + "description": "Sets the `border-right-color` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "style": { + "description": "Sets the `border-right-style` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "width": { + "description": "Sets the `border-right-width` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + } + }, + "additionalProperties": false + }, + "bottom": { + "type": "object", + "properties": { + "color": { + "description": "Sets the `border-bottom-color` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "style": { + "description": "Sets the `border-bottom-style` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "width": { + "description": "Sets the `border-bottom-width` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + } + }, + "additionalProperties": false + }, + "left": { + "type": "object", + "properties": { + "color": { + "description": "Sets the `border-left-color` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "style": { + "description": "Sets the `border-left-style` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "width": { + "description": "Sets the `border-left-width` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "color": { + "description": "Color styles.", + "type": "object", + "properties": { + "background": { + "description": "Sets the `background-color` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "gradient": { + "description": "Sets the `background` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "text": { + "description": "Sets the `color` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + } + }, + "additionalProperties": false + }, + "css": { + "description": "Sets custom CSS to apply styling not covered by other theme.json properties.", + "type": "string" + }, + "dimensions": { + "description": "Dimensions styles.", + "type": "object", + "properties": { + "aspectRatio": { + "description": "Sets the `aspect-ratio` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "height": { + "description": "Sets the `height` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "minHeight": { + "description": "Sets the `min-height` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "minWidth": { + "description": "Sets the `min-width` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "width": { + "description": "Sets the `width` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + } + } + }, + "filter": { + "description": "CSS and SVG filter styles.", + "type": "object", + "properties": { + "duotone": { + "description": "Sets the duotone filter.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + } + }, + "additionalProperties": false + }, + "outline": { + "description": "Outline styles.", + "type": "object", + "properties": { + "color": { + "description": "Sets the `outline-color` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "offset": { + "description": "Sets the `outline-offset` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "style": { + "description": "Sets the `outline-style` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "width": { + "description": "Sets the `outline-width` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + } + }, + "additionalProperties": false + }, + "shadow": { + "description": "Box shadow styles.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "spacing": { + "description": "Spacing styles.", + "type": "object", + "properties": { + "blockGap": { + "description": "Sets the `--wp--style--block-gap` CSS custom property when settings.spacing.blockGap is true.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "margin": { + "description": "Margin styles.", + "type": "object", + "properties": { + "top": { + "description": "Sets the `margin-top` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "right": { + "description": "Sets the `margin-right` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "bottom": { + "description": "Sets the `margin-bottom` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "left": { + "description": "Sets the `margin-left` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + } + }, + "additionalProperties": false + }, + "padding": { + "description": "Padding styles.", + "type": "object", + "properties": { + "top": { + "description": "Sets the `padding-top` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "right": { + "description": "Sets the `padding-right` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "bottom": { + "description": "Sets the `padding-bottom` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "left": { + "description": "Sets the `padding-left` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "typography": { + "description": "Typography styles.", + "type": "object", + "properties": { + "fontFamily": { + "description": "Sets the `font-family` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "fontSize": { + "description": "Sets the `font-size` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "fontStyle": { + "description": "Sets the `font-style` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "fontWeight": { + "description": "Sets the `font-weight` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "letterSpacing": { + "description": "Sets the `letter-spacing` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "lineHeight": { + "description": "Sets the `line-height` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "textIndent": { + "description": "Sets the `text-indent` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "textAlign": { + "description": "Sets the `text-align` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "textColumns": { + "description": "Sets the `column-count` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "textDecoration": { + "description": "Sets the `text-decoration` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "writingMode": { + "description": "Sets the `writing-mode` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + }, + "textTransform": { + "description": "Sets the `text-transform` CSS property.", + "oneOf": [ + { "type": "string" }, + { "$ref": "#/definitions/refComplete" } + ] + } + }, + "additionalProperties": false + } + } + }, + "stylesPropertyNames": { + "enum": [ + "background", + "border", + "color", + "css", + "dimensions", + "filter", + "outline", + "shadow", + "spacing", + "typography" + ] + }, + "stylesPropertiesComplete": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "type": "object", + "propertyNames": { + "$ref": "#/definitions/stylesPropertyNames" + } + } + ] + }, + "stylesBlocksPseudoSelectorsProperties": { + "type": "object", + "properties": { + ":hover": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + ":focus": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + ":focus-visible": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + ":active": { + "$ref": "#/definitions/stylesPropertiesComplete" + } + } + }, + "stylesBlocksPseudoSelectorsPropertyNames": { + "enum": [ ":hover", ":focus", ":focus-visible", ":active" ] + }, + "stylesBlocksResponsiveSelectorsProperties": { + "description": "Responsive block states keyed by breakpoint name. Each breakpoint supports the same style properties as the default block state.", + "type": "object", + "properties": { + "mobile": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + "tablet": { + "$ref": "#/definitions/stylesPropertiesComplete" + } + } + }, + "stylesBlocksResponsiveSelectorsPropertyNames": { + "enum": [ "mobile", "tablet" ] + }, + "stylesElementsPseudoSelectorsProperties": { + "type": "object", + "properties": { + ":active": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + ":any-link": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + ":focus": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + ":focus-visible": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + ":hover": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + ":link": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + ":visited": { + "$ref": "#/definitions/stylesPropertiesComplete" + } + } + }, + "stylesBlocksCustomStatesProperties": { + "description": "Custom class-based block states using the '@' prefix. Each state supports style properties as well as the same pseudo-selectors available to the block.", + "type": "object", + "properties": { + "@current": { + "description": "Styles applied to the current/active item (e.g. .current-menu-item in navigation).", + "allOf": [ + { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + { + "$ref": "#/definitions/stylesBlocksPseudoSelectorsProperties" + } + ] + } + } + }, + "stylesElementsPseudoSelectorsPropertyNames": { + "enum": [ + ":active", + ":any-link", + ":focus", + ":focus-visible", + ":hover", + ":link", + ":visited" + ] + }, + "stylesElementsResponsiveSelectorsProperties": { + "description": "Responsive element states keyed by breakpoint name. Each breakpoint supports the same style properties as the default element state.", + "type": "object", + "properties": { + "mobile": { + "$ref": "#/definitions/stylesPropertiesComplete" + }, + "tablet": { + "$ref": "#/definitions/stylesPropertiesComplete" + } + } + }, + "stylesElementsResponsiveSelectorsPropertyNames": { + "enum": [ "mobile", "tablet" ] + }, + "stylesElementsPropertiesComplete": { + "description": "Styles defined on a per-element basis using the element's selector.", + "type": "object", + "properties": { + "button": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "$ref": "#/definitions/stylesElementsPseudoSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsPseudoSelectorsPropertyNames" + } + ] + } + } + ] + }, + "link": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "$ref": "#/definitions/stylesElementsPseudoSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsPseudoSelectorsPropertyNames" + } + ] + } + } + ] + }, + "heading": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] + }, + "h1": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] + }, + "h2": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] + }, + "h3": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] + }, + "h4": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] + }, + "h5": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] + }, + "h6": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] + }, + "caption": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] + }, + "cite": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] + }, + "select": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] + }, + "textInput": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesElementsResponsiveSelectorsPropertyNames" + } + ] + } + } + ] + } + }, + "additionalProperties": false + }, + "stylesBlocksPropertiesComplete": { + "description": "Styles defined on a per-block basis using the block's selector.", + "type": "object", + "properties": { + "core/accordion": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/accordion-heading": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/accordion-item": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/accordion-panel": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/archives": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/audio": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/avatar": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/block": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/breadcrumbs": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/button": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, + { + "type": "object", + "properties": { + "variations": { + "type": "object", + "patternProperties": { + "^[a-z][a-z0-9-]*$": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, + { + "$ref": "#/definitions/stylesBlocksPseudoSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsPropertyNames" + }, + { + "$ref": "#/definitions/stylesBlocksPseudoSelectorsPropertyNames" + } + ] + } + } + ] + } + } + } + } + }, + { + "$ref": "#/definitions/stylesBlocksPseudoSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "enum": [ "variations" ] + }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsPropertyNames" + }, + { + "$ref": "#/definitions/stylesBlocksPseudoSelectorsPropertyNames" + } + ] + } + } + ] + }, + "core/buttons": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/calendar": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/categories": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/code": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/column": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/columns": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comment-author-avatar": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comment-author-name": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comment-content": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comment-date": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comment-edit-link": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comment-reply-link": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comments": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comments-pagination": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comments-pagination-next": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comments-pagination-numbers": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comments-pagination-previous": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comments-title": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/comment-template": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/cover": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/details": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/embed": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/file": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/footnotes": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/freeform": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/gallery": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/group": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/heading": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/home-link": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/html": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/icon": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/image": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/latest-comments": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/latest-posts": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/list": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/list-item": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/loginout": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/math": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/media-text": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/missing": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/more": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/navigation": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/navigation-link": { + "allOf": [ + { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, + { + "$ref": "#/definitions/stylesBlocksPseudoSelectorsProperties" + }, + { + "$ref": "#/definitions/stylesBlocksCustomStatesProperties" + } + ] + }, + "core/navigation-submenu": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/nextpage": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/page-list": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/page-list-item": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/paragraph": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-author": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-author-biography": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-author-name": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-comment": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-comments-count": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-comments-form": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-comments-link": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-content": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-date": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-excerpt": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-featured-image": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-navigation-link": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-template": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-terms": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-time-to-read": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/post-title": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/preformatted": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/pullquote": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/query": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/query-no-results": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/query-pagination": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/query-pagination-next": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/query-pagination-numbers": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/query-pagination-previous": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/query-title": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/query-total": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/quote": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/read-more": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/rss": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/search": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/separator": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/shortcode": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/site-logo": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/site-tagline": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/site-title": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/social-link": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/social-links": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/spacer": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/table": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/tag-cloud": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/template-part": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/term-count": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/term-description": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/term-name": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/term-template": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/terms-query": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/text-columns": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/verse": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/video": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/widget-area": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/legacy-widget": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/widget-group": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + } + }, + "patternProperties": { + "^[a-z][a-z0-9-]*/[a-z][a-z0-9-]*$": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + } + }, + "additionalProperties": false + }, + "stylesPropertiesAndElementsComplete": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, + { + "type": "object", + "properties": { + "elements": { + "$ref": "#/definitions/stylesElementsPropertiesComplete" + }, + "variations": { + "$ref": "#/definitions/stylesVariationsPropertiesComplete" + } + } + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsPropertyNames" + }, + { + "enum": [ "elements", "variations" ] + } + ] + } + } + ] + }, + "stylesVariationsProperties": { + "type": "object", + "patternProperties": { + "^[a-z][a-z0-9-]*$": { + "$ref": "#/definitions/stylesVariationProperties" + } + } + }, + "stylesVariationProperties": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "type": "object", + "properties": { + "elements": { + "$ref": "#/definitions/stylesElementsPropertiesComplete" + }, + "blocks": { + "$ref": "#/definitions/stylesVariationBlocksPropertiesComplete" + } + } + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "enum": [ "elements", "blocks" ] + } + ] + } + } + ] + }, + "stylesVariationsPropertiesComplete": { + "type": "object", + "patternProperties": { + "^[a-z][a-z0-9-]*$": { + "$ref": "#/definitions/stylesVariationPropertiesComplete" + } + } + }, + "stylesVariationPropertiesComplete": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "type": "object", + "properties": { + "elements": { + "$ref": "#/definitions/stylesElementsPropertiesComplete" + }, + "blocks": { + "$ref": "#/definitions/stylesVariationBlocksPropertiesComplete" + } + } + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "enum": [ "elements", "blocks" ] + } + ] + } + } + ] + }, + "stylesVariationBlocksPropertiesComplete": { + "type": "object", + "properties": { + "core/accordion": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/accordion-heading": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/accordion-item": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/accordion-panel": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/archives": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/audio": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/avatar": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/block": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/breadcrumbs": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/button": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, + { + "$ref": "#/definitions/stylesBlocksPseudoSelectorsProperties" + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsPropertyNames" + }, + { + "$ref": "#/definitions/stylesBlocksPseudoSelectorsPropertyNames" + } + ] + } + } + ] + }, + "core/buttons": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/calendar": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/categories": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/code": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/column": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/columns": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/comment-author-avatar": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/comment-author-name": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/comment-content": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/comment-date": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/comment-edit-link": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/comment-reply-link": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/comments": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/comments-pagination": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/comments-pagination-next": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/comments-pagination-numbers": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/comments-pagination-previous": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/comments-title": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/comment-template": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/cover": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/details": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/embed": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/file": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/footnotes": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/freeform": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/gallery": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/group": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/heading": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/home-link": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/html": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/icon": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/image": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/latest-comments": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/latest-posts": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/list": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/list-item": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/loginout": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/math": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/media-text": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/missing": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/more": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/navigation": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/navigation-link": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/navigation-submenu": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/nextpage": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/page-list": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/page-list-item": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/paragraph": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/post-author": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/post-author-biography": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/post-author-name": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/post-comment": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/post-comments-count": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/post-comments-form": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/post-comments-link": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/post-content": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/post-date": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/post-excerpt": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/post-featured-image": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/post-navigation-link": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/post-template": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/post-terms": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/post-time-to-read": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/post-title": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/preformatted": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/pullquote": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/query": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/query-no-results": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/query-pagination": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/query-pagination-next": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/query-pagination-numbers": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/query-pagination-previous": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/query-title": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/query-total": { + "$ref": "#/definitions/stylesPropertiesAndElementsComplete" + }, + "core/quote": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/read-more": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/rss": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/search": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/separator": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/shortcode": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/site-logo": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/site-tagline": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/site-title": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/social-link": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/social-links": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/spacer": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/table": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/tag-cloud": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/template-part": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/term-count": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/term-description": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/term-name": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/term-template": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/terms-query": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/text-columns": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/verse": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/video": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/widget-area": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/legacy-widget": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + }, + "core/widget-group": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + } + }, + "patternProperties": { + "^[a-z][a-z0-9-]*/[a-z][a-z0-9-]*$": { + "$ref": "#/definitions/stylesVariationBlockPropertiesComplete" + } + }, + "additionalProperties": false + }, + "stylesVariationBlockPropertiesComplete": { + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsProperties" + }, + { + "type": "object", + "properties": { + "elements": { + "$ref": "#/definitions/stylesElementsPropertiesComplete" + } + } + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "$ref": "#/definitions/stylesBlocksResponsiveSelectorsPropertyNames" + }, + { + "enum": [ "elements" ] + } + ] + } + } + ] + } + }, + "type": "object", + "properties": { + "$schema": { + "description": "JSON schema URI for theme.json.", + "type": "string" + }, + "version": { + "description": "Version of theme.json to use.", + "type": "integer", + "const": 3 + }, + "title": { + "description": "Title of the styles variation. If not defined, the file name will be used.", + "type": "string" + }, + "slug": { + "description": "Slug of the styles variation. If not defined, the kebab-case title will be used.", + "type": "string" + }, + "description": { + "description": "Description of the styles variation.", + "type": "string" + }, + "blockTypes": { + "description": "List of block types that can use the block style variation this theme.json file represents.", + "type": "array", + "items": { + "type": "string" + } + }, + "settings": { + "description": "Settings for the block editor and individual blocks. These include things like:\n- Which customization options should be available to the user. \n- The default colors, font sizes... available to the user. \n- CSS custom properties and class names used in styles.\n- And the default layout of the editor (widths and available alignments).", + "allOf": [ + { + "$ref": "#/definitions/settingsProperties" + }, + { + "type": "object", + "properties": { + "useRootPaddingAwareAlignments": { + "description": "Enables root padding (the values from `styles.spacing.padding`) to be applied to the contents of full-width blocks instead of the root block.\n\nPlease note that when using this setting, `styles.spacing.padding` should always be set as an object with `top`, `right`, `bottom`, `left` values declared separately.", + "type": "boolean", + "default": false + }, + "blocks": { + "$ref": "#/definitions/settingsBlocksPropertiesComplete" + } + } + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/settingsPropertyNames" + }, + { + "enum": [ + "useRootPaddingAwareAlignments", + "blocks" + ] + } + ] + } + } + ] + }, + "styles": { + "description": "Organized way to set CSS properties. Styles in the top-level will be added in the `body` selector.", + "allOf": [ + { + "$ref": "#/definitions/stylesProperties" + }, + { + "type": "object", + "properties": { + "elements": { + "$ref": "#/definitions/stylesElementsPropertiesComplete" + }, + "blocks": { + "$ref": "#/definitions/stylesBlocksPropertiesComplete" + }, + "variations": { + "$ref": "#/definitions/stylesVariationsProperties" + } + } + }, + { + "type": "object", + "propertyNames": { + "anyOf": [ + { + "$ref": "#/definitions/stylesPropertyNames" + }, + { + "enum": [ "elements", "blocks", "variations" ] + } + ] + } + } + ] + }, + "customTemplates": { + "description": "Additional metadata for custom templates defined in the templates folder.", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Filename, without extension, of the template in the templates folder.", + "type": "string" + }, + "title": { + "description": "Title of the template, translatable.", + "type": "string" + }, + "postTypes": { + "description": "List of post types that can use this custom template.", + "type": "array", + "items": { + "type": "string" + }, + "default": [ "page" ] + } + }, + "required": [ "name", "title" ], + "additionalProperties": false + } + }, + "templateParts": { + "description": "Additional metadata for template parts defined in the parts folder.", + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "description": "Filename, without extension, of the template in the parts folder.", + "type": "string" + }, + "title": { + "description": "Title of the template, translatable.", + "type": "string" + }, + "area": { + "description": "The area the template part is used for. Block variations for `header` and `footer` values exist and will be used when the area is set to one of those.", + "type": "string", + "default": "uncategorized" + } + }, + "required": [ "name" ], + "additionalProperties": false + } + }, + "patterns": { + "description": "An array of pattern slugs to be registered from the Pattern Directory.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ "version" ], + "additionalProperties": false +} diff --git a/packages/pipeline/src/indesign/map/slug.js b/packages/pipeline/src/indesign/map/slug.js new file mode 100644 index 0000000..98085f5 --- /dev/null +++ b/packages/pipeline/src/indesign/map/slug.js @@ -0,0 +1,37 @@ +// Slug helpers shared by the mapper's token builders. Derived tokens are always +// namespaced so they never overwrite the base theme's curated slugs. + +/** + * Turn an arbitrary name into a kebab-case slug fragment. + * + * @param {unknown} name + * @returns {string} + */ +export function slugify(name) { + return String(name ?? '') + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); +} + +/** + * Build a namespaced, collision-free slug from a name. Appends a numeric suffix + * if the namespaced slug is already taken. + * + * @param {unknown} name + * @param {string} namespace + * @param {Set} used Slugs already in use (mutated by the caller after). + * @param {string} [fallback] Used when the name slugifies to empty. + * @returns {string} + */ +export function namespacedSlug(name, namespace, used, fallback = 'token') { + const stem = slugify(name) || fallback; + const base = namespace ? `${namespace}-${stem}` : stem; + let slug = base; + let n = 2; + while (used.has(slug)) { + slug = `${base}-${n}`; + n += 1; + } + return slug; +} diff --git a/packages/pipeline/src/indesign/map/spacing.js b/packages/pipeline/src/indesign/map/spacing.js new file mode 100644 index 0000000..d21aaca --- /dev/null +++ b/packages/pipeline/src/indesign/map/spacing.js @@ -0,0 +1,141 @@ +// Derive a quantized theme.json spacing scale from IR geometry. +// +// Candidate spacings come from three observations: page margins (the smallest +// offset between a frame and each page edge), inter-frame gutters (gaps between +// adjacent frames), and paragraph space-before/after on styles. Values are +// quantized to a grid, deduped, and capped. Spacing is inherently approximate — +// especially for PDF-derived IRs — so an advisory warning is raised for those. + +import { lengthToPx } from '../units.js'; + +const DEFAULT_GRID_PX = 4; +const DEFAULT_MAX_SIZES = 8; + +/** @param {number} px @param {number} grid */ +function quantize(px, grid) { + return Math.round(px / grid) * grid; +} + +/** @param {number} px → rem string */ +function pxToRem(px) { + return `${Math.round((px / 16) * 10000) / 10000}rem`; +} + +/** + * Smallest positive offset between any frame and each page edge = page margins. + * @param {{ bounds: { x: number, y: number, width: number, height: number } }} page + * @param {Array<{ bounds: { x: number, y: number, width: number, height: number } }>} frames + * @returns {number[]} + */ +function pageMargins(page, frames) { + const p = page.bounds; + const edges = { left: Infinity, top: Infinity, right: Infinity, bottom: Infinity }; + for (const f of frames) { + const b = f.bounds; + edges.left = Math.min(edges.left, b.x - p.x); + edges.top = Math.min(edges.top, b.y - p.y); + edges.right = Math.min(edges.right, p.x + p.width - (b.x + b.width)); + edges.bottom = Math.min(edges.bottom, p.y + p.height - (b.y + b.height)); + } + return Object.values(edges).filter((v) => Number.isFinite(v) && v > 0); +} + +/** + * Gaps between adjacent frames: vertical (stacked) and horizontal (side by side). + * @param {Array<{ bounds: { x: number, y: number, width: number, height: number } }>} frames + * @returns {number[]} + */ +function frameGutters(frames) { + const gaps = []; + const byY = [...frames].sort((a, b) => a.bounds.y - b.bounds.y); + for (let i = 1; i < byY.length; i += 1) { + const prev = byY[i - 1].bounds; + const cur = byY[i].bounds; + gaps.push(cur.y - (prev.y + prev.height)); + } + const byX = [...frames].sort((a, b) => a.bounds.x - b.bounds.x); + for (let i = 1; i < byX.length; i += 1) { + const prev = byX[i - 1].bounds; + const cur = byX[i].bounds; + // Only count as a gutter when the two frames overlap vertically. + const overlap = Math.min(prev.y + prev.height, cur.y + cur.height) - Math.max(prev.y, cur.y); + if (overlap > 0) gaps.push(cur.x - (prev.x + prev.width)); + } + return gaps.filter((g) => g > 0); +} + +/** + * Paragraph space-before/after pulled from style properties (raw IDML attribute + * values, in points), tolerant of `@`-prefixed and bare keys. + * @param {Array} styles + * @param {number} dpi + * @returns {number[]} + */ +function paragraphSpacing(styles, dpi) { + const out = []; + const keys = ['SpaceBefore', 'SpaceAfter', '@SpaceBefore', '@SpaceAfter', 'spaceBefore', 'spaceAfter']; + for (const style of styles) { + const props = style.properties ?? {}; + for (const key of keys) { + if (props[key] === undefined || props[key] === null) continue; + const px = lengthToPx(props[key], dpi); + if (Number.isFinite(px) && px > 0) out.push(px); + } + } + return out; +} + +/** + * @typedef {Object} MapSpacingOptions + * @property {number} [gridPx] Quantization grid (default 4). + * @property {number} [maxSizes] Cap on emitted sizes (default 8). + * @property {string} [namespace] Slug prefix (default 'id'). + * @property {{ add: (code: string, message: string, context?: object) => void }} [warnings] + * + * @param {import('../ir.js').DocumentIR} ir + * @param {MapSpacingOptions} [options] + * @returns {{ spacingSizes: Array<{ slug: string, size: string, name: string }> }} + */ +export function mapSpacing(ir, options = {}) { + const { + gridPx = DEFAULT_GRID_PX, + maxSizes = DEFAULT_MAX_SIZES, + namespace = 'id', + warnings, + } = options; + const warn = warnings && typeof warnings.add === 'function' + ? (code, message, context) => warnings.add(code, message, context) + : () => {}; + + const dpi = ir.dpi ?? 96; + /** @type {number[]} */ + const candidates = []; + let usedGeometry = false; + + for (const spread of ir.spreads ?? []) { + const frames = spread.frames ?? []; + if (frames.length) usedGeometry = true; + for (const page of spread.pages ?? []) { + candidates.push(...pageMargins(page, frames)); + } + candidates.push(...frameGutters(frames)); + } + candidates.push(...paragraphSpacing(ir.styles ?? [], dpi)); + + // Quantize, drop non-positive, dedupe, sort ascending, cap to the smallest N. + const quantized = [...new Set(candidates.map((px) => quantize(px, gridPx)).filter((px) => px > 0))] + .sort((a, b) => a - b) + .slice(0, maxSizes); + + if (usedGeometry && (ir.warnings ?? []).some((w) => w.code === 'pdf-fallback')) { + warn('spacing-approximate', 'Spacing scale was derived from approximate PDF geometry; verify against the source.', {}); + } + + const spacingSizes = quantized.map((px, i) => ({ + slug: `${namespace}-space-${(i + 1) * 10}`, + size: pxToRem(px), + name: `Space ${(i + 1) * 10}`, + })); + + return { spacingSizes }; +} diff --git a/packages/pipeline/src/indesign/map/theme-json.js b/packages/pipeline/src/indesign/map/theme-json.js new file mode 100644 index 0000000..fe42640 --- /dev/null +++ b/packages/pipeline/src/indesign/map/theme-json.js @@ -0,0 +1,114 @@ +// Assemble, deep-merge, and validate theme.json output. +// +// The mapper emits an additive, namespaced *partial*. mergeThemeJson folds it +// into a base theme (token arrays merge by slug, so namespaced derived tokens +// extend the curated base without clobbering it). validateThemeJson checks the +// result against the official WordPress block-theme schema (ajv) and the +// emitted-subset zod schema. +// +// The vendored schema in ./schema/theme-json.schema.json is the published +// WordPress theme.json schema (draft-07) from https://schemas.wp.org/trunk/theme.json. + +import { readFileSync } from 'node:fs'; +import Ajv from 'ajv'; +import addFormats from 'ajv-formats'; +import { partialThemeSchema } from './schema/partial.zod.js'; + +const wpSchema = JSON.parse(readFileSync(new URL('./schema/theme-json.schema.json', import.meta.url), 'utf8')); +const ajv = new Ajv({ allErrors: true, strict: false }); +addFormats(ajv); +const validateAgainstWpSchema = ajv.compile(wpSchema); + +/** + * Build an additive theme.json partial from mapped token groups. Empty groups + * are omitted so the partial stays minimal and schema-clean. + * + * @param {Object} groups + * @param {Array} [groups.palette] + * @param {Array} [groups.fontSizes] + * @param {Array} [groups.fontFamilies] + * @param {Array} [groups.spacingSizes] + * @param {Record} [groups.elements] styles.elements (h1–h6, caption…) + * @param {Record} [groups.blocks] styles.blocks (e.g. core/paragraph) + * @returns {object} + */ +export function assemblePartial(groups = {}) { + const { + palette = [], + fontSizes = [], + fontFamilies = [], + spacingSizes = [], + elements = {}, + blocks = {}, + } = groups; + + const settings = {}; + if (palette.length) settings.color = { palette }; + const typography = {}; + if (fontSizes.length) typography.fontSizes = fontSizes; + if (fontFamilies.length) typography.fontFamilies = fontFamilies; + if (Object.keys(typography).length) settings.typography = typography; + if (spacingSizes.length) settings.spacing = { spacingSizes }; + + const styles = {}; + if (elements && Object.keys(elements).length) styles.elements = elements; + if (blocks && Object.keys(blocks).length) styles.blocks = blocks; + + /** @type {{ version: number, settings?: object, styles?: object }} */ + const partial = { version: 3 }; + if (Object.keys(settings).length) partial.settings = settings; + if (Object.keys(styles).length) partial.styles = styles; + return partial; +} + +function isPlainObject(v) { + return v !== null && typeof v === 'object' && !Array.isArray(v); +} + +/** Merge two token arrays by `slug`; partial entries override/extend base. */ +function mergeBySlug(baseArr, partialArr) { + const bySlug = new Map(); + for (const item of baseArr) bySlug.set(item.slug, item); + for (const item of partialArr) bySlug.set(item.slug, { ...(bySlug.get(item.slug) ?? {}), ...item }); + return [...bySlug.values()]; +} + +function deepMerge(base, partial) { + if (Array.isArray(base) && Array.isArray(partial)) { + const allSlugged = [...base, ...partial].every((x) => isPlainObject(x) && 'slug' in x); + return allSlugged ? mergeBySlug(base, partial) : [...partial]; + } + if (isPlainObject(base) && isPlainObject(partial)) { + const out = { ...base }; + for (const key of Object.keys(partial)) { + out[key] = key in base ? deepMerge(base[key], partial[key]) : partial[key]; + } + return out; + } + return partial; +} + +/** + * Deep-merge a partial into a base theme. Token arrays merge by slug. + * @param {object} base + * @param {object} partial + * @returns {object} + */ +export function mergeThemeJson(base, partial) { + return deepMerge(base, partial); +} + +/** + * Validate a theme.json object against the official schema (ajv) and the + * emitted-subset zod schema. + * @param {object} themeJson + * @returns {{ valid: boolean, errors: Array }} + */ +export function validateThemeJson(themeJson) { + const ajvValid = validateAgainstWpSchema(themeJson); + const zodResult = partialThemeSchema.safeParse(themeJson); + const errors = []; + if (!ajvValid) errors.push(...(validateAgainstWpSchema.errors ?? [])); + if (!zodResult.success) errors.push(...zodResult.error.issues); + return { valid: ajvValid && zodResult.success, errors }; +} diff --git a/packages/pipeline/src/indesign/map/typography.js b/packages/pipeline/src/indesign/map/typography.js new file mode 100644 index 0000000..0a4eea6 --- /dev/null +++ b/packages/pipeline/src/indesign/map/typography.js @@ -0,0 +1,211 @@ +// Map IR paragraph styles to a theme.json typography scale and element presets. +// +// Distinct paragraph-style font sizes are clustered (near-equal sizes merge); +// each cluster becomes one scale entry, reusing a close base font-size slug when +// possible, else a namespaced derived slug named after the InDesign style. Every +// emitted entry is referenced by at least one paragraph style by construction. +// Recognized style names (Heading N / Body / Caption) also produce element +// presets carrying line height, letter spacing, and text color. + +import { namespacedSlug } from './slug.js'; + +const DEFAULT_TOLERANCE_PX = 1; +const DEFAULT_FLUID_THRESHOLD_PX = 32; + +const HEADING_RE = /^h(?:eading)?\s*([1-6])$/i; +const BODY_RE = /^(body|paragraph|normal|default|text|copy)\b/i; +const CAPTION_RE = /^(caption|footnote|cutline|credit)\b/i; + +/** @param {number} n @param {number} dp */ +function round(n, dp) { + const f = 10 ** dp; + return Math.round(n * f) / f; +} + +/** @param {number} px → rem string (16px base) */ +function pxToRem(px) { + return `${round(px / 16, 4)}rem`; +} + +/** Parse "1rem" / "18px" / unitless into px. */ +function remOrPxToPx(token) { + const m = /^([\d.]+)\s*(rem|px)?$/i.exec(String(token).trim()); + if (!m) return Number.NaN; + const n = Number.parseFloat(m[1]); + return m[2] && m[2].toLowerCase() === 'px' ? n : n * 16; +} + +/** Resolve a base font-size token to a nominal px (clamp() uses its max). */ +function resolveBasePx(size) { + if (typeof size === 'number') return size; + const s = String(size).trim(); + const clamp = /clamp\(([^,]+),([^,]+),([^)]+)\)/i.exec(s); + if (clamp) return remOrPxToPx(clamp[3]); + return remOrPxToPx(s); +} + +/** Mirror the base theme's fluid pattern for larger sizes. */ +function fluidClamp(px) { + const maxRem = round(px / 16, 4); + const minRem = round(maxRem * 0.85, 4); + return `clamp(${minRem}rem, ${minRem}rem + 2vw, ${maxRem}rem)`; +} + +/** + * Greedily cluster styles by font size (ascending), merging sizes within the + * tolerance of the running cluster mean. + * + * @param {Array} paragraphs + * @param {number} tolerancePx + * @returns {Array<{ styles: Array, representativePx: number }>} + */ +function clusterBySize(paragraphs, tolerancePx) { + const sorted = [...paragraphs].sort((a, b) => a.fontSize - b.fontSize); + const clusters = []; + for (const style of sorted) { + const last = clusters[clusters.length - 1]; + if (last) { + const mean = last.sizes.reduce((s, v) => s + v, 0) / last.sizes.length; + if (Math.abs(style.fontSize - mean) <= tolerancePx) { + last.styles.push(style); + last.sizes.push(style.fontSize); + continue; + } + } + clusters.push({ styles: [style], sizes: [style.fontSize] }); + } + return clusters.map((c) => ({ + styles: c.styles, + representativePx: Math.round(c.sizes.reduce((s, v) => s + v, 0) / c.sizes.length), + })); +} + +/** + * @typedef {Object} MapTypographyOptions + * @property {Array<{ slug: string, size: string | number }>} [baseFontSizes] + * @property {Record} [swatchToSlug] + * @property {number} [tolerancePx] + * @property {boolean} [fluid] + * @property {number} [fluidThresholdPx] + * @property {string} [namespace] + * + * @param {Array} styles + * @param {MapTypographyOptions} [options] + * @returns {{ fontSizes: Array<{ slug: string, size: string, name: string }>, elements: Record, styleToSlug: Record }} + */ +export function mapTypography(styles, options = {}) { + const { + baseFontSizes = [], + swatchToSlug = {}, + fontToSlug = {}, + tolerancePx = DEFAULT_TOLERANCE_PX, + fluid = false, + fluidThresholdPx = DEFAULT_FLUID_THRESHOLD_PX, + namespace = 'id', + } = options; + + const paragraphs = styles.filter( + (s) => s.kind === 'paragraph' && typeof s.fontSize === 'number' && s.fontSize > 0, + ); + const clusters = clusterBySize(paragraphs, tolerancePx); + + const used = new Set(baseFontSizes.map((b) => b.slug)); + const baseResolved = baseFontSizes.map((b) => ({ slug: b.slug, px: resolveBasePx(b.size) })); + + /** @type {Array<{ slug: string, size: string, name: string }>} */ + const fontSizes = []; + /** @type {Record} */ + const styleToSlug = {}; + + for (const cluster of clusters) { + // Reuse the closest base slug within tolerance. + let slug = null; + let bestDist = Infinity; + for (const b of baseResolved) { + const d = Math.abs(b.px - cluster.representativePx); + if (d <= tolerancePx && d < bestDist) { + bestDist = d; + slug = b.slug; + } + } + + if (!slug) { + // Derived token, named after the cluster style closest to the mean. + const rep = cluster.styles.reduce( + (best, s) => (Math.abs(s.fontSize - cluster.representativePx) < Math.abs(best.fontSize - cluster.representativePx) ? s : best), + cluster.styles[0], + ); + slug = namespacedSlug(rep.name, namespace, used, 'font-size'); + used.add(slug); + const size = fluid && cluster.representativePx >= fluidThresholdPx + ? fluidClamp(cluster.representativePx) + : pxToRem(cluster.representativePx); + fontSizes.push({ slug, size, name: rep.name }); + } + + for (const s of cluster.styles) styleToSlug[s.id] = slug; + } + + const { elements, blocks } = buildElements(paragraphs, clusters, styleToSlug, swatchToSlug, fontToSlug); + return { fontSizes, elements, blocks, styleToSlug }; +} + +/** + * Build style presets for recognized style names. Headings (h1–h6) and captions + * map to styles.elements; the body paragraph maps to styles.blocks['core/paragraph'] + * (theme.json has no

element). Falls back to the most-used size for the body. + * + * @returns {{ elements: Record, blocks: Record }} + */ +function buildElements(paragraphs, clusters, styleToSlug, swatchToSlug, fontToSlug = {}) { + /** @type {Record} */ + const elements = {}; + /** @type {Record} */ + const blocks = {}; + + const makeEntry = (style) => { + const slug = styleToSlug[style.id]; + /** @type {Record} */ + const typography = { fontSize: `var(--wp--preset--font-size--${slug})` }; + const familySlug = style.fontRef ? fontToSlug[style.fontRef] : undefined; + if (familySlug) typography.fontFamily = `var(--wp--preset--font-family--${familySlug})`; + if (style.leading && style.fontSize) { + typography.lineHeight = String(round(style.leading / style.fontSize, 2)); + } + if (style.tracking) { + typography.letterSpacing = `${round(style.tracking / 1000, 3)}em`; + } + /** @type {{ typography: object, color?: object }} */ + const entry = { typography }; + const colorSlug = style.fillColorRef ? swatchToSlug[style.fillColorRef] : undefined; + if (colorSlug) entry.color = { text: `var(--wp--preset--color--${colorSlug})` }; + return entry; + }; + + let bodyAssigned = false; + for (const style of paragraphs) { + const name = String(style.name ?? '').trim(); + const h = HEADING_RE.exec(name); + if (h && !elements[`h${h[1]}`]) { + elements[`h${h[1]}`] = makeEntry(style); + continue; + } + if (BODY_RE.test(name) && !blocks['core/paragraph']) { + blocks['core/paragraph'] = makeEntry(style); + bodyAssigned = true; + continue; + } + if (CAPTION_RE.test(name) && !elements.caption) { + elements.caption = makeEntry(style); + } + } + + // Fallback: bind the most-used size to the paragraph block if no body style + // was recognized by name. + if (!bodyAssigned && clusters.length) { + const bodyCluster = clusters.reduce((a, b) => (b.styles.length > a.styles.length ? b : a), clusters[0]); + if (bodyCluster && !blocks['core/paragraph']) blocks['core/paragraph'] = makeEntry(bodyCluster.styles[0]); + } + + return { elements, blocks }; +} diff --git a/packages/pipeline/src/indesign/parse-pdf.js b/packages/pipeline/src/indesign/parse-pdf.js index 79c1d84..e2e948d 100644 --- a/packages/pipeline/src/indesign/parse-pdf.js +++ b/packages/pipeline/src/indesign/parse-pdf.js @@ -126,7 +126,7 @@ export async function parsePdfBuffer(bytes, options = {}) { } else { const id = `pdf-color-${sample.hex.slice(1)}`; hexToSwatchId.set(sample.hex, id); - swatches.push({ id, name: sample.hex.toUpperCase(), color: { hex: sample.hex, space: 'RGB' } }); + swatches.push({ id, name: sample.hex.toUpperCase(), color: { hex: sample.hex, space: sample.space ?? 'RGB', components: sample.components } }); } } diff --git a/packages/pipeline/src/indesign/parsers/resources.js b/packages/pipeline/src/indesign/parsers/resources.js index 82c81c0..8ca2611 100644 --- a/packages/pipeline/src/indesign/parsers/resources.js +++ b/packages/pipeline/src/indesign/parsers/resources.js @@ -7,6 +7,7 @@ import { parseXml, asArray } from './xml.js'; import { lengthToPx, roundPx } from '../units.js'; +import { rgbToSrgbHex, cmykToSrgb, labToSrgb } from '../color.js'; /** * @param {string|Uint8Array} input @@ -48,11 +49,25 @@ export function parseGraphic(input, warnings) { */ function toIrColor(spaceRaw, values, warnings, id) { const space = normalizeColorSpace(spaceRaw); + // Raw components are kept in the IR so the mapper can re-derive sRGB with + // documented math; the hex here is a usable preview. IDML ranges: RGB 0-255, + // CMYK 0-100, LAB L 0-100 / a,b -128..127. if (space === 'RGB' && values.length === 3) { - return { hex: rgbToHex(values), space }; + return { hex: rgbToSrgbHex(values), space, components: values }; } if (space === 'CMYK' && values.length === 4) { - return { hex: cmykToHexApprox(values), space }; + return { hex: cmykToSrgb(values).hex, space, components: values }; + } + if (space === 'LAB' && values.length === 3) { + const { hex, outOfGamut } = labToSrgb(values); + if (outOfGamut) { + warnings.add( + 'color-out-of-gamut', + `Swatch ${id} LAB color is outside the sRGB gamut; clamped to the nearest in-gamut color`, + { file: 'Resources/Graphic.xml', id }, + ); + } + return { hex, space, components: values }; } warnings.add( 'color-fallback', @@ -68,21 +83,6 @@ function normalizeColorSpace(raw) { return 'Unknown'; } -function rgbToHex([r, g, b]) { - // IDML RGB is in 0-255 already. - return '#' + [r, g, b].map((c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, '0')).join(''); -} - -function cmykToHexApprox([c, m, y, k]) { - // IDML CMYK is in 0-100. Naive conversion; a real ICC profile pass is - // the mapper's job. This is enough to produce a recognisable preview. - const cv = c / 100, mv = m / 100, yv = y / 100, kv = k / 100; - const r = Math.round(255 * (1 - cv) * (1 - kv)); - const g = Math.round(255 * (1 - mv) * (1 - kv)); - const b = Math.round(255 * (1 - yv) * (1 - kv)); - return rgbToHex([r, g, b]); -} - /** * @param {string|Uint8Array} input * @returns {Array} diff --git a/packages/pipeline/src/indesign/pdf/color.js b/packages/pipeline/src/indesign/pdf/color.js index 9be97bb..5c1f2f4 100644 --- a/packages/pipeline/src/indesign/pdf/color.js +++ b/packages/pipeline/src/indesign/pdf/color.js @@ -1,93 +1,7 @@ -// Color normalization for PDF fill operators, plus nearest-match against an -// IDML-derived swatch palette. -// -// PDF content streams set fill color via three operator families: -// rg -> DeviceRGB (pdfjs hands us 0..255 ints) -// g -> DeviceGray (pdfjs hands us a single 0..255 int) -// k -> DeviceCMYK (pdfjs hands us 0..1 floats) -// We collapse all of them to "#rrggbb". When a palette from a sibling IDML -// parse is available, we snap each detected color to the closest swatch so the -// PDF and IDML pipelines produce aligned token names downstream. +// Color helpers for the PDF operator path. These now live in the shared color +// module (../color.js) so the PDF parser, IDML parser, and token mapper share a +// single implementation; this file re-exports the device-shaped subset the PDF +// extractor and parser use. See ../color.js for the documented source-space → +// sRGB conversions (cmykToSrgb/labToSrgb) used by the mapper. -/** - * @param {number} n - * @returns {string} two-digit lowercase hex - */ -function hexByte(n) { - return Math.max(0, Math.min(255, Math.round(n))).toString(16).padStart(2, '0'); -} - -/** - * @param {[number, number, number]} rgb 0..255 per channel - * @returns {string} - */ -export function rgbToHex([r, g, b]) { - return `#${hexByte(r)}${hexByte(g)}${hexByte(b)}`; -} - -/** - * @param {number} gray 0..255 - * @returns {string} - */ -export function grayToHex(gray) { - return rgbToHex([gray, gray, gray]); -} - -/** - * DeviceCMYK (0..1) → hex via the same naive conversion the IDML graphic - * parser uses, so identical CMYK swatches land on identical hex in both pipelines. - * - * @param {[number, number, number, number]} cmyk 0..1 per channel - * @returns {string} - */ -export function cmykToHex([c, m, y, k]) { - const r = 255 * (1 - c) * (1 - k); - const g = 255 * (1 - m) * (1 - k); - const b = 255 * (1 - y) * (1 - k); - return rgbToHex([r, g, b]); -} - -/** - * @param {string} hex "#rrggbb" - * @returns {[number, number, number]} - */ -export function hexToRgb(hex) { - const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex); - if (!m) return [0, 0, 0]; - return [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)]; -} - -/** - * Squared Euclidean distance in RGB. Squared is enough for "which is closest" - * and avoids a sqrt per comparison. - * - * @param {string} a "#rrggbb" - * @param {string} b "#rrggbb" - * @returns {number} - */ -export function colorDistance(a, b) { - const [r1, g1, b1] = hexToRgb(a); - const [r2, g2, b2] = hexToRgb(b); - return (r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2; -} - -/** - * Find the closest swatch in a palette, within a tolerance. - * - * @param {string} hex "#rrggbb" - * @param {Array} palette - * @param {number} [maxDistance] Squared-distance cutoff (default ~24/channel). - * @returns {import('../ir.js').SwatchIR | null} - */ -export function nearestSwatch(hex, palette, maxDistance = 24 * 24 * 3) { - let best = null; - let bestDist = Infinity; - for (const swatch of palette) { - const dist = colorDistance(hex, swatch.color.hex); - if (dist < bestDist) { - bestDist = dist; - best = swatch; - } - } - return best && bestDist <= maxDistance ? best : null; -} +export { rgbToHex, grayToHex, cmykToHex, hexToRgb, colorDistance, nearestSwatch } from '../color.js'; diff --git a/packages/pipeline/src/indesign/pdf/extract.js b/packages/pipeline/src/indesign/pdf/extract.js index 7134551..1d637ba 100644 --- a/packages/pipeline/src/indesign/pdf/extract.js +++ b/packages/pipeline/src/indesign/pdf/extract.js @@ -75,7 +75,7 @@ function getImageObject(page, name) { * heightPt: number, * textItems: Array<{ text: string, x: number, baseline: number, width: number, fontSize: number, fontKey: string }>, * fonts: Map, - * colorSamples: Array<{ fontSizePt: number, hex: string, glyphs: number }>, + * colorSamples: Array<{ fontSizePt: number, hex: string, space: string, components: number[], glyphs: number }>, * images: Array<{ x: number, y: number, width: number, height: number, image: object | null, failed: boolean }>, * hasVector: boolean, * }>} @@ -93,6 +93,9 @@ export async function extractPage(page, pdfjs) { let ctm = [1, 0, 0, 1, 0, 0]; const ctmStack = []; let fillHex = '#000000'; + // Raw fill color alongside the hex, in the IR's component ranges (RGB 0..255, + // CMYK 0..100), so synthesized swatches can carry components like IDML does. + let fillColor = { space: 'RGB', components: [0, 0, 0] }; let currentSize = 0; let hasVector = false; const colorSamples = []; @@ -116,12 +119,16 @@ export async function extractPage(page, pdfjs) { break; case OPS.setFillRGBColor: fillHex = rgbToHex([args[0], args[1], args[2]]); + fillColor = { space: 'RGB', components: [args[0], args[1], args[2]] }; break; case OPS.setFillGray: fillHex = grayToHex(args[0]); + fillColor = { space: 'RGB', components: [args[0], args[0], args[0]] }; break; case OPS.setFillCMYKColor: fillHex = cmykToHex([args[0], args[1], args[2], args[3]]); + // pdfjs gives CMYK in 0..1; store 0..100 to match the IR range. + fillColor = { space: 'CMYK', components: [args[0] * 100, args[1] * 100, args[2] * 100, args[3] * 100] }; break; case OPS.setFont: currentSize = Math.abs(args[1]); @@ -130,7 +137,7 @@ export async function extractPage(page, pdfjs) { case OPS.showSpacedText: { const glyphs = countGlyphs(args[0]); if (glyphs > 0 && currentSize > 0) { - colorSamples.push({ fontSizePt: currentSize, hex: fillHex, glyphs }); + colorSamples.push({ fontSizePt: currentSize, hex: fillHex, space: fillColor.space, components: fillColor.components, glyphs }); } break; } diff --git a/packages/pipeline/tests/indesign/color.test.mjs b/packages/pipeline/tests/indesign/color.test.mjs new file mode 100644 index 0000000..b361a0a --- /dev/null +++ b/packages/pipeline/tests/indesign/color.test.mjs @@ -0,0 +1,44 @@ +// Shared color conversion: RGB/CMYK/LAB → sRGB, with out-of-gamut detection. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + rgbToSrgbHex, + cmykToSrgb, + labToSrgb, + colorFromComponents, +} from '../../src/indesign/color.js'; + +test('rgbToSrgbHex formats 0..255 channels', () => { + assert.equal(rgbToSrgbHex([0, 102, 204]), '#0066cc'); +}); + +test('cmykToSrgb (0..100) matches the naive IDML preview', () => { + assert.deepEqual(cmykToSrgb([0, 0, 0, 100]), { hex: '#000000', outOfGamut: false }); + assert.deepEqual(cmykToSrgb([0, 0, 0, 0]), { hex: '#ffffff', outOfGamut: false }); + // Pure cyan ink. + assert.equal(cmykToSrgb([100, 0, 0, 0]).hex, '#00ffff'); +}); + +test('labToSrgb converts reference values (D50)', () => { + assert.equal(labToSrgb([100, 0, 0]).hex, '#ffffff'); // white + assert.equal(labToSrgb([0, 0, 0]).hex, '#000000'); // black + // Mid grey L*≈53.39 → ~#7f7f7f / #808080. Assert a neutral grey, not exact. + const grey = labToSrgb([53.389, 0, 0]).hex; + assert.match(grey, /^#(7[0-9a-f]|80)\1\1$/); +}); + +test('labToSrgb does not flag in-gamut white, flags saturated out-of-gamut color', () => { + assert.equal(labToSrgb([100, 0, 0]).outOfGamut, false); + // Extreme saturation well outside sRGB. + assert.equal(labToSrgb([50, 120, -120]).outOfGamut, true); +}); + +test('colorFromComponents dispatches by space and falls back to hex', () => { + assert.equal(colorFromComponents('CMYK', [0, 0, 0, 100], '#123456').hex, '#000000'); + assert.equal(colorFromComponents('RGB', [0, 102, 204], '#123456').hex, '#0066cc'); + // No components → fall back to the supplied hex. + assert.equal(colorFromComponents('LAB', undefined, '#abcdef').hex, '#abcdef'); + // Opaque spaces fall back to hex. + assert.equal(colorFromComponents('Spot', [1, 2, 3], '#abcdef').hex, '#abcdef'); +}); diff --git a/packages/pipeline/tests/indesign/ir-color.test.mjs b/packages/pipeline/tests/indesign/ir-color.test.mjs new file mode 100644 index 0000000..6ee130c --- /dev/null +++ b/packages/pipeline/tests/indesign/ir-color.test.mjs @@ -0,0 +1,15 @@ +// IR Color schema: optional raw components for the token mapper. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { Color } from '../../src/indesign/ir.js'; + +test('Color accepts optional raw components', () => { + const c = Color.parse({ hex: '#0066cc', space: 'RGB', components: [0, 102, 204] }); + assert.deepEqual(c.components, [0, 102, 204]); +}); + +test('Color still validates without components (backward compatible)', () => { + const c = Color.parse({ hex: '#000000', space: 'CMYK' }); + assert.equal(c.components, undefined); +}); diff --git a/packages/pipeline/tests/indesign/malformed.test.mjs b/packages/pipeline/tests/indesign/malformed.test.mjs index 67d7e7c..a02aa66 100644 --- a/packages/pipeline/tests/indesign/malformed.test.mjs +++ b/packages/pipeline/tests/indesign/malformed.test.mjs @@ -111,7 +111,7 @@ test('missing optional resource files produce warnings, not errors', () => { test('unknown color space falls back to black with a warning', () => { const bytes = buildIdml({ - colors: [{ id: 'col-weird', name: 'Mystery', space: 'LAB', values: [50, 0, 0] }], + colors: [{ id: 'col-weird', name: 'Mystery', space: 'HSB', values: [50, 0, 0] }], }); const ir = parseIdmlBuffer(bytes); const swatch = ir.swatches.find((s) => s.id === 'col-weird'); @@ -119,3 +119,14 @@ test('unknown color space falls back to black with a warning', () => { assert.equal(swatch.color.space, 'Unknown'); assert.ok(ir.warnings.some((w) => w.code === 'color-fallback')); }); + +test('LAB color space is converted to sRGB (no longer collapses to black)', () => { + const bytes = buildIdml({ + colors: [{ id: 'col-lab', name: 'Lab Red', space: 'LAB', values: [54, 81, 70] }], + }); + const ir = parseIdmlBuffer(bytes); + const swatch = ir.swatches.find((s) => s.id === 'col-lab'); + assert.equal(swatch.color.space, 'LAB'); + assert.deepEqual(swatch.color.components, [54, 81, 70]); + assert.notEqual(swatch.color.hex, '#000000'); +}); diff --git a/packages/pipeline/tests/indesign/map-colors.test.mjs b/packages/pipeline/tests/indesign/map-colors.test.mjs new file mode 100644 index 0000000..d3ddea1 --- /dev/null +++ b/packages/pipeline/tests/indesign/map-colors.test.mjs @@ -0,0 +1,44 @@ +// Swatches → theme.json color palette: convert, dedupe, reuse base slugs. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mapColors } from '../../src/indesign/map/colors.js'; + +const base = [{ slug: 'primary', color: '#0b5cff', name: 'Primary' }]; + +test('includes all distinct swatches, deduped within tolerance', () => { + const swatches = [ + { id: 's1', name: 'Brand Blue', color: { hex: '#0066cc', space: 'RGB', components: [0, 102, 204] } }, + { id: 's2', name: 'Brand Blue Dup', color: { hex: '#0265cb', space: 'RGB', components: [2, 101, 203] } }, + { id: 's3', name: 'Ink', color: { hex: '#121212', space: 'CMYK', components: [0, 0, 0, 93] } }, + ]; + const { palette, swatchToSlug } = mapColors(swatches, { basePalette: base, tolerance: 24 * 24 * 3 }); + // s1 and s2 collapse to one entry; ink is separate → 2 derived entries. + assert.equal(palette.length, 2); + assert.equal(swatchToSlug.s1, swatchToSlug.s2); + // Every derived palette entry is a valid theme.json token. + for (const entry of palette) { + assert.match(entry.color, /^#[0-9a-f]{6}$/); + assert.ok(entry.slug && entry.name); + } +}); + +test('reuses a base slug when close, instead of duplicating', () => { + const swatches = [{ id: 's1', name: 'Almost Primary', color: { hex: '#0b5dff', space: 'RGB', components: [11, 93, 255] } }]; + const { palette, swatchToSlug } = mapColors(swatches, { basePalette: base, tolerance: 24 * 24 * 3 }); + assert.equal(swatchToSlug.s1, 'primary'); + assert.equal(palette.length, 0); // nothing new; it maps onto the base +}); + +test('derived slugs are namespaced and warns on opaque spaces', () => { + const warnings = []; + const swatches = [{ id: 's1', name: 'Pantone 286', color: { hex: '#0000ff', space: 'Spot', components: undefined } }]; + const { palette, swatchToSlug } = mapColors(swatches, { + basePalette: base, + namespace: 'id', + warnings: { add: (code, message) => warnings.push({ code, message }) }, + }); + assert.equal(palette.length, 1); + assert.ok(swatchToSlug.s1.startsWith('id-')); + assert.ok(warnings.some((w) => w.code === 'swatch-approximated')); +}); diff --git a/packages/pipeline/tests/indesign/map-design-tokens.test.mjs b/packages/pipeline/tests/indesign/map-design-tokens.test.mjs new file mode 100644 index 0000000..4b31c3b --- /dev/null +++ b/packages/pipeline/tests/indesign/map-design-tokens.test.mjs @@ -0,0 +1,28 @@ +// Mapped token groups → Style Dictionary (DTCG) design tokens. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { toDesignTokens } from '../../src/indesign/map/design-tokens.js'; + +test('emits DTCG-shaped tokens with type, value, and provenance', () => { + const dtcg = toDesignTokens({ + palette: [{ slug: 'id-brand', color: '#0066cc', name: 'Brand' }], + fontSizes: [{ slug: 'id-lead', size: '1.3125rem', name: 'Lead' }], + fontFamilies: [{ slug: 'id-merriweather', fontFamily: 'Merriweather, serif', name: 'Merriweather' }], + spacingSizes: [{ slug: 'id-space-10', size: '1.25rem', name: 'Space 10' }], + }); + assert.equal(dtcg.color['id-brand'].$value, '#0066cc'); + assert.equal(dtcg.color['id-brand'].$type, 'color'); + assert.equal(dtcg.color['id-brand'].$description, 'Brand'); + assert.equal(dtcg.fontSize['id-lead'].$type, 'dimension'); + assert.equal(dtcg.fontFamily['id-merriweather'].$type, 'fontFamily'); + assert.equal(dtcg.fontFamily['id-merriweather'].$value, 'Merriweather, serif'); + assert.equal(dtcg.spacing['id-space-10'].$type, 'dimension'); +}); + +test('omits empty groups', () => { + const dtcg = toDesignTokens({ palette: [{ slug: 'id-brand', color: '#0066cc', name: 'Brand' }] }); + assert.ok(dtcg.color); + assert.equal(dtcg.fontSize, undefined); + assert.equal(dtcg.spacing, undefined); +}); diff --git a/packages/pipeline/tests/indesign/map-fonts.test.mjs b/packages/pipeline/tests/indesign/map-fonts.test.mjs new file mode 100644 index 0000000..b3422d7 --- /dev/null +++ b/packages/pipeline/tests/indesign/map-fonts.test.mjs @@ -0,0 +1,46 @@ +// InDesign fonts → theme.json font families, via a configurable fallback table. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mapFonts, loadFontMap } from '../../src/indesign/map/fonts.js'; + +const baseFamilies = [ + { slug: 'sans', name: 'Sans', fontFamily: "system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" }, + { slug: 'serif', name: 'Serif', fontFamily: "Georgia, 'Times New Roman', serif" }, +]; +const fontMap = { Georgia: { fontFamily: "Georgia, 'Times New Roman', serif", source: 'system', fallback: 'serif' } }; + +test('maps a known family and warns + falls back for an unknown one', () => { + const warnings = []; + const fonts = [ + { id: 'f1', family: 'Georgia', style: 'Regular' }, + { id: 'f2', family: 'Bell Gothic', style: 'Bold' }, + ]; + const { fontToSlug } = mapFonts(fonts, { + fontMap, + baseFontFamilies: baseFamilies, + warnings: { add: (code, msg) => warnings.push({ code, msg }) }, + }); + assert.equal(fontToSlug.f1, 'serif'); // Georgia's mapped stack matches base serif + assert.ok(warnings.some((w) => w.code === 'font-fallback' && /Bell Gothic/.test(w.msg))); + assert.equal(fontToSlug.f2, 'sans'); // heuristic generic → base sans +}); + +test('deduplicates fonts that share a family and records google fonts', () => { + const fonts = [ + { id: 'f1', family: 'Merriweather', style: 'Regular' }, + { id: 'f2', family: 'Merriweather', style: 'Bold' }, + ]; + const map = { Merriweather: { fontFamily: 'Merriweather, Georgia, serif', source: 'google', googleFontName: 'Merriweather', fallback: 'serif' } }; + const { fontFamilies, fontToSlug, googleFonts } = mapFonts(fonts, { fontMap: map, baseFontFamilies: baseFamilies, namespace: 'id' }); + assert.equal(fontToSlug.f1, fontToSlug.f2); // same family → same slug + assert.equal(fontFamilies.length, 1); + assert.equal(fontFamilies[0].slug, 'id-merriweather'); + assert.ok(googleFonts.some((g) => g.name === 'Merriweather')); +}); + +test('ships a default font map with common families', () => { + const map = loadFontMap(); + assert.ok(map.Helvetica, 'expected Helvetica in the default map'); + assert.ok(map.Georgia, 'expected Georgia in the default map'); +}); diff --git a/packages/pipeline/tests/indesign/map-spacing.test.mjs b/packages/pipeline/tests/indesign/map-spacing.test.mjs new file mode 100644 index 0000000..fac9f44 --- /dev/null +++ b/packages/pipeline/tests/indesign/map-spacing.test.mjs @@ -0,0 +1,59 @@ +// IR geometry + paragraph spacing → a quantized theme.json spacing scale. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mapSpacing } from '../../src/indesign/map/spacing.js'; + +function irWith(frames, styles = []) { + return { + dpi: 96, + swatches: [], fonts: [], stories: [], masterSpreads: [], warnings: [], + styles, + spreads: [{ + id: 'sp1', source: 's', + pages: [{ id: 'pg', bounds: { x: 0, y: 0, width: 600, height: 800 } }], + frames, + }], + }; +} + +test('quantizes observed gaps to the grid and dedupes', () => { + const ir = irWith( + [ + { kind: 'text', id: 'f1', bounds: { x: 48, y: 50, width: 500, height: 100 } }, + { kind: 'text', id: 'f2', bounds: { x: 48, y: 170, width: 500, height: 100 } }, // 20px vertical gap + ], + [{ id: 'p1', name: 'Body', kind: 'paragraph', properties: { SpaceAfter: '12pt' } }], // 16px + ); + const { spacingSizes } = mapSpacing(ir, { gridPx: 4 }); + const rems = spacingSizes.map((s) => s.size); + assert.ok(rems.includes('1.25rem'), `expected a 20px (1.25rem) gap, got ${rems.join()}`); + assert.ok(rems.includes('1rem'), '12pt paragraph spacing should quantize to 16px (1rem)'); + // All entries are valid spacing tokens. + for (const s of spacingSizes) { + assert.ok(s.slug && s.name); + assert.match(s.size, /^[\d.]+rem$/); + } +}); + +test('caps the number of emitted sizes', () => { + const frames = []; + let y = 0; + for (let i = 0; i < 20; i += 1) { + frames.push({ kind: 'text', id: `f${i}`, bounds: { x: 10, y, width: 100, height: 10 } }); + y += 10 + (i + 1) * 4; // ever-growing gaps → many distinct values + } + const { spacingSizes } = mapSpacing(irWith(frames), { gridPx: 4, maxSizes: 6 }); + assert.ok(spacingSizes.length <= 6, `expected ≤6, got ${spacingSizes.length}`); +}); + +test('emits an approximate warning for PDF-derived IRs', () => { + const warnings = []; + const ir = irWith([ + { kind: 'text', id: 'f1', bounds: { x: 10, y: 10, width: 100, height: 50 } }, + { kind: 'text', id: 'f2', bounds: { x: 10, y: 90, width: 100, height: 50 } }, + ]); + ir.warnings = [{ code: 'pdf-fallback', message: 'x', context: {} }]; + mapSpacing(ir, { gridPx: 4, warnings: { add: (code) => warnings.push(code) } }); + assert.ok(warnings.includes('spacing-approximate')); +}); diff --git a/packages/pipeline/tests/indesign/map-theme-json.test.mjs b/packages/pipeline/tests/indesign/map-theme-json.test.mjs new file mode 100644 index 0000000..da9701b --- /dev/null +++ b/packages/pipeline/tests/indesign/map-theme-json.test.mjs @@ -0,0 +1,53 @@ +// Assemble a theme.json partial, deep-merge with the base theme, and validate +// against the official WordPress schema (ajv) plus the emitted-subset zod schema. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { assemblePartial, mergeThemeJson, validateThemeJson } from '../../src/indesign/map/theme-json.js'; + +const base = JSON.parse(readFileSync(new URL('../../../../themes/flavian-shop/theme.json', import.meta.url), 'utf8')); + +test('assembled partial validates against the WordPress schema', () => { + const partial = assemblePartial({ + palette: [{ slug: 'id-brand-blue', color: '#0066cc', name: 'Brand Blue' }], + fontSizes: [{ slug: 'id-lead', size: '1.3125rem', name: 'Lead' }], + fontFamilies: [], + spacingSizes: [], + elements: {}, + }); + assert.equal(partial.version, 3); + const { valid, errors } = validateThemeJson(partial); + assert.ok(valid, JSON.stringify(errors)); +}); + +test('merge keeps base tokens and adds namespaced derived tokens', () => { + const partial = assemblePartial({ + palette: [{ slug: 'id-brand-blue', color: '#0066cc', name: 'Brand Blue' }], + fontSizes: [], + fontFamilies: [], + spacingSizes: [], + elements: {}, + }); + const merged = mergeThemeJson(base, partial); + const slugs = merged.settings.color.palette.map((p) => p.slug); + assert.ok(slugs.includes('primary'), 'base token preserved'); + assert.ok(slugs.includes('id-brand-blue'), 'derived token added'); + assert.ok(validateThemeJson(merged).valid); +}); + +test('merging the same slug overrides rather than duplicating', () => { + const partial = assemblePartial({ + palette: [{ slug: 'primary', color: '#123456', name: 'Primary' }], + fontSizes: [], fontFamilies: [], spacingSizes: [], elements: {}, + }); + const merged = mergeThemeJson(base, partial); + const primaries = merged.settings.color.palette.filter((p) => p.slug === 'primary'); + assert.equal(primaries.length, 1); + assert.equal(primaries[0].color, '#123456'); +}); + +test('validation rejects a malformed color token', () => { + const bad = { version: 3, settings: { color: { palette: [{ slug: 'x' }] } } }; // missing color + assert.equal(validateThemeJson(bad).valid, false); +}); diff --git a/packages/pipeline/tests/indesign/map-tokens.test.mjs b/packages/pipeline/tests/indesign/map-tokens.test.mjs new file mode 100644 index 0000000..ee49877 --- /dev/null +++ b/packages/pipeline/tests/indesign/map-tokens.test.mjs @@ -0,0 +1,100 @@ +// End-to-end: mapTokens runs on an IR from either parser (IDML or PDF) and +// produces a schema-valid theme.json, DTCG tokens, and a report. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mapTokens } from '../../src/indesign/map/index.js'; +import { parseIdmlBuffer } from '../../src/indesign/parse-idml.js'; +import { parsePdfBuffer } from '../../src/indesign/parse-pdf.js'; +import { buildIdml } from './helpers/build-idml.js'; +import { buildPdf } from './helpers/build-pdf.js'; + +function buildBrochureIdml() { + return buildIdml({ + name: 'Brochure', + colors: [ + { id: 'col-brand', name: 'Brand Blue', space: 'RGB', values: [0, 102, 204] }, + { id: 'col-ink', name: 'Ink', space: 'CMYK', values: [0, 0, 0, 100] }, + { id: 'col-accent', name: 'Accent', space: 'LAB', values: [54, 81, 70] }, + ], + fonts: [ + { id: 'f-helv', family: 'Helvetica', style: 'Bold', postScriptName: 'Helvetica-Bold' }, + { id: 'f-bell', family: 'Bell Gothic', style: 'Regular' }, // not in the font map + ], + styles: [ + { id: 'p-h1', name: 'Heading 1', kind: 'paragraph', pointSize: 36, leading: 40, appliedFont: 'f-helv', fillColor: 'col-brand' }, + { id: 'p-body', name: 'Body', kind: 'paragraph', pointSize: 12, leading: 18, appliedFont: 'f-bell', fillColor: 'col-ink' }, + ], + stories: [ + { id: 'story-h', runs: [{ text: 'Welcome', paragraphStyle: 'p-h1' }] }, + { id: 'story-b', runs: [{ text: 'Body copy here.', paragraphStyle: 'p-body' }] }, + ], + spreads: [ + { + id: 'spread-1', + pages: [{ id: 'page-1', bounds: [0, 0, 792, 612] }], + frames: [ + { kind: 'text', id: 'frame-h', bounds: [72, 72, 130, 400], parentStory: 'story-h' }, + { kind: 'text', id: 'frame-b', bounds: [150, 72, 230, 400], parentStory: 'story-b' }, + ], + }, + ], + }); +} + +test('maps an IDML-derived IR into schema-valid tokens', () => { + const ir = parseIdmlBuffer(buildBrochureIdml()); + const { partial, designTokens, merged, report } = mapTokens(ir); + + // theme.json validates against the official schema (+ zod subset). + assert.ok(report.valid, JSON.stringify(report.validationErrors)); + + // Palette includes all distinct swatches (3 derived, none close to base). + assert.equal(report.counts.swatches, 3); + assert.ok(merged.settings.color.palette.length > 10, 'derived colors added to base'); + + // Every emitted typography entry is referenced by at least one paragraph style. + const referenced = new Set(Object.values(report.provenance.styleToSlug)); + for (const entry of partial.settings.typography?.fontSizes ?? []) { + assert.ok(referenced.has(entry.slug), `font size ${entry.slug} unreferenced`); + } + + // Font fallback warning emitted + listed in the report. + assert.ok(report.fontFallbacks.some((m) => /Bell Gothic/.test(m))); + + // Named style variation: h1 element carries size, family, and color. + assert.ok(partial.styles.elements.h1); + assert.match(partial.styles.elements.h1.typography.fontSize, /^var\(--wp--preset--font-size--/); + assert.ok(partial.styles.elements.h1.typography.fontFamily); + assert.match(partial.styles.elements.h1.color.text, /^var\(--wp--preset--color--/); + + // DTCG output present. + assert.ok(designTokens.color); + assert.ok(designTokens.fontSize); +}); + +test('maps a PDF-derived IR into schema-valid tokens (source-agnostic)', async () => { + const toUnit = ([r, g, b]) => [r / 255, g / 255, b / 255]; + const pdf = buildPdf({ + title: 'Brochure PDF', + pages: [ + { + width: 612, + height: 792, + texts: [ + // Teal headline — clearly outside the base palette, so it derives a token. + { text: 'Welcome', x: 72, y: 96, size: 36, font: 'Helvetica-Bold', color: toUnit([20, 184, 166]) }, + { text: 'Body copy that wraps across the column nicely.', x: 72, y: 150, size: 12, font: 'Helvetica', color: toUnit([0, 0, 0]) }, + ], + }, + ], + }); + const ir = await parsePdfBuffer(pdf); + const { merged, designTokens, report } = mapTokens(ir); + + assert.ok(report.valid, JSON.stringify(report.validationErrors)); + assert.ok(merged.settings.color.palette.length >= 10); + assert.ok(designTokens.color, 'a distinct headline color should derive a color token'); + // Styles synthesized from the PDF were mapped to typography slugs. + assert.ok(Object.keys(report.provenance.styleToSlug).length > 0); +}); diff --git a/packages/pipeline/tests/indesign/map-typography.test.mjs b/packages/pipeline/tests/indesign/map-typography.test.mjs new file mode 100644 index 0000000..10c7dc4 --- /dev/null +++ b/packages/pipeline/tests/indesign/map-typography.test.mjs @@ -0,0 +1,54 @@ +// Paragraph styles → theme.json typography scale + element presets. + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mapTypography } from '../../src/indesign/map/typography.js'; + +const baseFontSizes = [ + { slug: 'base', size: '1rem' }, // 16px + { slug: 'display', size: 'clamp(2.25rem, 5vw, 3.5rem)' }, // 56px max +]; + +test('clusters near-equal sizes and references every emitted entry by a style', () => { + const styles = [ + { id: 'p1', name: 'Body', kind: 'paragraph', fontSize: 16 }, + { id: 'p2', name: 'Body Alt', kind: 'paragraph', fontSize: 16.4 }, + { id: 'p3', name: 'Title', kind: 'paragraph', fontSize: 56 }, + ]; + const { fontSizes, styleToSlug } = mapTypography(styles, { baseFontSizes, tolerancePx: 1 }); + // 16 / 16.4 reuse base 'base'; 56 reuses base 'display' → no new entries. + assert.equal(styleToSlug.p1, 'base'); + assert.equal(styleToSlug.p2, 'base'); + assert.equal(styleToSlug.p3, 'display'); + for (const entry of fontSizes) { + assert.ok(Object.values(styleToSlug).includes(entry.slug), `entry ${entry.slug} must be referenced`); + } +}); + +test('creates a derived, namespaced slug for a size with no base match', () => { + const styles = [{ id: 'p1', name: 'Lead', kind: 'paragraph', fontSize: 21 }]; + const { fontSizes, styleToSlug } = mapTypography(styles, { baseFontSizes, tolerancePx: 1, namespace: 'id' }); + assert.equal(fontSizes.length, 1); + assert.equal(styleToSlug.p1, fontSizes[0].slug); + assert.equal(fontSizes[0].slug, 'id-lead'); + assert.equal(fontSizes[0].size, '1.3125rem'); // 21/16 + assert.equal(fontSizes[0].name, 'Lead'); +}); + +test('maps recognized style names to elements with line height and color', () => { + const styles = [ + { id: 'p1', name: 'Heading 1', kind: 'paragraph', fontSize: 56, leading: 64, tracking: -10, fillColorRef: 'col-brand' }, + { id: 'p2', name: 'Body', kind: 'paragraph', fontSize: 16, leading: 24 }, + ]; + const swatchToSlug = { 'col-brand': 'id-brand' }; + const { elements, blocks } = mapTypography(styles, { baseFontSizes, swatchToSlug, tolerancePx: 1 }); + assert.ok(elements.h1, 'h1 element should be present'); + assert.equal(elements.h1.typography.fontSize, 'var(--wp--preset--font-size--display)'); + assert.equal(elements.h1.typography.lineHeight, '1.14'); // 64/56 + assert.equal(elements.h1.typography.letterSpacing, '-0.01em'); // -10/1000 + assert.equal(elements.h1.color.text, 'var(--wp--preset--color--id-brand)'); + // theme.json has no

element; body maps to the core/paragraph block. + assert.ok(blocks['core/paragraph'], 'core/paragraph block should be present'); + assert.equal(blocks['core/paragraph'].typography.lineHeight, '1.5'); // 24/16 + assert.equal(elements.p, undefined); +}); diff --git a/packages/pipeline/tests/indesign/parser-color-components.test.mjs b/packages/pipeline/tests/indesign/parser-color-components.test.mjs new file mode 100644 index 0000000..ee081fb --- /dev/null +++ b/packages/pipeline/tests/indesign/parser-color-components.test.mjs @@ -0,0 +1,32 @@ +// The IDML graphic parser attaches raw color components and converts LAB +// through the shared module (no longer collapsing LAB to black). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { parseGraphic } from '../../src/indesign/parsers/resources.js'; +import { WarningCollector } from '../../src/indesign/warnings.js'; + +test('IDML LAB swatch converts to a real (non-black) color with components', () => { + const warnings = new WarningCollector(); + const xml = ''; + const [sw] = parseGraphic(xml, warnings); + assert.equal(sw.color.space, 'LAB'); + assert.deepEqual(sw.color.components, [54, 81, 70]); + assert.notEqual(sw.color.hex, '#000000'); +}); + +test('IDML CMYK swatch keeps components in 0..100 and converts to hex', () => { + const warnings = new WarningCollector(); + const xml = ''; + const [sw] = parseGraphic(xml, warnings); + assert.deepEqual(sw.color.components, [0, 0, 0, 100]); + assert.equal(sw.color.hex, '#000000'); +}); + +test('IDML RGB swatch carries components', () => { + const warnings = new WarningCollector(); + const xml = ''; + const [sw] = parseGraphic(xml, warnings); + assert.deepEqual(sw.color.components, [0, 102, 204]); + assert.equal(sw.color.hex, '#0066cc'); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eafe875..434bade 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,13 @@ importers: zod: specifier: ^3.23.8 version: 3.23.8 + devDependencies: + ajv: + specifier: ^8.20.0 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) packages: @@ -287,6 +294,14 @@ packages: resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} engines: {node: '>= 14'} + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} @@ -1957,6 +1972,10 @@ snapshots: agent-base@7.1.4: {} + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + ajv@8.20.0: dependencies: fast-deep-equal: 3.1.3