File size: 19,919 Bytes
4748aae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea7b176
4748aae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea7b176
 
 
 
 
 
 
4748aae
 
 
 
ea7b176
 
 
 
 
 
 
 
4748aae
 
 
 
 
 
 
 
 
 
 
 
 
 
ea7b176
4748aae
 
ea7b176
 
 
4748aae
 
 
ea7b176
4748aae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9371818
 
 
 
 
 
 
 
 
 
 
 
 
 
4748aae
9371818
ea7b176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4748aae
 
 
 
 
9371818
4748aae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9371818
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea7b176
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9371818
 
 
 
 
 
 
 
 
 
 
4748aae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
// ---------------------------------------------------------------------------
// customer-grid / catalogData.ts
// Wave-18 owner item 4 (contract C6-CATALOG) β€” the CATALOG view's pure layer:
// paper geometry, page templates, product resolution and the C2-ASSET image
// URLs. No React, no DOM: everything here is drivable by the gate.
//
// What a catalog IS, stated once so the rest of the code can be short: a view
// holds up to MAX_CATALOGS print artifacts; each is an ordered list of PAGES;
// a `section` page names PRODUCT CODES and the renderer joins them to the live
// pool at paint time. Codes, never pids β€” a catalogue outlives any one fetch,
// and the code is the thing actually printed on the page.
//
// Fidelity target is `reference/Royal Imports/Royal Collection 2027.pdf`
// (rendered and read before this file was written): letterspaced caps section
// headers, product renders on white, caps description, a navy pill carrying the
// pack line, SKU in accent bold beside the colour name, a full-bleed lifestyle
// strip under the grid, page number + crown mark in the footer.
// ---------------------------------------------------------------------------

import { API_V1 } from "../apiContract";
import {
  CATALOG_CODE_MAX,
  MAX_CATALOG_CODES,
  MAX_CATALOG_PAGES,
  RECORD_IMAGE_PREFIX,
} from "./types";
import type {
  CatalogOrientation,
  CatalogPage,
  CatalogPageKind,
  CatalogPaper,
  CatalogQuality,
  CatalogSpec,
  Field,
  Row,
} from "./types";

// ----------------------------------------------------------------- the pool

/**
 * The column a catalog joins on. `product_data`'s business key
 * (`aios_grid_fields.json: business_key = "code"`) and ALSO the C2-ASSET key β€”
 * the 1,142 masters are named for `default_code`, so one string is both the
 * SKU printed on the page and the image that goes above it. That coincidence is
 * why the catalog binds to a named column instead of to the table's primary
 * field: on `customer_data` the primary field is a customer NAME, and joining
 * assets on it would ask the asset route for an image of a person.
 */
export const CATALOG_CODE_FIELD = "code";

/** Code β†’ row, over the WHOLE pool (not the filtered rows) so a catalogue keeps
 *  painting the products it names after the user narrows the view. */
export function indexByCode(rows: Row[]): Map<string, Row> {
  const out = new Map<string, Row>();
  for (const r of rows) {
    const code = r[CATALOG_CODE_FIELD];
    if (typeof code === "string" && code && !out.has(code)) out.set(code, r);
  }
  return out;
}

/**
 * The four listing lines, resolved for ONE product.
 *
 * `code` is always present (it is the identity); everything else is absent when
 * the catalog has not bound a column for it, or when the row carries no value.
 * ⚠ `pack` is the bound field's value VERBATIM β€” never composed from a number.
 * The reference prints "6-Piece per Pack" on p7 and "6-Sets Per Case" on p121,
 * so any composed rule is wrong on one of those pages.
 */
export interface CatalogItem {
  code: string;
  name?: string;
  pack?: string;
  color?: string;
  price?: string;
  /**
   * ⭐ Wave-19 R7 β€” WHICH PICTURE this item prints, resolved from the table's `image` column
   * with the code as the fallback. Always a non-empty string when `known`; equal to `code` on
   * every row nobody has overridden, which is why the ruling could keep the fallback and still
   * call the field authoritative.
   */
  image: string;
  /** False when the row is not in the pool at all β€” drawn as a stated gap. */
  known: boolean;
}

