@fideus-labs/ngff-zarr 0.18.1 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/esm/browser-mod.d.ts +1 -0
  2. package/esm/browser-mod.d.ts.map +1 -1
  3. package/esm/browser-mod.js +1 -0
  4. package/esm/browser-mod.js.map +1 -1
  5. package/esm/io/hcs.d.ts.map +1 -1
  6. package/esm/io/hcs.js +11 -3
  7. package/esm/io/hcs.js.map +1 -1
  8. package/esm/io/to_ngff_zarr-browser.d.ts +2 -0
  9. package/esm/io/to_ngff_zarr-browser.d.ts.map +1 -1
  10. package/esm/io/to_ngff_zarr-browser.js +5 -2
  11. package/esm/io/to_ngff_zarr-browser.js.map +1 -1
  12. package/esm/mod.d.ts +1 -0
  13. package/esm/mod.d.ts.map +1 -1
  14. package/esm/mod.js +1 -0
  15. package/esm/mod.js.map +1 -1
  16. package/esm/schemas/coordinate_systems.d.ts +2 -2
  17. package/esm/schemas/rfc4.d.ts +9 -9
  18. package/esm/schemas/rfc4.d.ts.map +1 -1
  19. package/esm/schemas/rfc4.js +6 -0
  20. package/esm/schemas/rfc4.js.map +1 -1
  21. package/esm/types/hcs.d.ts +7 -0
  22. package/esm/types/hcs.d.ts.map +1 -1
  23. package/esm/types/hcs.js +16 -3
  24. package/esm/types/hcs.js.map +1 -1
  25. package/esm/types/rfc4.d.ts +7 -1
  26. package/esm/types/rfc4.d.ts.map +1 -1
  27. package/esm/types/rfc4.js +12 -0
  28. package/esm/types/rfc4.js.map +1 -1
  29. package/esm/types/zarr_metadata.d.ts +8 -0
  30. package/esm/types/zarr_metadata.d.ts.map +1 -1
  31. package/esm/types/zarr_metadata.js.map +1 -1
  32. package/esm/utils/from_zarr_attrs.d.ts.map +1 -1
  33. package/esm/utils/from_zarr_attrs.js +97 -3
  34. package/esm/utils/from_zarr_attrs.js.map +1 -1
  35. package/esm/utils/py_format.d.ts +18 -0
  36. package/esm/utils/py_format.d.ts.map +1 -0
  37. package/esm/utils/py_format.js +22 -0
  38. package/esm/utils/py_format.js.map +1 -0
  39. package/esm/utils/rfc4_validation.d.ts +8 -0
  40. package/esm/utils/rfc4_validation.d.ts.map +1 -1
  41. package/esm/utils/rfc4_validation.js +19 -2
  42. package/esm/utils/rfc4_validation.js.map +1 -1
  43. package/esm/utils/structural_validation.d.ts +425 -0
  44. package/esm/utils/structural_validation.d.ts.map +1 -0
  45. package/esm/utils/structural_validation.js +808 -0
  46. package/esm/utils/structural_validation.js.map +1 -0
  47. package/package.json +1 -1