/** The table's picture column, if it has one. FIRST `image`-typed field: a table with two is
 *  ambiguous and picking the first is at least stable, where picking "none" would silently
 *  disable the feature for the user who added a second one. */
export function imageFieldKey(fieldByKey: Map<string, Field>): string | undefined {
  for (const [key, field] of fieldByKey) if (field.type === "image") return key;
  return undefined;
}

export function resolveItems(
  codes: string[],
  byCode: Map<string, Row>,
  binds: CatalogSpec["fields"] | undefined,
  format: (field: Field, value: Row[string]) => string,
  fieldByKey: Map<string, Field>
): CatalogItem[] {
  const line = (row: Row, key: string | undefined): string | undefined => {
    if (!key) return undefined;
    const field = fieldByKey.get(key);
    if (!field) return undefined;
    const text = format(field, row[key]);
    return text ? text : undefined;
  };
  const imageKey = imageFieldKey(fieldByKey);
  return codes.map((code) => {
    const row = byCode.get(code);
    // An unknown code still prints its own frame under its own name β€” the gap is STATED, and
    // stating it needs the code as the reference it would have used.
    if (!row) return { code, image: code, known: false };
    return {
      code,
      known: true,
      image: rowImageRef(row, imageKey) || code,
      name: line(row, binds?.name),
      pack: line(row, binds?.pack),
      color: line(row, binds?.color),
      price: line(row, binds?.price),
    };
  });
}

/**
 * The default column bindings for a NEW catalog, guessed from the table's own
 * fields. A guess, not a contract: every one is overridable in the designer, and
 * a wrong guess renders a wrong LINE rather than a wrong number.
 *
 * `name` deliberately skips the code column β€” printing the SKU twice is the one
 * outcome that always looks broken.
 */
export function autoBind(fields: Field[]): NonNullable<CatalogSpec["fields"]> {
  const out: NonNullable<CatalogSpec["fields"]> = {};
  const find = (test: RegExp, types?: string[]): string | undefined =>
    fields.find(
      (f) =>
        f.key !== CATALOG_CODE_FIELD &&
        (test.test(f.key) || test.test(f.label)) &&
        (!types || types.includes(f.type))
    )?.key;
  out.name =
    find(/^(product|name|title|description)$/i) ??
    fields.find((f) => f.key !== CATALOG_CODE_FIELD && f.type === "text")?.key;
  out.pack = find(/pack|case|carton|inner/i);
  out.color = find(/colou?r/i);
  out.price = find(/price|msrp|list/i, ["currency", "int", "pct"]);
  for (const k of ["name", "pack", "color", "price"] as const)
    if (!out[k]) delete out[k];
  return out;
}

// -------------------------------------------------------------- the images

/**
 * C2-ASSET, consumed as a SHAPE (the contract's client rule: tokens, not
 * values). `API_V1` is the app's own base β€” this file never names a host.
 *
 * R9: `web` is an ~800px alpha-preserving PNG (transparency matters β€” the 2027
 * listing sits product renders directly on white), `print` serves the ORIGINAL
 * master bytes. The response is `immutable`-cached, so swapping quality for an
 * export costs one fetch per image and nothing after that.
 */
/**
 * DEBT-5 (2026-08-04): EDITORIAL imagery β€” non-SKU, full-bleed lifestyle photos
 * for gallery pages. Addressed as `ed:<slug>` wherever a page stores an image
 * code: the colon is outside the product-code charset, so the two namespaces
 * cannot collide, and every existing catalog keeps meaning what it meant.
 */
export const EDITORIAL_PREFIX = "ed:";

export function editorialSlug(code: string): string | null {
  return code.startsWith(EDITORIAL_PREFIX)
    ? code.slice(EDITORIAL_PREFIX.length).trim().toLowerCase()
    : null;
}

export function assetUrl(code: string, quality: CatalogQuality): string {
  const slug = editorialSlug(code);
  if (slug !== null)
    return `${API_V1}/assets/editorial/${encodeURIComponent(slug)}?q=${quality}`;
  // ⭐ Wave-19 R7 / C5 β€” the THIRD namespace: a picture uploaded through an `image` field. The
  // tenant is NOT in the URL and never will be: the route takes it from the session, so a
  // reference copied out of one tenant's cell resolves to nothing in another's (a 404, not
  // somebody else's photograph).
  if (code.startsWith(RECORD_IMAGE_PREFIX)) {
    const id = code.slice(RECORD_IMAGE_PREFIX.length).trim();
    return `${API_V1}/assets/records/${encodeURIComponent(id)}?q=${quality}`;
  }
  return `${API_V1}/assets/products/${encodeURIComponent(code)}?q=${quality}`;
}

/**
 * ⭐ Wave-19 R7 β€” WHICH REFERENCE A ROW'S PICTURE IS, given the catalog's bound image column.
 *
 * The ruling is "catalog reads the field (code fallback kept)", and the fallback is the half
 * that matters: Royal's 1,142 masters are named for `default_code`, so a product nobody has
 * touched already names its own picture. Reading the field FIRST is what lets one SKU be
 * overridden β€” a lifestyle shot for the cover product, say β€” without uploading 1,141 others.
 *
 * `imageKey` absent (a catalog bound before this field existed, or a table with no image
 * column) collapses to exactly the old behaviour: the code IS the reference.
 */
export function rowImageRef(row: Row | undefined, imageKey?: string): string {
  if (!row) return "";
  if (imageKey) {
    const v = row[imageKey];
    if (typeof v === "string" && v.trim()) return v.trim();
  }
  const code = row[CATALOG_CODE_FIELD];
  return typeof code === "string" ? code.trim() : "";
}

/** C2-ASSET's manifest β€” which codes HAVE an image, so the designer can mark
 *  the ones that will print an empty frame instead of discovering it at print. */
export const ASSET_MANIFEST_URL = `${API_V1}/assets/products`;
export const EDITORIAL_MANIFEST_URL = `${API_V1}/assets/editorial`;

export async function fetchAssetCodes(): Promise<Set<string> | null> {
  try {
    const res = await fetch(ASSET_MANIFEST_URL, { credentials: "same-origin" });
    if (!res.ok) return null;
    const body = (await res.json()) as { codes?: unknown };
    if (!Array.isArray(body.codes)) return null;
    return new Set(body.codes.filter((c): c is string => typeof c === "string"));
  } catch {
    // A manifest we cannot reach means "we do not know", NOT "nothing exists".
    // Returning null keeps the designer silent instead of marking every product
    // missing β€” a false gap report would send the user hunting for images that
    // are sitting right there.
    return null;
  }
}

/** The editorial library, same null-means-unknown semantics as fetchAssetCodes. */
export async function fetchEditorialSlugs(): Promise<Set<string> | null> {
  try {
    const res = await fetch(EDITORIAL_MANIFEST_URL, { credentials: "same-origin" });
    if (!res.ok) return null;
    const body = (await res.json()) as { slugs?: unknown };
    if (!Array.isArray(body.slugs)) return null;
    return new Set(
      body.slugs.filter((s): s is string => typeof s === "string").map((s) => s.toLowerCase())
    );
  } catch {
    return null;
  }
}

/** Upload one editorial master (admin route). Resolves to the stored slug, or
 *  throws with the server's honest error message. */
export async function uploadEditorial(slug: string, file: Blob): Promise<string> {
  const data = await new Promise<string>((resolve, reject) => {
    const r = new FileReader();
    r.onload = () => resolve(String(r.result ?? ""));
    r.onerror = () => reject(new Error("could not read the file"));
    r.readAsDataURL(file);
  });
  const res = await fetch(EDITORIAL_MANIFEST_URL, {
    method: "POST",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ slug, data }),
  });
  const body = (await res.json().catch(() => ({}))) as {
    slug?: string;
    error?: { message?: string };
  };
  if (!res.ok) throw new Error(body.error?.message ?? `upload failed (${res.status})`);
  return body.slug ?? slug;
}

/**
 * ⭐ Wave-19 R7 / C5 β€” upload one RECORD image and get back the reference to store in the cell.
 *
 * Session-gated, not admin-gated: attaching a picture to your own row is the same class of act
 * as typing a note. The tenant is never sent β€” the server takes it from the session, which is
 * what makes a `rec:` reference meaningless outside the tenant that minted it.
 *
 * ⚠ The client checks the size FIRST purely to spare a doomed 2 MB round trip. The server
 * re-checks the DECODED bytes and re-parses the image; this is a courtesy, not the wall.
 */