@@ -0,0 +1,808 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) Fideus Labs LLC
2
+ // SPDX-License-Identifier: MIT
3
+ /**
4
+ * Structural validation for OME-Zarr v0.4 image/multiscales metadata.
5
+ *
6
+ * Where the Zod schema validation in {@link validateMetadata} answers "is this
7
+ * shaped like OME-Zarr?", the rules in this module answer "does this obey the
8
+ * specification's structural invariants?" -- axis counts, axis ordering,
9
+ * coordinate-transformation arity, finest-to-coarsest dataset ordering, and
10
+ * OMERO channel color format. These are spec MUSTs that a JSON Schema cannot
11
+ * express.
12
+ *
13
+ * Each rule is identified by a stable, kebab-case {@link SpecRule} value that is
14
+ * part of the observable surface: the identifiers, their evaluation order, and
15
+ * the dotted-segment location strings are kept byte-for-byte identical to the
16
+ * package's Python implementation so the two stay in lockstep (a property the
17
+ * cross-language parity tests assert). Rules fail fast -- the orchestrator
18
+ * throws a {@link ValidationError} on the first violation, in canonical
19
+ * specification order.
20
+ *
21
+ * This module provides the image/multiscales rule surface -- including the
22
+ * RFC 4 anatomical-orientation checks -- plus the HCS plate/well rules, which
23
+ * operate on separate metadata objects and are dispatched by the companion
24
+ * {@link validatePlate} and {@link validateWell} entry points.
25
+ */
26
+ import { validateColor, } from "../types/zarr_metadata.js";
27
+ import { hasRfc4OrientationMetadata, validateRfc4Orientation, } from "./rfc4_validation.js";
28
+ import { formatNameList } from "./py_format.js";
29
+ /**
30
+ * Stable, kebab-case identifiers for the structural specification rules.
31
+ *
32
+ * The string values are the observable surface -- they appear in
33
+ * {@link ValidationError.rule}, logs, and tests -- and must match the package's
34
+ * Python implementation exactly. Members are listed in canonical
35
+ * specification-MUST evaluation order.
36
+ */
37
+ export const SpecRule = {
38
+ /** Axis count within the v0.4 range of 2..5 inclusive. */
39
+ AxisCount: "axis-count",
40
+ /** At most one `time` axis and at most one `channel` axis. */
41
+ AxisType: "axis-type",
42
+ /** time before channel before space; spatial names suffix (z, y, x). */
43
+ AxisOrder: "axis-order",
44
+ /** Every scale/translation vector length equals the axis count. */
45
+ ScaleLengthMismatch: "scale-length-mismatch",
46
+ /** Exactly one scale per dataset; a translation must follow it. */
47
+ GlobalCoordTransformAfterPerLevel: "global-coord-transform-after-per-level",
48
+ /** Datasets ordered finest to coarsest; spatial scale must not shrink. */
49
+ DatasetOrderHighestToLowest: "dataset-order-highest-to-lowest",
50
+ /** Each OMERO channel color is exactly six hexadecimal digits. */
51
+ OmeroChannelColorFormat: "omero-channel-color-format",
52
+ /** All spatial-axis RFC 4 orientations share one `type`. */
53
+ AxisOrientationConsistentType: "axis-orientation-consistent-type",
54
+ /** If any spatial axis declares an orientation, all spatial axes do. */
55
+ AxisOrientationCompleteness: "axis-orientation-completeness",
56
+ /** A v0.5 entry implies a Zarr v3 store: a leaked `zarr_format` must be 3. */
57
+ ZarrFormat: "zarr-format",
58
+ /** A v0.5 entry must not retain a group-level `ome`/`multiscales` wrapper. */
59
+ OmeNamespace: "ome-namespace",
60
+ /** Each well's rowIndex/columnIndex agrees with the row/column in its path. */
61
+ PlateRowIndexConsistency: "plate-row-index-consistency",
62
+ /** Each well image references an acquisition when the plate declares many. */
63
+ WellAcquisitionMissing: "well-acquisition-missing",
64
+ };
65
+ /**
66
+ * How much validation {@link validateStructural} performs.
67
+ *
68
+ * `"strict"` is the default and runs every structural image/multiscales rule.
69
+ * `"schema_only"` skips them -- schema validation is the separate concern of
70
+ * {@link validateMetadata}, which checks the raw attributes via Zod.
71
+ */
72
+ export const ValidationLevel = {
73
+ /** Skip structural rules; rely on Zod schema validation only. */
74
+ SchemaOnly: "schema_only",
75
+ /** Run every structural image/multiscales rule (the default). */
76
+ Strict: "strict",
77
+ };
78
+ /**
79
+ * Raised when a structural specification rule is violated.
80
+ *
81
+ * Carries the offending {@link SpecRule} and an optional `location` identifying
82
+ * the offending metadata node in dotted-segment (JSON-Pointer-style) form, e.g.
83
+ * `multiscales[0].datasets[2].coordinateTransformations[0]`. The `message` is
84
+ * formatted as `Spec rule [<rule>] violated: <message>`.
85
+ */
86
+ export class ValidationError extends Error {
87
+ /** The structural rule that was violated. */
88
+ rule;
89
+ /** Dotted-segment location of the offending metadata node, if known. */
90
+ location;
91
+ constructor(rule, message, location) {
92
+ super(`Spec rule [${rule}] violated: ${message}`);
93
+ this.name = "ValidationError";
94
+ this.rule = rule;
95
+ if (location !== undefined) {
96
+ this.location = location;
97
+ }
98
+ }
99
+ }
100
+ /**
101
+ * Check one coordinate transform's vector length against the axis count.
102
+ *
103
+ * Centralizes the length invariant shared by the global and per-dataset
104
+ * coordinate-transformation checks (see {@link SpecRule.ScaleLengthMismatch}),
105
+ * so the rule lives in one place. Transforms that carry no length-bearing
106
+ * vector are ignored.
107
+ *
108
+ * @param transform - The coordinate transform to inspect.
109
+ * @param axesLen - The expected vector length (the axis count).
110
+ * @returns `["scale", actual]` or `["translation", actual]` when the
111
+ * transform's vector length disagrees with `axesLen`; otherwise `null`.
112
+ */
113
+ function transformLenMismatch(transform, axesLen) {
114
+ if (transform.type === "scale") {
115
+ const actual = transform.scale.length;
116
+ if (actual !== axesLen) {
117
+ return ["scale", actual];
118
+ }
119
+ }
120
+ else if (transform.type === "translation") {
121
+ const actual = transform.translation.length;
122
+ if (actual !== axesLen) {
123
+ return ["translation", actual];
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+ /**
129
+ * Return the first `scale` vector in a dataset, or `null` if none is present.
130
+ *
131
+ * Lets {@link validateDatasetOrder} compare adjacent multiscale levels without
132
+ * re-deriving the per-dataset `scale` lookup.
133
+ *
134
+ * @param dataset - The dataset whose coordinate transforms are scanned.
135
+ * @returns The first `scale` transform's vector, or `null` when the dataset
136
+ * declares no `scale` transform.
137
+ */
138
+ function firstScaleVector(dataset) {
139
+ for (const transform of dataset.coordinateTransformations) {
140
+ if (transform.type === "scale") {
141
+ return transform.scale;
142
+ }
143
+ }
144
+ return null;
145
+ }
146
+ /**
147
+ * Class-ordering rank for axis types: a `time` axis must precede a `channel`
148
+ * axis, which must precede any `space` axis. Lower rank must never follow
149
+ * higher rank (see {@link validateAxisOrder}). Axis types absent from this map
150
+ * (e.g. `array`) carry no rank, so a pair involving one is skipped -- mirroring
151
+ * the Python implementation's `dict.get(...) is None` handling.
152
+ */
153
+ const AXIS_TYPE_RANK = {
154
+ time: 0,
155
+ channel: 1,
156
+ space: 2,
157
+ };
158
+ /**
159
+ * Spatial axis names in canonical order. A valid set of `space` axis names is
160
+ * the matching-length suffix of this tuple: 1 -> `["x"]`, 2 -> `["y", "x"]`,
161
+ * 3 -> `["z", "y", "x"]` (see {@link validateSpatialAxisOrder}).
162
+ */
163
+ const SPATIAL_AXIS_NAMES = ["z", "y", "x"];
164
+ /**
165
+ * Render a number the way Python's `str()`/f-string renders a `float`: a
166
+ * whole-number value keeps a trailing `.0` (e.g. `2` -> `"2.0"`), matching the
167
+ * `list[float]` scale vectors the Python port interpolates into the
168
+ * dataset-order message. Non-integer scale ratios (e.g. `1.5`) already render
169
+ * identically across both languages, so they pass through unchanged. This keeps
170
+ * the message byte-for-byte identical to Python for the whole-number
171
+ * downsampling ratios OME-Zarr writers (including this package) emit to disk.
172
+ */
173
+ function pyFloat(value) {
174
+ return Number.isInteger(value) ? `${value}.0` : `${value}`;
175
+ }
176
+ /**
177
+ * Validate that the axis count is within the v0.4-permitted range.
178
+ *
179
+ * OME-Zarr v0.4 requires between 2 and 5 axes, inclusive.
180
+ *
181
+ * @param metadata - The parsed multiscales metadata to validate.
182
+ * @throws {ValidationError} With {@link SpecRule.AxisCount} when
183
+ * `metadata.axes.length` lies outside `2..5`; location `multiscales[0].axes`.
184
+ */
185
+ export function validateAxisCount(metadata) {
186
+ const count = metadata.axes.length;
187
+ if (count < 2 || count > 5) {
188
+ throw new ValidationError(SpecRule.AxisCount, `OME-Zarr v0.4 requires between 2 and 5 axes, inclusive; found ${count}.`, "multiscales[0].axes");
189
+ }
190
+ }
191
+ /**
192
+ * Validate axis-type multiplicity.
193
+ *
194
+ * At most one `time` axis and at most one `channel` axis may be present.
195
+ *
196
+ * @param metadata - The parsed multiscales metadata to validate.
197
+ * @throws {ValidationError} With {@link SpecRule.AxisType} when more than one
198
+ * `time` axis or more than one `channel` axis is present; location
199
+ * `multiscales[0].axes`.
200
+ */
201
+ export function validateAxisType(metadata) {
202
+ const timeCount = metadata.axes.filter((ax) => ax.type === "time").length;
203
+ if (timeCount > 1) {
204
+ throw new ValidationError(SpecRule.AxisType, `At most one 'time' axis is permitted; found ${timeCount}.`, "multiscales[0].axes");
205
+ }
206
+ const channelCount = metadata.axes.filter((ax) => ax.type === "channel").length;
207
+ if (channelCount > 1) {
208
+ throw new ValidationError(SpecRule.AxisType, `At most one 'channel' axis is permitted; found ${channelCount}.`, "multiscales[0].axes");
209
+ }
210
+ }
211
+ /**
212
+ * Validate the class ordering of axes.
213
+ *
214
+ * Axes are ranked by type (`time` < `channel` < `space`) and must be listed in
215
+ * non-decreasing rank order: every `time` axis precedes every `channel` axis,
216
+ * which precedes every `space` axis. The first adjacent pair that inverts this
217
+ * ranking is reported. Pairs involving an axis type with no rank (e.g. `array`)
218
+ * are skipped.
219
+ *
220
+ * @param metadata - The parsed multiscales metadata to validate.
221
+ * @throws {ValidationError} With {@link SpecRule.AxisOrder} for the first
222
+ * adjacent pair where a lower-ranked axis type follows a higher-ranked one;
223
+ * location `multiscales[0].axes[i+1]`.
224
+ */
225
+ export function validateAxisOrder(metadata) {
226
+ const axes = metadata.axes;
227
+ for (let i = 0; i < axes.length - 1; i++) {
228
+ const current = axes[i];
229
+ const following = axes[i + 1];
230
+ const currentRank = AXIS_TYPE_RANK[current.type];
231
+ const followingRank = AXIS_TYPE_RANK[following.type];
232
+ if (currentRank === undefined || followingRank === undefined) {
233
+ continue;
234
+ }
235
+ if (followingRank < currentRank) {
236
+ throw new ValidationError(SpecRule.AxisOrder, `Axis '${following.name}' of type '${following.type}' must not ` +
237
+ `follow axis '${current.name}' of type '${current.type}'; axes ` +
238
+ `must be ordered time, then channel, then space.`, `multiscales[0].axes[${i + 1}]`);
239
+ }
240
+ }
241
+ }
242
+ /**
243
+ * Validate the count and names of spatial axes.
244
+ *
245
+ * The `space` axes, taken in order, must be the matching-length suffix of
246
+ * `(z, y, x)`: one spatial axis must be `(x)`, two must be `(y, x)`, and three
247
+ * must be `(z, y, x)`. At most three `space` axes are permitted.
248
+ *
249
+ * @param metadata - The parsed multiscales metadata to validate.
250
+ * @throws {ValidationError} With {@link SpecRule.AxisOrder} when there are more
251
+ * than three `space` axes, or when their names are not the expected suffix of
252
+ * `(z, y, x)`; location `multiscales[0].axes` or the first offending axis.
253
+ */
254
+ export function validateSpatialAxisOrder(metadata) {
255
+ const spaceIndices = [];
256
+ metadata.axes.forEach((ax, i) => {
257
+ if (ax.type === "space") {
258
+ spaceIndices.push(i);
259
+ }
260
+ });
261
+ const count = spaceIndices.length;
262
+ if (count > 3) {
263
+ throw new ValidationError(SpecRule.AxisOrder, `OME-Zarr v0.4 permits at most 3 'space' axes; found ${count}.`, "multiscales[0].axes");
264
+ }
265
+ const expected = SPATIAL_AXIS_NAMES.slice(SPATIAL_AXIS_NAMES.length - count);
266
+ const actual = spaceIndices.map((i) => metadata.axes[i].name);
267
+ const mismatch = actual.findIndex((name, j) => name !== expected[j]);
268
+ if (mismatch !== -1) {
269
+ throw new ValidationError(SpecRule.AxisOrder, `Spatial axis names ${formatNameList(actual)} must be the ` +
270
+ `length-${count} suffix of (z, y, x): ${formatNameList(expected)}.`, `multiscales[0].axes[${spaceIndices[mismatch]}]`);
271
+ }
272
+ }
273
+ /**
274
+ * Validate that each dataset defines exactly one `scale` transform.
275
+ *
276
+ * Every dataset's `coordinateTransformations` must contain exactly one `scale`
277
+ * (the per-level voxel size). This shares the per-dataset
278
+ * coordinate-transform-shape rule identifier
279
+ * ({@link SpecRule.GlobalCoordTransformAfterPerLevel}); there is no dedicated
280
+ * identifier for the scale count.
281
+ *
282
+ * @param metadata - The parsed multiscales metadata to validate.
283
+ * @throws {ValidationError} With
284
+ * {@link SpecRule.GlobalCoordTransformAfterPerLevel} when a dataset has zero or
285
+ * more than one `scale` transform; location
286
+ * `multiscales[0].datasets[i].coordinateTransformations`.
287
+ */
288
+ export function validatePerDatasetScaleCount(metadata) {
289
+ for (let i = 0; i < metadata.datasets.length; i++) {
290
+ const dataset = metadata.datasets[i];
291
+ const scaleCount = dataset.coordinateTransformations.filter((t) => t.type === "scale").length;
292
+ if (scaleCount !== 1) {
293
+ throw new ValidationError(SpecRule.GlobalCoordTransformAfterPerLevel, `Each dataset must define exactly one 'scale' coordinate ` +
294
+ `transformation; dataset ${i} has ${scaleCount}.`, `multiscales[0].datasets[${i}].coordinateTransformations`);
295
+ }
296
+ }
297
+ }
298
+ /**
299
+ * Validate coordinate-transform vector lengths against the axis count.
300
+ *
301
+ * Every `scale` and `translation` vector -- in the global
302
+ * `coordinateTransformations` (if present) and in each dataset -- must have
303
+ * exactly one entry per axis. The first mismatch, scanned
304
+ * global-then-per-dataset, is reported. The length invariant itself lives in
305
+ * {@link transformLenMismatch}.
306
+ *
307
+ * @param metadata - The parsed multiscales metadata to validate.
308
+ * @throws {ValidationError} With {@link SpecRule.ScaleLengthMismatch} for the
309
+ * first transform whose vector length disagrees with `metadata.axes.length`;
310
+ * location identifies the offending transform.
311
+ */
312
+ export function validateScaleLength(metadata) {
313
+ const axesLen = metadata.axes.length;
314
+ const globalTransforms = metadata.coordinateTransformations;
315
+ if (globalTransforms) {
316
+ for (let j = 0; j < globalTransforms.length; j++) {
317
+ const mismatch = transformLenMismatch(globalTransforms[j], axesLen);
318
+ if (mismatch !== null) {
319
+ const [kind, actualLen] = mismatch;
320
+ throw new ValidationError(SpecRule.ScaleLengthMismatch, `Global '${kind}' transform has length ${actualLen}, but ` +
321
+ `there are ${axesLen} axes; lengths must match.`, `multiscales[0].coordinateTransformations[${j}]`);
322
+ }
323
+ }
324
+ }
325
+ for (let i = 0; i < metadata.datasets.length; i++) {
326
+ const dataset = metadata.datasets[i];
327
+ for (let j = 0; j < dataset.coordinateTransformations.length; j++) {
328
+ const mismatch = transformLenMismatch(dataset.coordinateTransformations[j], axesLen);
329
+ if (mismatch !== null) {
330
+ const [kind, actualLen] = mismatch;
331
+ throw new ValidationError(SpecRule.ScaleLengthMismatch, `Dataset ${i} '${kind}' transform has length ${actualLen}, ` +
332
+ `but there are ${axesLen} axes; lengths must match.`, `multiscales[0].datasets[${i}].coordinateTransformations[${j}]`);
333
+ }
334
+ }
335
+ }
336
+ }
337
+ /**
338
+ * Validate coordinate-transform ordering within each dataset.
339
+ *
340
+ * Within a dataset's `coordinateTransformations`, a `translation` (if present)
341
+ * must follow its `scale` -- a `scale` must never appear after a `translation`.
342
+ *
343
+ * @param metadata - The parsed multiscales metadata to validate.
344
+ * @throws {ValidationError} With
345
+ * {@link SpecRule.GlobalCoordTransformAfterPerLevel} for the first dataset
346
+ * where a `scale` follows a `translation`; location identifies the offending
347
+ * `scale`.
348
+ */
349
+ export function validateTransformOrder(metadata) {
350
+ for (let i = 0; i < metadata.datasets.length; i++) {
351
+ const dataset = metadata.datasets[i];
352
+ let seenTranslation = false;
353
+ for (let j = 0; j < dataset.coordinateTransformations.length; j++) {
354
+ const transform = dataset.coordinateTransformations[j];
355
+ if (transform.type === "translation") {
356
+ seenTranslation = true;
357
+ }
358
+ else if (transform.type === "scale" && seenTranslation) {
359
+ throw new ValidationError(SpecRule.GlobalCoordTransformAfterPerLevel, `In dataset ${i}, a 'scale' transform must not follow a ` +
360
+ `'translation'; a translation must follow its scale.`, `multiscales[0].datasets[${i}].coordinateTransformations[${j}]`);
361
+ }
362
+ }
363
+ }
364
+ }
365
+ /**
366
+ * Validate that datasets are ordered finest to coarsest.
367
+ *
368
+ * Multiscale datasets must be listed from highest resolution (finest, i.e.
369
+ * smallest spatial `scale`) to lowest (coarsest). For each adjacent pair, the
370
+ * later level's `scale` must not be smaller than the earlier level's on any
371
+ * `space` axis. Pairs whose `scale` vectors are missing or too short to index a
372
+ * spatial axis are skipped -- those shapes are the concern of
373
+ * {@link validatePerDatasetScaleCount} and {@link validateScaleLength} -- so
374
+ * this rule never indexes out of bounds.
375
+ *
376
+ * @param metadata - The parsed multiscales metadata to validate.
377
+ * @throws {ValidationError} With {@link SpecRule.DatasetOrderHighestToLowest}
378
+ * for the first adjacent pair whose later (coarser) level has a smaller spatial
379
+ * scale; location `multiscales[0].datasets[i+1]`.
380
+ */
381
+ export function validateDatasetOrder(metadata) {
382
+ const spaceIndices = [];
383
+ metadata.axes.forEach((ax, i) => {
384
+ if (ax.type === "space") {
385
+ spaceIndices.push(i);
386
+ }
387
+ });
388
+ const datasets = metadata.datasets;
389
+ for (let i = 0; i < datasets.length - 1; i++) {
390
+ const finer = firstScaleVector(datasets[i]);
391
+ const coarser = firstScaleVector(datasets[i + 1]);
392
+ if (finer === null || coarser === null) {
393
+ continue;
394
+ }
395
+ for (const axisIndex of spaceIndices) {
396
+ if (axisIndex >= finer.length || axisIndex >= coarser.length) {
397
+ // Scale too short to compare this axis: another rule's concern.
398
+ // Skip so we never index out of bounds here.
399
+ continue;
400
+ }
401
+ if (coarser[axisIndex] < finer[axisIndex]) {
402
+ const axisName = metadata.axes[axisIndex].name;
403
+ throw new ValidationError(SpecRule.DatasetOrderHighestToLowest, `Datasets must be ordered finest to coarsest; dataset ` +
404
+ `${i + 1} has spatial scale ${pyFloat(coarser[axisIndex])} ` +
405
+ `on axis '${axisName}', smaller than dataset ${i}'s ` +
406
+ `${pyFloat(finer[axisIndex])}.`, `multiscales[0].datasets[${i + 1}]`);
407
+ }
408
+ }
409
+ }
410
+ }
411
+ /**
412
+ * Validate the color format of each OMERO channel.
413
+ *
414
+ * When OMERO rendering metadata is present, every channel `color` must be
415
+ * exactly six hexadecimal digits (RGB). This reuses the package's existing
416
+ * {@link validateColor} predicate rather than re-implementing the format check,
417
+ * surfacing its failure message through the unified {@link ValidationError}
418
+ * channel.
419
+ *
420
+ * @param metadata - The parsed multiscales metadata to validate.
421
+ * @throws {ValidationError} With {@link SpecRule.OmeroChannelColorFormat} for
422
+ * the first channel whose `color` is not six hex digits; location
423
+ * `multiscales[0].omero.channels[i].color`.
424
+ */
425
+ export function validateOmeroColorHex(metadata) {
426
+ const omero = metadata.omero;
427
+ if (omero === undefined) {
428
+ return;
429
+ }
430
+ for (let i = 0; i < omero.channels.length; i++) {
431
+ try {
432
+ validateColor(omero.channels[i].color);
433
+ }
434
+ catch (error) {
435
+ throw new ValidationError(SpecRule.OmeroChannelColorFormat, error instanceof Error ? error.message : String(error), `multiscales[0].omero.channels[${i}].color`);
436
+ }
437
+ }
438
+ }
439
+ /**
440
+ * Render a parsed {@link Axis} back to the record form the RFC 4 helpers
441
+ * consume.
442
+ *
443
+ * Emits only the fields the RFC 4 helpers inspect -- `name`, `type`, and (for a
444
+ * spatial axis that declares it) `orientation` as a `{ type, value }` record. A
445
+ * stored `orientation` may be the serialized {@link AxisOrientation} form or
446
+ * an {@link AnatomicalOrientation}; both already expose a string `type` and
447
+ * `value`, so the record is built directly without further coercion.
448
+ *
449
+ * @param axis - The parsed axis to render.
450
+ * @returns A plain record carrying `name`, `type`, and, when present,
451
+ * `orientation`.
452
+ */
453
+ function axisToValidationRecord(axis) {
454
+ const record = {
455
+ name: axis.name,
456
+ type: axis.type,
457
+ };
458
+ const orientation = axis.orientation;
459
+ if (orientation !== undefined && orientation !== null) {
460
+ record.orientation = {
461
+ type: orientation.type,
462
+ value: orientation.value,
463
+ };
464
+ }
465
+ return record;
466
+ }
467
+ /**
468
+ * Validate RFC 4 anatomical-orientation metadata on the spatial axes.
469
+ *
470
+ * This rule does not reimplement RFC 4; it wraps the package's existing
471
+ * logic -- {@link hasRfc4OrientationMetadata} and
472
+ * {@link validateRfc4Orientation} -- and surfaces its failures through the
473
+ * unified {@link ValidationError} channel. The parsed {@link Axis} objects are
474
+ * first rendered back to the record form those helpers expect (see
475
+ * {@link axisToValidationRecord}).
476
+ *
477
+ * Orientation is optional in RFC 4, so when no spatial axis carries it the rule
478
+ * is a no-op.
479
+ *
480
+ * @param metadata - The parsed multiscales metadata to validate.
481
+ * @throws {ValidationError} With {@link SpecRule.AxisOrientationConsistentType}
482
+ * when the spatial axes' orientations do not all share one `type`, or
483
+ * {@link SpecRule.AxisOrientationCompleteness} when orientation is defined for
484
+ * some but not all spatial axes; location `multiscales[0].axes`. The original
485
+ * RFC 4 message text is preserved. Any other failure from
486
+ * {@link validateRfc4Orientation} -- e.g. an orientation value outside the
487
+ * RFC 4 vocabulary, a schema-level concern with no dedicated structural rule
488
+ * -- propagates unchanged.
489
+ */
490
+ export function validateAxisOrientation(metadata) {
491
+ const axesRecords = metadata.axes.map(axisToValidationRecord);
492
+ if (!hasRfc4OrientationMetadata(axesRecords)) {
493
+ return;
494
+ }
495
+ try {
496
+ validateRfc4Orientation(axesRecords);
497
+ }
498
+ catch (error) {
499
+ // validateRfc4Orientation throws exactly two messages this rule maps: a
500
+ // spatial-orientation "same type" mismatch and the all-or-none completeness
501
+ // failure. An out-of-vocabulary orientation value -- a schema-level concern
502
+ // with no dedicated structural rule -- carries neither marker and so
503
+ // propagates unchanged, matching the package's Python implementation.
504
+ //
505
+ // The "same type" / "all spatial axes" substrings below are a load-bearing
506
+ // contract with validateRfc4Orientation's message text: an unrecognized
507
+ // error falls through to the final `throw` and surfaces as a plain Error
508
+ // rather than a ValidationError. The message-stability test in
509
+ // structural_validation_orientation_test.ts pins both markers so editing
510
+ // that wording fails CI loudly instead of silently breaking this mapping.
511
+ const message = error instanceof Error ? error.message : String(error);
512
+ if (message.includes("same type")) {
513
+ throw new ValidationError(SpecRule.AxisOrientationConsistentType, message, "multiscales[0].axes");
514
+ }
515
+ if (message.includes("all spatial axes")) {
516
+ throw new ValidationError(SpecRule.AxisOrientationCompleteness, message, "multiscales[0].axes");
517
+ }
518
+ throw error;
519
+ }
520
+ }
521
+ /**
522
+ * Render a string as Python's `repr()` renders it, for byte-identical messages.
523
+ *
524
+ * The plate/well rule messages interpolate row, column, and well-path strings
525
+ * the way the Python implementation does with `{value!r}`: a single-quoted
526
+ * literal, switching to double quotes only when the value contains a single
527
+ * quote but no double quote. Keeping this byte-for-byte identical to Python is a
528
+ * property the cross-language parity tests assert. Inputs reaching these rules
529
+ * are alphanumeric path segments, so this resolves to a plain single-quoted
530
+ * form in practice.
531
+ */
532
+ function pyRepr(value) {
533
+ const quote = value.includes("'") && !value.includes('"') ? '"' : "'";
534
+ const escaped = value.replaceAll("\\", "\\\\").replaceAll(quote, `\\${quote}`);
535
+ return `${quote}${escaped}${quote}`;
536
+ }
537
+ /**
538
+ * Validate that a v0.5 multiscales entry implies a Zarr v3 store.
539
+ *
540
+ * OME-Zarr v0.5 metadata MUST be backed by a Zarr v3 store
541
+ * (`zarr_format === 3`). A v0.4 dataset is always Zarr v2 and carries no
542
+ * `zarr_format`, so this rule is inert below v0.5. The parsed {@link Metadata}
543
+ * does not surface the group's `zarr_format` byte -- its authoritative on-disk
544
+ * verification is a store-backed concern -- but a `zarr_format` mis-embedded
545
+ * *inside* the multiscale entry (captured in {@link Metadata.extra}) is
546
+ * reachable here: it belongs on the enclosing Zarr v3 group, never on the entry,
547
+ * and any value other than `3` is incompatible with v0.5.
548
+ *
549
+ * @param metadata - The parsed multiscales metadata to validate.
550
+ * @throws {ValidationError} With {@link SpecRule.ZarrFormat} when v0.5 metadata
551
+ * carries a `zarr_format` other than `3` in its `extra` passthrough; location
552
+ * `multiscales[0]`.
553
+ */
554
+ export function validateZarrFormatForVersion(metadata) {
555
+ if (metadata.version !== "0.5") {
556
+ return;
557
+ }
558
+ const zarrFormat = metadata.extra?.zarr_format;
559
+ if (zarrFormat !== undefined && zarrFormat !== 3) {
560
+ throw new ValidationError(SpecRule.ZarrFormat, `OME-Zarr v0.5 requires a Zarr v3 store (zarr_format == 3), but ` +
561
+ `the entry declares zarr_format = ${String(zarrFormat)}.`, "multiscales[0]");
562
+ }
563
+ }
564
+ /**
565
+ * Validate that a v0.5 multiscales entry is not double-namespaced.
566
+ *
567
+ * At v0.5 the multiscales metadata lives under the top-level `ome` namespace key
568
+ * of the group's `zarr.json` attributes, with the spec `version` hoisted to
569
+ * `ome.version`. The reader unwraps that namespace into this {@link Metadata}, so
570
+ * the group-level wrapper keys -- `ome` itself and the `multiscales` array --
571
+ * must never reappear *inside* a multiscale entry. A surviving wrapper key
572
+ * (captured in {@link Metadata.extra}) means the document is double-namespaced,
573
+ * was not unwrapped, or is otherwise malformed with respect to the v0.5 `ome`
574
+ * namespacing. A v0.4 store keeps multiscales at the `.zattrs` top level with no
575
+ * `ome` wrapper, so this rule is inert below v0.5.
576
+ *
577
+ * @param metadata - The parsed multiscales metadata to validate.
578
+ * @throws {ValidationError} With {@link SpecRule.OmeNamespace} when v0.5 metadata
579
+ * retains a group-level `ome` or `multiscales` key in its `extra` passthrough;
580
+ * location `multiscales[0]`.
581
+ */
582
+ export function validateOmeNamespace(metadata) {
583
+ if (metadata.version !== "0.5") {
584
+ return;
585
+ }
586
+ const extra = metadata.extra ?? {};
587
+ for (const wrapperKey of ["ome", "multiscales"]) {
588
+ if (wrapperKey in extra) {
589
+ throw new ValidationError(SpecRule.OmeNamespace, `v0.5 metadata entry retains the group-level '${wrapperKey}' ` +
590
+ `key; the 'ome' namespace wraps the group attributes, not each ` +
591
+ `multiscale entry.`, "multiscales[0]");
592
+ }
593
+ }
594
+ }
595
+ /**
596
+ * Validate each plate well's recorded indices against its `path`.
597
+ *
598
+ * Every `PlateWell` records a `path` of the form `"<row>/<column>"` (e.g.
599
+ * `"B/03"`) alongside numeric `rowIndex` and `columnIndex` fields. OME-Zarr v0.4
600
+ * requires these to agree: the row segment must name an entry in `plate.rows`
601
+ * whose position equals `rowIndex`, and the column segment must name an entry in
602
+ * `plate.columns` whose position equals `columnIndex`. The first offending well,
603
+ * scanned in order, is reported.
604
+ *
605
+ * @param plate - The plate metadata whose wells are validated.
606
+ * @throws {ValidationError} With {@link SpecRule.PlateRowIndexConsistency} for
607
+ * the first well whose `path` is not `"<row>/<column>"`, whose row/column
608
+ * segment is absent from `plate.rows`/`plate.columns`, or whose recorded
609
+ * `rowIndex`/`columnIndex` disagrees with that segment's position; location
610
+ * `plate.wells[i]`.
611
+ */
612
+ export function validatePlateWellIndexConsistency(plate) {
613
+ const rowIndexByName = new Map();
614
+ plate.rows.forEach((row, i) => rowIndexByName.set(row.name, i));
615
+ const columnIndexByName = new Map();
616
+ plate.columns.forEach((column, i) => columnIndexByName.set(column.name, i));
617
+ for (let i = 0; i < plate.wells.length; i++) {
618
+ const well = plate.wells[i];
619
+ const location = `plate.wells[${i}]`;
620
+ const parts = well.path.split("/");
621
+ if (parts.length !== 2) {
622
+ throw new ValidationError(SpecRule.PlateRowIndexConsistency, `Well path ${pyRepr(well.path)} must have the form '<row>/<column>'.`, location);
623
+ }
624
+ const [rowName, columnName] = parts;
625
+ const expectedRow = rowIndexByName.get(rowName);
626
+ if (expectedRow === undefined) {
627
+ throw new ValidationError(SpecRule.PlateRowIndexConsistency, `Well path ${pyRepr(well.path)} names row ${pyRepr(rowName)}, which ` +
628
+ `is not declared in plate.rows.`, location);
629
+ }
630
+ const expectedColumn = columnIndexByName.get(columnName);
631
+ if (expectedColumn === undefined) {
632
+ throw new ValidationError(SpecRule.PlateRowIndexConsistency, `Well path ${pyRepr(well.path)} names column ${pyRepr(columnName)}, ` +
633
+ `which is not declared in plate.columns.`, location);
634
+ }
635
+ if (well.rowIndex !== expectedRow) {
636
+ throw new ValidationError(SpecRule.PlateRowIndexConsistency, `Well ${pyRepr(well.path)} has rowIndex ${well.rowIndex}, but row ` +
637
+ `${pyRepr(rowName)} is at index ${expectedRow} in plate.rows.`, location);
638
+ }
639
+ if (well.columnIndex !== expectedColumn) {
640
+ throw new ValidationError(SpecRule.PlateRowIndexConsistency, `Well ${pyRepr(well.path)} has columnIndex ${well.columnIndex}, but ` +
641
+ `column ${pyRepr(columnName)} is at index ${expectedColumn} in ` +
642
+ `plate.columns.`, location);
643
+ }
644
+ }
645
+ }
646
+ /**
647
+ * Validate that well images reference an acquisition when required.
648
+ *
649
+ * When a plate declares more than one acquisition, every image in each of its
650
+ * wells must carry an `acquisition` reference so the field can be attributed to
651
+ * a specific acquisition. Plates with zero or one acquisition impose no such
652
+ * requirement, so this rule is a no-op for them. The first image missing its
653
+ * reference, scanned in order, is reported.
654
+ *
655
+ * @param plate - The plate metadata declaring the acquisitions.
656
+ * @param well - The well metadata whose images are validated.
657
+ * @throws {ValidationError} With {@link SpecRule.WellAcquisitionMissing} for the
658
+ * first `WellImage` whose `acquisition` is absent while the plate declares
659
+ * multiple acquisitions; location `well.images[i].acquisition`.
660
+ */
661
+ export function validateWellAcquisition(plate, well) {
662
+ const acquisitions = plate.acquisitions;
663
+ if (acquisitions === undefined || acquisitions.length <= 1) {
664
+ return;
665
+ }
666
+ for (let i = 0; i < well.images.length; i++) {
667
+ const image = well.images[i];
668
+ if (image.acquisition === undefined || image.acquisition === null) {
669
+ throw new ValidationError(SpecRule.WellAcquisitionMissing, `Plate declares ${acquisitions.length} acquisitions, so well ` +
670
+ `image ${i} must reference one via 'acquisition'.`, `well.images[${i}].acquisition`);
671
+ }
672
+ }
673
+ }
674
+ /**
675
+ * Run the structural image/multiscales rules in canonical specification order.
676
+ *
677
+ * Orchestrates the per-rule `validate*` functions in this module. It is
678
+ * fail-fast: the rules run in canonical specification-MUST order and the first
679
+ * {@link ValidationError} thrown propagates to the caller -- later rules do not
680
+ * run. The rules run in this order:
681
+ *
682
+ * 1. {@link validateAxisCount}
683
+ * 2. {@link validateAxisType}
684
+ * 3. {@link validateAxisOrder}
685
+ * 4. {@link validateSpatialAxisOrder}
686
+ * 5. {@link validatePerDatasetScaleCount}
687
+ * 6. {@link validateScaleLength}
688
+ * 7. {@link validateTransformOrder}
689
+ * 8. {@link validateDatasetOrder}
690
+ * 9. {@link validateOmeroColorHex}
691
+ * 10. {@link validateAxisOrientation}
692
+ * 11. {@link validateZarrFormatForVersion}
693
+ * 12. {@link validateOmeNamespace}
694
+ *
695
+ * Rules 11 and 12 are the OME-Zarr v0.5 namespacing checks; they fire only for
696
+ * v0.5 metadata and are inert (a no-op) for v0.4.
697
+ *
698
+ * Under {@link ValidationLevel.SchemaOnly} this function returns immediately
699
+ * without running any structural rule -- shape/schema validation is the
700
+ * separate concern of {@link validateMetadata}, which checks the raw
701
+ * attributes via Zod. The default level is {@link ValidationLevel.Strict}.
702
+ *
703
+ * This orchestrator covers the image/multiscales rules, including the RFC 4
704
+ * anatomical-orientation checks ({@link validateAxisOrientation}, a no-op when
705
+ * no axis declares orientation). The HCS plate/well structural rules operate on
706
+ * separate metadata objects and are dispatched by the companion
707
+ * {@link validatePlate} and {@link validateWell} entry points.
708
+ *
709
+ * @param metadata - The parsed OME-Zarr v0.4 multiscales metadata to validate.
710
+ * @param options - Validation options; defaults to
711
+ * `{ level: "strict", allowUnknownFields: true }`.
712
+ * @throws {ValidationError} For the first structural rule violated, carrying
713
+ * the offending {@link SpecRule} and `location`. Never thrown under
714
+ * {@link ValidationLevel.SchemaOnly}, which runs no structural rule.
715
+ */
716
+ export function validateStructural(metadata, options) {
717
+ const resolved = {
718
+ level: options?.level ?? ValidationLevel.Strict,
719
+ allowUnknownFields: options?.allowUnknownFields ?? true,
720
+ };
721
+ if (resolved.level === ValidationLevel.SchemaOnly) {
722
+ return;
723
+ }
724
+ validateAxisCount(metadata);
725
+ validateAxisType(metadata);
726
+ validateAxisOrder(metadata);
727
+ validateSpatialAxisOrder(metadata);
728
+ validatePerDatasetScaleCount(metadata);
729
+ validateScaleLength(metadata);
730
+ validateTransformOrder(metadata);
731
+ validateDatasetOrder(metadata);
732
+ validateOmeroColorHex(metadata);
733
+ validateAxisOrientation(metadata);
734
+ validateZarrFormatForVersion(metadata);
735
+ validateOmeNamespace(metadata);
736
+ }
737
+ /**
738
+ * Run the structural HCS plate rules in canonical specification order.
739
+ *
740
+ * The plate-level counterpart to {@link validateStructural}: it orchestrates
741
+ * the structural rules that operate on a parsed {@link PlateMetadata}. Like its
742
+ * image/multiscales sibling it is fail-fast -- the first {@link ValidationError}
743
+ * thrown propagates to the caller and later rules do not run. The rules run in
744
+ * this order:
745
+ *
746
+ * 1. {@link validatePlateWellIndexConsistency}
747
+ *
748
+ * Per-well image rules (e.g. acquisition references) are the separate concern
749
+ * of {@link validateWell}, called where each well's own metadata is loaded.
750
+ *
751
+ * Under {@link ValidationLevel.SchemaOnly} this function returns immediately
752
+ * without running any structural rule -- shape/schema validation is the
753
+ * separate concern of {@link validateMetadata}, which checks the raw attributes
754
+ * via Zod. The default level is {@link ValidationLevel.Strict}.
755
+ *
756
+ * @param plate - The parsed OME-Zarr v0.4 plate metadata to validate.
757
+ * @param options - Validation options; defaults to
758
+ * `{ level: "strict", allowUnknownFields: true }`.
759
+ * @throws {ValidationError} For the first structural plate rule violated,
760
+ * carrying the offending {@link SpecRule} and `location`. Never thrown under
761
+ * {@link ValidationLevel.SchemaOnly}, which runs no structural rule.
762
+ */
763
+ export function validatePlate(plate, options) {
764
+ const resolved = {
765
+ level: options?.level ?? ValidationLevel.Strict,
766
+ allowUnknownFields: options?.allowUnknownFields ?? true,
767
+ };
768
+ if (resolved.level === ValidationLevel.SchemaOnly) {
769
+ return;
770
+ }
771
+ validatePlateWellIndexConsistency(plate);
772
+ }
773
+ /**
774
+ * Run the structural HCS well rules in canonical specification order.
775
+ *
776
+ * Validates a single {@link WellMetadata} in the context of its parent
777
+ * {@link PlateMetadata} -- the plate's acquisition declarations govern whether
778
+ * each well image must reference an acquisition. Fail-fast, like
779
+ * {@link validateStructural} and {@link validatePlate}. The rules run in this
780
+ * order:
781
+ *
782
+ * 1. {@link validateWellAcquisition}
783
+ *
784
+ * Under {@link ValidationLevel.SchemaOnly} this function returns immediately
785
+ * without running any structural rule -- shape/schema validation is the
786
+ * separate concern of {@link validateMetadata}, which checks the raw attributes
787
+ * via Zod. The default level is {@link ValidationLevel.Strict}.
788
+ *
789
+ * @param plate - The parsed plate metadata that owns `well`; supplies the
790
+ * acquisition context the well rules consult.
791
+ * @param well - The parsed well metadata to validate.
792
+ * @param options - Validation options; defaults to
793
+ * `{ level: "strict", allowUnknownFields: true }`.
794
+ * @throws {ValidationError} For the first structural well rule violated,
795
+ * carrying the offending {@link SpecRule} and `location`. Never thrown under
796
+ * {@link ValidationLevel.SchemaOnly}, which runs no structural rule.
797
+ */
798
+ export function validateWell(plate, well, options) {
799
+ const resolved = {
800
+ level: options?.level ?? ValidationLevel.Strict,
801
+ allowUnknownFields: options?.allowUnknownFields ?? true,
802
+ };
803
+ if (resolved.level === ValidationLevel.SchemaOnly) {
804
+ return;
805
+ }
806
+ validateWellAcquisition(plate, well);
807
+ }
808
+ //# sourceMappingURL=structural_validation.js.map