export const RECORD_IMAGE_MAX_BYTES = 2 * 1024 * 1024;
export const RECORD_ASSET_URL = `${API_V1}/assets/records`;

export async function uploadRecordImage(file: Blob): Promise<string> {
  if (file.size > RECORD_IMAGE_MAX_BYTES)
    throw new Error(
      `Images cap at ${RECORD_IMAGE_MAX_BYTES / (1024 * 1024)} MB β€” that one is ` +
        `${(file.size / (1024 * 1024)).toFixed(1)} MB.`
    );
  const data = await new Promise<string>((resolve, reject) => {
    const r = new FileReader();
    r.onload = () => resolve(String(r.result ?? ""));
    r.onerror = () => reject(new Error("could not read the file"));
    r.readAsDataURL(file);
  });
  const res = await fetch(RECORD_ASSET_URL, {
    method: "POST",
    credentials: "same-origin",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ data }),
  });
  const body = (await res.json().catch(() => ({}))) as {
    ref?: string;
    error?: { message?: string };
  };
  if (!res.ok) throw new Error(body.error?.message ?? `upload failed (${res.status})`);
  if (!body.ref) throw new Error("the server accepted the image but named no reference");
  return body.ref;
}

/** A slug guessed from a filename: lowercased, non-alphanumerics folded to
 *  hyphens, trimmed β€” mirrors the server's charset so the guess is accepted. */
export function slugFromFilename(name: string): string {
  return name
    .replace(/\.[A-Za-z0-9]+$/, "")
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, "-")
    .replace(/^-+|-+$/g, "")
    .slice(0, 64);
}

// -------------------------------------------------------------- the paper

/**
 * Physical page sizes. Emitted into `@page { size: … }` as EXPLICIT dimensions
 * rather than the CSS keywords (`letter`, `A4`, `ledger`): the keyword set has
 * no unambiguous name for 11x17 β€” `ledger` and `tabloid` differ by rotation
 * depending on who you read β€” and an explicit pair is what the print gate can
 * actually assert against the PDF it gets back.
 */
export const PAPER_SIZES: Record<CatalogPaper, { w: number; h: number; unit: "in" | "mm" }> = {
  letter: { w: 8.5, h: 11, unit: "in" },
  a4: { w: 210, h: 297, unit: "mm" },
  tabloid: { w: 11, h: 17, unit: "in" },
};

export const PAPER_LABELS: Record<CatalogPaper, string> = {
  letter: 'Letter Β· 8.5 Γ— 11"',
  a4: "A4 Β· 210 Γ— 297 mm",
  tabloid: 'Tabloid Β· 11 Γ— 17"',
};

/** The page box, with orientation applied. Landscape SWAPS the pair β€” it does
 *  not rotate the content, which is what `@page`'s own keyword would do. */
export function pageBox(
  paper: CatalogPaper,
  orientation: CatalogOrientation
): { w: number; h: number; unit: "in" | "mm" } {
  const size = PAPER_SIZES[paper];
  return orientation === "landscape"
    ? { w: size.h, h: size.w, unit: size.unit }
    : { ...size };
}

/** `8.5in` / `297mm` β€” one place that decides how a dimension is spelled, so the
 *  screen box and the `@page` rule can never drift apart by a unit. */
export function cssLength(value: number, unit: "in" | "mm"): string {
  return `${Number.isInteger(value) ? value : value.toFixed(2).replace(/0+$/, "")}${unit}`;
}

// ------------------------------------------------------- catalogs & pages

/** Royal Imports' own palette, measured off the 2027 cover: deep navy field,
 *  terracotta accent. The defaults for a new catalog; every catalog can differ. */
export const DEFAULT_BRAND = {
  primary: "#16233A",
  accent: "#C47B5A",
  company: "Royal Imports",
} as const;

function mintId(prefix: string): string {
  const rand =
    typeof crypto !== "undefined" && "randomUUID" in crypto
      ? crypto.randomUUID().slice(0, 8)
      : Math.random().toString(36).slice(2, 10);
  return `${prefix}_${rand}`;
}

export const newCatalogId = (): string => mintId("cat");
export const newPageId = (): string => mintId("pg");

export const PAGE_KIND_LABELS: Record<CatalogPageKind, string> = {
  cover: "Cover",
  intro: "Introduction",
  section: "Product section",
  gallery: "Full-page image",
};

/** A fresh page of a kind, carrying the defaults its template reads best at. */
export function newPage(kind: CatalogPageKind): CatalogPage {
  const page: CatalogPage = { id: newPageId(), kind };
  if (kind === "cover") page.title = "Collection";
  if (kind === "intro") page.title = "About us";
  if (kind === "section") page.title = "New section";
  return page;
}

/**
 * A new catalog, opening on the reference's own running order: cover, letter,
 * one product section. Three pages rather than an empty list because an empty
 * designer teaches nothing β€” the user should see the artifact they are editing.
 */
export function newCatalog(name: string, fields: Field[]): CatalogSpec {
  const binds = autoBind(fields);
  const cat: CatalogSpec = {
    id: newCatalogId(),
    name,
    paper: "letter",
    orientation: "portrait",
    brand: { ...DEFAULT_BRAND },
    pages: [newPage("cover"), newPage("intro"), newPage("section")],
  };
  if (Object.keys(binds).length) cat.fields = binds;
  return cat;
}

/** How much of the 500-code allowance this catalog has spent. Shown in the
 *  designer BEFORE the cap bites, so a full catalogue is a fact the user can see
 *  rather than a silent truncation on the next save ([[no-unverifiable-aggregates]]). */
export function codesUsed(cat: CatalogSpec): number {
  let n = 0;
  for (const p of cat.pages) n += p.products?.length ?? 0;
  return n;
}

export function codesRemaining(cat: CatalogSpec): number {
  return Math.max(0, MAX_CATALOG_CODES - codesUsed(cat));
}

export function pagesRemaining(cat: CatalogSpec): number {
  return Math.max(0, MAX_CATALOG_PAGES - cat.pages.length);
}

/**
 * Add codes to a page, honouring BOTH caps and reporting what it refused.
 *
 * The report is the point: "added 40 of 60, the catalogue is full" is a fact the
 * user can act on. Silently keeping 40 is the defect this house calls a silent
 * `[:N]` cap.
 */
export function addCodes(
  cat: CatalogSpec,
  pageId: string,
  codes: string[]
): { pages: CatalogPage[]; added: number; skippedDuplicate: number; skippedCap: number } {
  const budget = codesRemaining(cat);
  let added = 0;
  let skippedDuplicate = 0;
  let skippedCap = 0;
  const pages = cat.pages.map((p) => {
    if (p.id !== pageId) return p;
    const have = new Set(p.products ?? []);
    const next = [...(p.products ?? [])];
    for (const raw of codes) {
      const code = raw.slice(0, CATALOG_CODE_MAX);
      if (!code) continue;
      if (have.has(code)) {
        skippedDuplicate += 1;
        continue;
      }
      if (added >= budget) {
        skippedCap += 1;
        continue;
      }
      have.add(code);
      next.push(code);
      added += 1;
    }
    return next.length ? { ...p, products: next } : p;
  });
  return { pages, added, skippedDuplicate, skippedCap };
}

/** The layout a section page renders at, defaults applied. The three toggles are
 *  stored literal-only (see `CatalogPage.layout`), so "absent" is where every
 *  default lives and this is the ONE place that knows what they are. */
export function sectionLayout(page: CatalogPage): {
  cols: number;
  showPack: boolean;
  showColor: boolean;
  showPrice: boolean;
} {
  const l = page.layout;
  return {
    cols: l?.cols ?? 3,
    showPack: l?.showPack !== false,
    showColor: l?.showColor !== false,
    showPrice: l?.showPrice === true,
  };
}

/**
 * The printed page NUMBER for each page, and the total.
 *
 * The cover is page 1 and carries no folio β€” exactly the reference, where the
 * first numeral to appear on paper is on the intro spread. So the number is the
 * INDEX, and whether it is drawn is the template's business.
 */
export function folio(pages: CatalogPage[]): { number: number; total: number }[] {
  return pages.map((_, i) => ({ number: i + 1, total: pages.length }));
}