diff --git a/README.md b/README.md index 4324c47..4b25b67 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ Returns whether a single viewer is compatible with the given metadata. #### `validateViewer(viewer: ViewerManifest, metadata: OmeZarrMetadata): ValidationResult` -Returns full validation details (compatible flag, errors, warnings) for a single viewer against the given metadata. +Returns full validation details (`dataCompatible`, `dataFeaturesSupported`, errors, warnings) for a single viewer against the given metadata. ### Types @@ -92,16 +92,133 @@ The library exports TypeScript types for all data structures: - `ValidationError`, `ValidationWarning` - Detailed validation messages - `AxisMetadata`, `MultiscaleMetadata` - Nested metadata types -## Manifest Specification (DRAFT) +## Icons -| Attribute | Description | -| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| ome_zarr_versions | List of OME-NGFF versions which are supported by the tool. When a Zarr group with multiscales metadata containing a version listed here is given to the tool, the tool promises to do something useful. However, it may not support every feature of the specification. | -| rfcs_supported | List of supported RFC numbers which have been implemented on top of the released OME-NGFF versions listed in ome_zarr_versions. Given test data produced for a given RFC listed here, the tool promises to do something useful. However, it may not support every feature of the RFC. | -| bioformats2raw_layout | A tool that advertises support for this will be able to open a Zarr that implements this transitional layout. | -| omero_metadata | A tool that advertises support for this will be able to open a Zarr that implements this transitional metadata, for example by defaulting channel colors with the provided color values. | -| labels | A tool that advertises support will open pixel-annotation metadata found in the "labels" group. | -| hcs_plates | A tool that advertises support will open high content screening datasets found in the "plate" group. | +Icons for canonical viewers are hosted in the [`public/icons/`](public/icons/) directory and served via GitHub Pages at: + +``` +https://raw.githubusercontent.com/bioimagetools/capability-manifest/host-manifests-and-docs/public/icons/{slug}.png +``` + +where `{slug}` is the viewer name lowercased with spaces replaced by hyphens (e.g. `"OME-Zarr Validator"` → `ome-zarr-validator.png`). Consumers can derive this URL automatically and fall back to a local placeholder when the icon is unavailable. + +The `logo` field in the `viewer` section is an optional override for cases where this convention does not apply. + +## Canonical Manifests + +This repository hosts canonical capability manifests for well-known OME-Zarr viewers in the [`manifests/`](manifests/) directory: + +| Manifest | Viewer | OME-Zarr Versions | +| --- | --- | --- | +| [neuroglancer.yaml](manifests/neuroglancer.yaml) | Neuroglancer | 0.4, 0.5 | +| [avivator.yaml](manifests/avivator.yaml) | Avivator (Viv) | 0.4 | +| [validator.yaml](manifests/validator.yaml) | OME-Zarr Validator | 0.4, 0.5 | +| [vole.yaml](manifests/vole.yaml) | Vol-E | 0.4, 0.5 | + +Consumers can load these manifests by URL directly from GitHub (raw content URLs) or host copies on their own infrastructure. + +Viewer developers are encouraged to maintain their own manifests and submit PRs to update the canonical versions here when capabilities change. + +## Manifest Schema (DRAFT) + +A capability manifest is a YAML file with two top-level sections: `viewer` and `capabilities`. + +### `viewer` Section + +Identifies the tool and provides a URL template for launching it. + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | string | yes | Human-readable name of the viewer | +| `version` | string | yes | Version of the viewer these capabilities describe | +| `repo` | string | no | URL of the source code repository | +| `logo` | string | no | URL to a logo image for the viewer. Optional override — consumers may derive a logo URL by convention (see [Icons](#icons)). Omit if the conventional path applies. | +| `template_url` | string | no | URL template for opening a dataset. Use `{DATA_URL}` as a placeholder for the dataset URL — consumers replace it at runtime with the actual OME-Zarr location | + +Example: + +```yaml +viewer: + name: "Neuroglancer" + version: "2.41.2" + repo: "https://github.com/google/neuroglancer" + template_url: https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"{DATA_URL}","type":"image"}]} +``` + +### `capabilities` Section + +Describes which OME-Zarr features the tool supports. All fields are optional — omitting a field means the capability is unknown/undeclared. + +| Field | Type | Description | +| --- | --- | --- | +| `ome_zarr_versions` | number[] | OME-NGFF specification versions the tool can load (e.g. `[0.4, 0.5]`). When a dataset's multiscales metadata contains a version listed here, the tool should be able to open it. | +| `compression_codecs` | string[] | Compression codecs the tool can decode (e.g. `["blosc", "zstd", "gzip"]`). An empty array `[]` means the tool does not declare codec support — compatibility is unknown rather than unsupported. | +| `rfcs_supported` | number[] | RFC numbers implemented on top of the released OME-NGFF versions. Given test data for a listed RFC, the tool should handle it. | +| `axes` | boolean | Whether axis names and units from the metadata are respected | +| `scale` | boolean | Whether scaling factors on multiscale datasets are respected | +| `translation` | boolean | Whether translation offsets (including subpixel offsets for lower scale levels) are respected | +| `channels` | boolean | Whether the tool supports datasets with multiple channels (c axis) | +| `timepoints` | boolean | Whether the tool supports datasets with multiple timepoints (t axis) | +| `labels` | boolean | Whether pixel-annotation metadata in the "labels" group is loaded | +| `hcs_plates` | boolean | Whether high content screening datasets in the "plate" group are loaded | +| `bioformats2raw_layout` | boolean | Whether the tool can open Zarr stores using the bioformats2raw transitional layout | +| `omero_metadata` | boolean | Whether the tool uses OMERO metadata (e.g. to set default channel colors) | + +Example: + +```yaml +capabilities: + ome_zarr_versions: [0.4, 0.5] + compression_codecs: ["blosc", "zstd", "gzip"] + rfcs_supported: [] + axes: true + scale: true + translation: true + channels: true + timepoints: true + labels: false + hcs_plates: false + bioformats2raw_layout: false + omero_metadata: true +``` + +### How `validateViewer()` Uses the Manifest + +The `validateViewer()` function checks a manifest's declared capabilities against a dataset's `OmeZarrMetadata` and returns a `ValidationResult`: + +```typescript +interface ValidationResult { + dataCompatible: boolean; // true if no errors (viewer can open the data) + dataFeaturesSupported: boolean; // true if no warnings (viewer fully supports all data features) + errors: ValidationError[]; // hard failures — data will not load + warnings: ValidationWarning[]; // soft issues — data loads but features may be missing +} +``` + +Capabilities fall into two levels: + +- **Data compatibility** (`errors`): Hard requirements. If unmet, the viewer cannot open or render the data at all — it should not be shown. +- **Data support** (`warnings`): Soft requirements. If unmet, the viewer can still open the data but may not display certain features — it should still be shown, with warnings logged or surfaced to the user. + +The checks performed, in order: + +| Check | Metadata field | Manifest field | Level | Result if mismatch | +| --- | --- | --- | --- | --- | +| OME-Zarr version | `version` or `multiscales[0].version` | `ome_zarr_versions` | **Compatibility** | **Error** — viewer cannot load this version | +| Compression codec | `compressor.id` | `compression_codecs` | **Compatibility** | **Error** if codec not listed; **Warning** if viewer declares no codecs (unknown support) | +| Axes metadata | `axes` | `axes` | **Support** | **Warning** — axis names/units may be ignored | +| Channel support | `axes` contains c/channel | `channels` | **Support** | **Warning** — multi-channel data may not render correctly | +| Timepoint support | `axes` contains t/time | `timepoints` | **Support** | **Warning** — time-series data may not render correctly | +| Labels | `labels` array non-empty | `labels` | **Support** | **Warning** — labels won't be displayed | +| HCS plates | `plate` present | `hcs_plates` | **Support** | **Warning** — plate layout won't be shown | +| OMERO metadata | `omero` present | `omero_metadata` | **Support** | **Warning** — channel colors etc. won't be applied | +| Scale transforms | `multiscales[].datasets[].coordinateTransformations` type `scale` | `scale` | **Support** | **Warning** — scaling factors may be ignored | +| Translation offsets | `multiscales[].datasets[].coordinateTransformations` type `translation` | `translation` | **Support** | **Warning** — coordinate offsets may be ignored | +| bioformats2raw layout | `bioformats2raw_layout` | `bioformats2raw_layout` | **Support** | **Warning** — layout may not be traversed correctly | + +> **Note on `rfcs_supported`:** Although `rfcs_supported` is a hard compatibility requirement (it determines whether a viewer can parse RFC-mandated metadata structures), no validation check is currently implemented. OME-NGFF metadata does not yet expose which RFCs a dataset requires — this is a spec-level gap. When the spec defines a `rfcs_required` field, the validator will compare it against `viewer.capabilities.rfcs_supported` and produce an error on mismatch. + +A viewer is considered **data-compatible** (`dataCompatible: true`) when there are zero errors — it should be shown to the user. `dataFeaturesSupported` is `false` when there are warnings, indicating the viewer can open the data but may not display all features. ## Prototype diff --git a/manifests/avivator.yaml b/manifests/avivator.yaml new file mode 100644 index 0000000..3098d39 --- /dev/null +++ b/manifests/avivator.yaml @@ -0,0 +1,41 @@ +viewer: + name: "Avivator" + version: "0.16.1" + repo: "https://github.com/hms-dbmi/viv" + template_url: "https://avivator.gehlenborglab.org/?image_url={DATA_URL}" + +capabilities: + # Enumeration of OME-Zarr versions that can be loaded + ome_zarr_versions: [0.4] + + compression_codecs: ["blosc", "gzip"] + + # Which additional RFCs are supported? + rfcs_supported: [] + + # Are axis names and units respected? + axes: true + + # Are scaling factors respected on multiscales? + scale: true + + # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? + translation: true + + # Does the tool support multiple channels? + channels: true + + # Does the tool support multiple timepoints? + timepoints: true + + # Are labels loaded when available? + labels: false + + # Are HCS plates loaded when available? + hcs_plates: false + + # Does the viewer handle multiple images in a bioformats2raw layout? + bioformats2raw_layout: false + + # Is the OMERO metadata used to e.g. color the channels? + omero_metadata: true diff --git a/manifests/neuroglancer.yaml b/manifests/neuroglancer.yaml new file mode 100644 index 0000000..4ccebd2 --- /dev/null +++ b/manifests/neuroglancer.yaml @@ -0,0 +1,41 @@ +viewer: + name: "Neuroglancer" + version: "2.41.2" + repo: "https://github.com/google/neuroglancer" + template_url: https://neuroglancer-demo.appspot.com/#!{"layers":[{"name":"image","source":"{DATA_URL}","type":"image"}]} + +capabilities: + # Enumeration of OME-Zarr versions that can be loaded + ome_zarr_versions: [0.4, 0.5] + + compression_codecs: ["blosc", "zstd", "zlib", "lz4", "gzip"] + + # Which additional RFCs are supported? + rfcs_supported: [] + + # Are axis names and units respected? + axes: true + + # Are scaling factors respected on multiscales? + scale: true + + # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? + translation: true + + # Does the tool support multiple channels? + channels: true + + # Does the tool support multiple timepoints? + timepoints: true + + # Are labels loaded when available? + labels: false + + # Are HCS plates loaded when available? + hcs_plates: false + + # Does the viewer handle multiple images in a bioformats2raw layout? + bioformats2raw_layout: false + + # Is the OMERO metadata used to e.g. color the channels? + omero_metadata: true diff --git a/manifests/validator.yaml b/manifests/validator.yaml new file mode 100644 index 0000000..85e479c --- /dev/null +++ b/manifests/validator.yaml @@ -0,0 +1,41 @@ +viewer: + name: "OME-Zarr Validator" + version: "1.0.0" + repo: "https://github.com/ome/ome-ngff-validator" + template_url: "https://ome.github.io/ome-ngff-validator/?source={DATA_URL}" + +capabilities: + # Enumeration of OME-Zarr versions that can be loaded + ome_zarr_versions: [0.4, 0.5] + + compression_codecs: [] + + # Which additional RFCs are supported? + rfcs_supported: [] + + # Are axis names and units respected? + axes: true + + # Are scaling factors respected on multiscales? + scale: true + + # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? + translation: true + + # Does the tool support multiple channels? + channels: true + + # Does the tool support multiple timepoints? + timepoints: true + + # Are labels loaded when available? + labels: true + + # Are HCS plates loaded when available? + hcs_plates: true + + # Does the viewer handle multiple images in a bioformats2raw layout? + bioformats2raw_layout: false + + # Is the OMERO metadata used to e.g. color the channels? + omero_metadata: true diff --git a/manifests/vole.yaml b/manifests/vole.yaml new file mode 100644 index 0000000..d0fbf0c --- /dev/null +++ b/manifests/vole.yaml @@ -0,0 +1,41 @@ +viewer: + name: "Vol-E" + version: "1.0.0" + repo: "https://github.com/allen-cell-animated/volume-viewer" + template_url: "https://volumeviewer.allencell.org/viewer?url={DATA_URL}" + +capabilities: + # Enumeration of OME-Zarr versions that can be loaded + ome_zarr_versions: [0.4, 0.5] + + compression_codecs: [] + + # Which additional RFCs are supported? + rfcs_supported: [] + + # Are axis names and units respected? + axes: true + + # Are scaling factors respected on multiscales? + scale: true + + # Are translation factors respected on multiscales (including subpixel offsets for lower scale levels)? + translation: true + + # Does the tool support multiple channels? + channels: true + + # Does the tool support multiple timepoints? + timepoints: true + + # Are labels loaded when available? + labels: false + + # Are HCS plates loaded when available? + hcs_plates: false + + # Does the viewer handle multiple images in a bioformats2raw layout? + bioformats2raw_layout: false + + # Is the OMERO metadata used to e.g. color the channels? + omero_metadata: false diff --git a/package-lock.json b/package-lock.json index 8a8ea2f..0af67fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bioimagetools/capability-manifest", - "version": "0.3.3", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bioimagetools/capability-manifest", - "version": "0.3.3", + "version": "0.5.0", "license": "ISC", "dependencies": { "js-yaml": "^4.1.1" diff --git a/package.json b/package.json index dfafd51..b0f1547 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bioimagetools/capability-manifest", - "version": "0.3.3", + "version": "0.5.0", "description": "Library to determine OME-Zarr viewer compatibility based on capability manifests", "type": "module", "main": "./dist/index.js", diff --git a/public/icons/avivator.png b/public/icons/avivator.png new file mode 100644 index 0000000..2d07b8c Binary files /dev/null and b/public/icons/avivator.png differ diff --git a/public/icons/neuroglancer.png b/public/icons/neuroglancer.png new file mode 100644 index 0000000..bb39db6 Binary files /dev/null and b/public/icons/neuroglancer.png differ diff --git a/public/icons/ome-zarr-validator.png b/public/icons/ome-zarr-validator.png new file mode 100644 index 0000000..b96a530 Binary files /dev/null and b/public/icons/ome-zarr-validator.png differ diff --git a/public/icons/vol-e.png b/public/icons/vol-e.png new file mode 100644 index 0000000..e5e0074 Binary files /dev/null and b/public/icons/vol-e.png differ diff --git a/public/schema.json b/public/schema.json index 4553d8d..e4efdba 100644 --- a/public/schema.json +++ b/public/schema.json @@ -27,6 +27,12 @@ "description": "URL to the tool's source code repository", "examples": ["https://github.com/google/neuroglancer"] }, + "logo": { + "type": "string", + "format": "uri", + "description": "URL to a logo image for the viewer. Optional override — consumers may derive a logo URL from the viewer name by convention. Provide this field only if the conventional path does not apply.", + "examples": ["https://raw.githubusercontent.com/bioimagetools/capability-manifest/host-manifests-and-docs/public/icons/neuroglancer.png"] + }, "template_url": { "type": "string", "format": "uri", diff --git a/public/viewers/neuroglancer.yaml b/public/viewers/neuroglancer.yaml index 9b395a8..4ccebd2 100644 --- a/public/viewers/neuroglancer.yaml +++ b/public/viewers/neuroglancer.yaml @@ -38,4 +38,4 @@ capabilities: bioformats2raw_layout: false # Is the OMERO metadata used to e.g. color the channels? - omero_metadata: false + omero_metadata: true diff --git a/src/app.ts b/src/app.ts index e2b643b..9f72fec 100644 --- a/src/app.ts +++ b/src/app.ts @@ -193,7 +193,7 @@ function createLaunchButton( button.textContent = 'Launch'; button.target = '_blank'; - if (validation && !validation.compatible) { + if (validation && !validation.dataCompatible) { button.classList.add('disabled'); button.setAttribute('aria-disabled', 'true'); diff --git a/src/index.test.ts b/src/index.test.ts index 840917b..837cb38 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -44,9 +44,28 @@ describe('getCompatibleViewers', () => { expect(result).toEqual(['ViewerA', 'ViewerB']); }); - it('filters out incompatible viewers', () => { + it('filters out incompatible viewers by hard requirements', () => { const manifests = [ - createManifest('FullViewer', { ome_zarr_versions: [0.4, 0.5], channels: true }), + createManifest('FullViewer', { ome_zarr_versions: [0.4, 0.5] }), + createManifest('LimitedViewer', { ome_zarr_versions: [0.4] }) + ]; + const metadata: OmeZarrMetadata = { + version: '0.5', + axes: [ + { name: 'y', type: 'space' }, + { name: 'x', type: 'space' } + ] + }; + + const result = getCompatibleViewers(manifests, metadata); + + expect(result).toContain('FullViewer'); + expect(result).not.toContain('LimitedViewer'); + }); + + it('includes viewers with unsupported features (soft requirements)', () => { + const manifests = [ + createManifest('FullViewer', { ome_zarr_versions: [0.4], channels: true }), createManifest('LimitedViewer', { ome_zarr_versions: [0.4], channels: false }) ]; const metadata: OmeZarrMetadata = { @@ -61,7 +80,7 @@ describe('getCompatibleViewers', () => { const result = getCompatibleViewers(manifests, metadata); expect(result).toContain('FullViewer'); - expect(result).not.toContain('LimitedViewer'); + expect(result).toContain('LimitedViewer'); }); it('returns empty array when no viewers are compatible', () => { @@ -106,7 +125,8 @@ describe('getCompatibleViewersWithDetails', () => { expect(results).toHaveLength(1); expect(results[0].name).toBe('TestViewer'); - expect(results[0].validation).toHaveProperty('compatible'); + expect(results[0].validation).toHaveProperty('dataCompatible'); + expect(results[0].validation).toHaveProperty('dataFeaturesSupported'); expect(results[0].validation).toHaveProperty('errors'); expect(results[0].validation).toHaveProperty('warnings'); }); @@ -122,7 +142,7 @@ describe('getCompatibleViewersWithDetails', () => { expect(results).toHaveLength(1); expect(results[0].name).toBe('Compatible'); - expect(results[0].validation.compatible).toBe(true); + expect(results[0].validation.dataCompatible).toBe(true); expect(results[0].validation.errors).toHaveLength(0); }); @@ -146,7 +166,8 @@ describe('getCompatibleViewersWithDetails', () => { const results = getCompatibleViewersWithDetails(manifests, metadata); expect(results).toHaveLength(1); - expect(results[0].validation.compatible).toBe(true); + expect(results[0].validation.dataCompatible).toBe(true); + expect(results[0].validation.dataFeaturesSupported).toBe(false); expect(results[0].validation.warnings.length).toBeGreaterThan(0); }); diff --git a/src/index.ts b/src/index.ts index 16dc43b..97630d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -45,11 +45,8 @@ export function getCompatibleViewersWithDetails( metadata: OmeZarrMetadata ): Array<{ name: string; validation: ValidationResult }> { return manifests - .filter(viewer => isCompatible(viewer, metadata)) - .map(viewer => ({ - name: viewer.viewer.name, - validation: validateViewer(viewer, metadata) - })); + .map(viewer => ({ name: viewer.viewer.name, validation: validateViewer(viewer, metadata) })) + .filter(({ validation }) => validation.dataCompatible); } // Re-export loader @@ -58,6 +55,9 @@ export { loadManifestsFromUrls } from './loader.js'; // Re-export validator functions export { validateViewer, isCompatible } from './validator.js'; +// Re-export logo utility +export { getLogoUrl } from './logo.js'; + // Re-export types for consumers export type { ViewerManifest, diff --git a/src/logo.test.ts b/src/logo.test.ts new file mode 100644 index 0000000..a06e870 --- /dev/null +++ b/src/logo.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import { getLogoUrl } from './logo.js'; +import type { ViewerManifest } from './types.js'; + +function createManifest( + name: string, + logo?: string +): ViewerManifest { + return { + viewer: { name, version: '1.0.0', ...(logo !== undefined ? { logo } : {}) }, + capabilities: { + ome_zarr_versions: [0.4, 0.5] + } + }; +} + +describe('getLogoUrl', () => { + it('returns the manifest logo field when provided', () => { + const manifest = createManifest('Neuroglancer', 'https://example.com/custom-logo.png'); + expect(getLogoUrl(manifest)).toBe('https://example.com/custom-logo.png'); + }); + + it('derives a URL from the viewer name when no logo field is set', () => { + const manifest = createManifest('Neuroglancer'); + expect(getLogoUrl(manifest)).toBe( + 'https://raw.githubusercontent.com/bioimagetools/capability-manifest/host-manifests-and-docs/public/icons/neuroglancer.png' + ); + }); + + it('replaces spaces with hyphens in the derived slug', () => { + const manifest = createManifest('OME-Zarr Validator'); + expect(getLogoUrl(manifest)).toBe( + 'https://raw.githubusercontent.com/bioimagetools/capability-manifest/host-manifests-and-docs/public/icons/ome-zarr-validator.png' + ); + }); + + it('handles multiple consecutive spaces', () => { + const manifest = createManifest('My Cool Viewer'); + expect(getLogoUrl(manifest)).toBe( + 'https://raw.githubusercontent.com/bioimagetools/capability-manifest/host-manifests-and-docs/public/icons/my-cool-viewer.png' + ); + }); + + it('does not use an empty string logo as an override', () => { + const manifest = createManifest('Avivator', ''); + expect(getLogoUrl(manifest)).toBe( + 'https://raw.githubusercontent.com/bioimagetools/capability-manifest/host-manifests-and-docs/public/icons/avivator.png' + ); + }); +}); diff --git a/src/logo.ts b/src/logo.ts new file mode 100644 index 0000000..8abc597 --- /dev/null +++ b/src/logo.ts @@ -0,0 +1,27 @@ +import type { ViewerManifest } from './types.js'; + +/** + * Default base URL for viewer icons hosted in the capability-manifest repository. + */ +const DEFAULT_ICONS_BASE_URL = + 'https://raw.githubusercontent.com/bioimagetools/capability-manifest/host-manifests-and-docs/public/icons'; + +/** + * Derive a logo URL for a viewer. + * + * If the manifest includes a `viewer.logo` field, that value is returned as-is + * (allowing per-viewer overrides). Otherwise a URL is constructed pointing to + * the capability-manifest icon repository, where the filename is the viewer + * name lowercased with spaces replaced by hyphens + * (e.g. "OME-Zarr Validator" → "ome-zarr-validator.png"). + * + * @param manifest - The viewer's capability manifest + * @returns URL string pointing to the viewer's logo + */ +export function getLogoUrl(manifest: ViewerManifest): string { + if (manifest.viewer.logo) { + return manifest.viewer.logo; + } + const slug = manifest.viewer.name.toLowerCase().replace(/\s+/g, '-'); + return `${DEFAULT_ICONS_BASE_URL}/${slug}.png`; +} diff --git a/src/types.ts b/src/types.ts index 070915b..516943d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ // Schema types -export interface Schema { +export type Schema = { properties: { viewer: { properties: { @@ -12,7 +12,7 @@ export interface Schema { }; }; }; -} +}; export interface PropertyDefinition { type: string; @@ -28,11 +28,12 @@ export interface CapabilityDefinition extends PropertyDefinition { } // Viewer manifest types -export interface ViewerManifest { +export type ViewerManifest = { viewer: { name: string; version: string; repo?: string; + logo?: string; template_url?: string; }; capabilities: { @@ -49,46 +50,74 @@ export interface ViewerManifest { bioformats2raw_layout?: boolean; omero_metadata?: boolean; }; -} +}; // OME-Zarr metadata types -export interface OmeZarrMetadata { - version?: string; - axes?: AxisMetadata[]; - multiscales?: MultiscaleMetadata[]; - omero?: any; - labels?: string[]; - plate?: any; - compressor?: any; -} - -export interface AxisMetadata { +export type AxisMetadata = { name: string; type?: string; unit?: string; -} +}; + +export type CoordinateTransformation = + | { type: 'identity' } + | { type: 'scale'; scale: number[] } + | { type: 'scale'; path: string } + | { type: 'translation'; translation: number[] } + | { type: 'translation'; path: string }; -export interface MultiscaleMetadata { +export type DatasetMetadata = { + path: string; + coordinateTransformations?: CoordinateTransformation[]; +}; + +export type MultiscaleMetadata = { version?: string; axes?: AxisMetadata[]; - datasets?: any[]; -} + datasets: DatasetMetadata[]; + coordinateTransformations?: CoordinateTransformation[]; + [key: string]: unknown; +}; + +export type OmeZarrMetadata = { + version?: string; + axes?: AxisMetadata[]; + bioformats2raw_layout?: boolean; + multiscales?: MultiscaleMetadata[]; + omero?: { [key: string]: unknown }; + labels?: string[]; + plate?: { [key: string]: unknown }; + well?: { [key: string]: unknown }; + compressor?: { id: string; [key: string]: unknown } | null; // Zarr v2 + codecs?: Array<{ name: string; configuration?: { [key: string]: unknown } }>; // Zarr v3 +}; // Validation types -export interface ValidationResult { - compatible: boolean; +export type ValidationResult = { + /** Can the viewer read and parse this data? Determines whether a viewer is shown. + * Determined by ome_zarr_versions, compression_codecs, and rfcs_supported. + */ + dataCompatible: boolean; + /** + * Does the viewer support all features in this data? Determines whether + * warnings are shown/logged, NOT whether the viewer is shown. A viewer may be + * compatible with a dataset but not support all features + */ + dataFeaturesSupported: boolean; + /** Data compatibility errors (e.g. unsupported version or codec) */ errors: ValidationError[]; + /** Unsupported data feature warnings (e.g. labels, channels not supported) */ warnings: ValidationWarning[]; -} +}; -export interface ValidationError { +export type ValidationError = { capability: string; message: string; required: any; found: any; -} +}; -export interface ValidationWarning { +export type ValidationWarning = { capability: string; message: string; -} +}; diff --git a/src/validator.test.ts b/src/validator.test.ts index b6b996f..232a678 100644 --- a/src/validator.test.ts +++ b/src/validator.test.ts @@ -46,7 +46,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(true); + expect(result.dataCompatible).toBe(true); expect(result.errors).toHaveLength(0); }); @@ -56,10 +56,10 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(false); + expect(result.dataCompatible).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0].capability).toBe('ome_zarr_versions'); - expect(result.errors[0].required).toBe(0.5); + expect(result.errors[0].required).toBe('0.5'); expect(result.errors[0].found).toEqual([0.4]); }); @@ -72,7 +72,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(true); + expect(result.dataCompatible).toBe(true); expect(result.errors).toHaveLength(0); }); @@ -82,7 +82,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(false); + expect(result.dataCompatible).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0].message).toContain('does not specify OME-Zarr version support'); }); @@ -93,7 +93,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(false); + expect(result.dataCompatible).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0].message).toContain('does not specify OME-Zarr version support'); }); @@ -104,7 +104,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(false); + expect(result.dataCompatible).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0].capability).toBe('ome_zarr_versions'); expect(result.errors[0].message).toContain('Metadata does not specify an OME-Zarr version'); @@ -118,7 +118,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(true); + expect(result.dataCompatible).toBe(true); }); it('returns error when viewer does not support the codec', () => { @@ -127,29 +127,71 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(false); + expect(result.dataCompatible).toBe(false); expect(result.errors).toHaveLength(1); expect(result.errors[0].capability).toBe('compression_codecs'); expect(result.errors[0].required).toBe('zstd'); }); - it('handles compressor as plain string', () => { + it('skips codec check when metadata has no compressor or codecs', () => { const viewer = createViewer({ compression_codecs: ['blosc'] }); - const metadata = createMetadata({ compressor: 'zstd' }); + const metadata = createMetadata({ compressor: undefined }); const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(false); + expect(result.dataCompatible).toBe(true); + }); + + it('returns compatible when viewer supports a Zarr v3 codec', () => { + const viewer = createViewer({ compression_codecs: ['blosc', 'bytes'] }); + const metadata = createMetadata({ + codecs: [{ name: 'bytes' }, { name: 'blosc', configuration: { cname: 'lz4' } }] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.dataCompatible).toBe(true); + }); + + it('returns error when viewer does not support a Zarr v3 codec', () => { + const viewer = createViewer({ compression_codecs: ['bytes'] }); + const metadata = createMetadata({ + codecs: [{ name: 'bytes' }, { name: 'zstd' }] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.dataCompatible).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].capability).toBe('compression_codecs'); expect(result.errors[0].required).toBe('zstd'); }); - it('skips codec check when metadata has no compressor', () => { - const viewer = createViewer({ compression_codecs: ['blosc'] }); - const metadata = createMetadata({ compressor: undefined }); + it('returns multiple errors when viewer does not support multiple Zarr v3 codecs', () => { + const viewer = createViewer({ compression_codecs: ['bytes'] }); + const metadata = createMetadata({ + codecs: [{ name: 'blosc' }, { name: 'bytes' }, { name: 'zstd' }] + }); const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(true); + expect(result.dataCompatible).toBe(false); + expect(result.errors).toHaveLength(2); + expect(result.errors.map(e => e.required)).toEqual(expect.arrayContaining(['blosc', 'zstd'])); + }); + + it('returns warning when viewer declares no codecs and data uses Zarr v3 codecs', () => { + const viewer = createViewer({ compression_codecs: undefined }); + const metadata = createMetadata({ + codecs: [{ name: 'bytes' }, { name: 'blosc' }] + }); + + const result = validateViewer(viewer, metadata); + + expect(result.dataCompatible).toBe(true); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].capability).toBe('compression_codecs'); + expect(result.warnings[0].message).toContain('compatibility unknown'); }); it('returns warning when viewer has no codec list but data uses compression', () => { @@ -158,7 +200,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(true); + expect(result.dataCompatible).toBe(true); expect(result.warnings).toHaveLength(1); expect(result.warnings[0].capability).toBe('compression_codecs'); expect(result.warnings[0].message).toContain('zstd'); @@ -171,7 +213,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(true); + expect(result.dataCompatible).toBe(true); expect(result.warnings).toHaveLength(1); expect(result.warnings[0].capability).toBe('compression_codecs'); expect(result.warnings[0].message).toContain('blosc'); @@ -188,7 +230,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(true); + expect(result.dataCompatible).toBe(true); expect(result.warnings).toHaveLength(1); expect(result.warnings[0].capability).toBe('axes'); }); @@ -206,7 +248,7 @@ describe('validateViewer', () => { }); describe('channels support', () => { - it('returns error when data has channels but viewer does not support them', () => { + it('returns warning when data has channels but viewer does not support them', () => { const viewer = createViewer({ channels: false }); const metadata = createMetadata({ axes: [ @@ -218,9 +260,11 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(false); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].capability).toBe('channels'); + expect(result.dataCompatible).toBe(true); + expect(result.dataFeaturesSupported).toBe(false); + expect(result.warnings).toContainEqual( + expect.objectContaining({ capability: 'channels' }) + ); }); it('detects channels by axis name "c"', () => { @@ -231,7 +275,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.errors.some(e => e.capability === 'channels')).toBe(true); + expect(result.warnings.some(w => w.capability === 'channels')).toBe(true); }); it('detects channels by axis type "channel"', () => { @@ -242,7 +286,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.errors.some(e => e.capability === 'channels')).toBe(true); + expect(result.warnings.some(w => w.capability === 'channels')).toBe(true); }); it('returns compatible when viewer supports channels', () => { @@ -253,12 +297,13 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(true); + expect(result.dataCompatible).toBe(true); + expect(result.dataFeaturesSupported).toBe(true); }); }); describe('timepoints support', () => { - it('returns error when data has timepoints but viewer does not support them', () => { + it('returns warning when data has timepoints but viewer does not support them', () => { const viewer = createViewer({ timepoints: false }); const metadata = createMetadata({ axes: [ @@ -270,9 +315,11 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(false); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].capability).toBe('timepoints'); + expect(result.dataCompatible).toBe(true); + expect(result.dataFeaturesSupported).toBe(false); + expect(result.warnings).toContainEqual( + expect.objectContaining({ capability: 'timepoints' }) + ); }); it('detects timepoints by axis name "t"', () => { @@ -283,7 +330,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.errors.some(e => e.capability === 'timepoints')).toBe(true); + expect(result.warnings.some(w => w.capability === 'timepoints')).toBe(true); }); it('detects timepoints by axis type "time"', () => { @@ -294,7 +341,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.errors.some(e => e.capability === 'timepoints')).toBe(true); + expect(result.warnings.some(w => w.capability === 'timepoints')).toBe(true); }); it('returns compatible when viewer supports timepoints', () => { @@ -305,7 +352,8 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(true); + expect(result.dataCompatible).toBe(true); + expect(result.dataFeaturesSupported).toBe(true); }); }); @@ -318,7 +366,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(true); + expect(result.dataCompatible).toBe(true); expect(result.warnings).toContainEqual( expect.objectContaining({ capability: 'labels' }) ); @@ -332,7 +380,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(true); + expect(result.dataCompatible).toBe(true); }); it('skips label check when metadata has empty labels array', () => { @@ -343,7 +391,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(true); + expect(result.dataCompatible).toBe(true); }); it('skips label check when metadata has no labels', () => { @@ -354,12 +402,12 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(true); + expect(result.dataCompatible).toBe(true); }); }); describe('HCS plates support', () => { - it('returns error when data is HCS plate but viewer does not support them', () => { + it('returns warning when data is HCS plate but viewer does not support them', () => { const viewer = createViewer({ hcs_plates: false }); const metadata = createMetadata({ plate: { wells: [], columns: [] } @@ -367,12 +415,14 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(false); - expect(result.errors).toHaveLength(1); - expect(result.errors[0].capability).toBe('hcs_plates'); + expect(result.dataCompatible).toBe(true); + expect(result.dataFeaturesSupported).toBe(false); + expect(result.warnings).toContainEqual( + expect.objectContaining({ capability: 'hcs_plates' }) + ); }); - it('returns compatible when viewer supports HCS plates', () => { + it('returns fully supported when viewer supports HCS plates', () => { const viewer = createViewer({ hcs_plates: true }); const metadata = createMetadata({ plate: { wells: [], columns: [] } @@ -380,7 +430,8 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(true); + expect(result.dataCompatible).toBe(true); + expect(result.dataFeaturesSupported).toBe(true); }); it('skips HCS check when metadata has no plate', () => { @@ -391,7 +442,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(true); + expect(result.dataCompatible).toBe(true); }); }); @@ -404,7 +455,7 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(true); + expect(result.dataCompatible).toBe(true); expect(result.warnings).toHaveLength(1); expect(result.warnings[0].capability).toBe('omero_metadata'); }); @@ -421,8 +472,102 @@ describe('validateViewer', () => { }); }); + describe('bioformats2raw_layout support', () => { + it('returns warning when data uses bioformats2raw layout but viewer does not support it', () => { + const viewer = createViewer({ bioformats2raw_layout: false }); + const metadata = createMetadata({ bioformats2raw_layout: true }); + const result = validateViewer(viewer, metadata); + expect(result.dataCompatible).toBe(true); + expect(result.dataFeaturesSupported).toBe(false); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0].capability).toBe('bioformats2raw_layout'); + }); + + it('returns no warning when viewer supports bioformats2raw layout', () => { + const viewer = createViewer({ bioformats2raw_layout: true }); + const metadata = createMetadata({ bioformats2raw_layout: true }); + const result = validateViewer(viewer, metadata); + expect(result.dataFeaturesSupported).toBe(true); + expect(result.warnings).toHaveLength(0); + }); + + it('skips bioformats2raw check when metadata does not use it', () => { + const viewer = createViewer({ bioformats2raw_layout: false }); + const metadata = createMetadata({ bioformats2raw_layout: false }); + const result = validateViewer(viewer, metadata); + expect(result.warnings).toHaveLength(0); + }); + }); + + describe('scale support', () => { + const metadataWithScale = createMetadata({ + multiscales: [{ + version: '0.4', + axes: [{ name: 'y', type: 'space' }, { name: 'x', type: 'space' }], + datasets: [{ path: '0', coordinateTransformations: [{ type: 'scale', scale: [0.5, 0.5] }] }] + }] + }); + + it('returns warning when data has scale transforms and viewer does not support them', () => { + const viewer = createViewer({ scale: false }); + const result = validateViewer(viewer, metadataWithScale); + expect(result.dataCompatible).toBe(true); + expect(result.dataFeaturesSupported).toBe(false); + expect(result.warnings[0].capability).toBe('scale'); + }); + + it('no warning when viewer supports scale', () => { + const viewer = createViewer({ scale: true }); + const result = validateViewer(viewer, metadataWithScale); + expect(result.warnings.filter(w => w.capability === 'scale')).toHaveLength(0); + }); + + it('returns warning when viewer does not declare scale (undefined)', () => { + const viewer = createViewer({ scale: undefined }); + const result = validateViewer(viewer, metadataWithScale); + expect(result.warnings.filter(w => w.capability === 'scale')).toHaveLength(1); + }); + }); + + describe('translation support', () => { + const metadataWithTranslation = createMetadata({ + multiscales: [{ + version: '0.4', + axes: [{ name: 'y', type: 'space' }, { name: 'x', type: 'space' }], + datasets: [{ path: '0', coordinateTransformations: [{ type: 'translation', translation: [10, 20] }] }] + }] + }); + + it('returns warning when data has translation transforms and viewer does not support them', () => { + const viewer = createViewer({ translation: false }); + const result = validateViewer(viewer, metadataWithTranslation); + expect(result.dataCompatible).toBe(true); + expect(result.dataFeaturesSupported).toBe(false); + expect(result.warnings[0].capability).toBe('translation'); + }); + + it('no warning when viewer supports translation', () => { + const viewer = createViewer({ translation: true }); + const result = validateViewer(viewer, metadataWithTranslation); + expect(result.warnings.filter(w => w.capability === 'translation')).toHaveLength(0); + }); + + it('no warning when metadata has no translation transforms', () => { + const viewer = createViewer({ translation: false }); + const metadataNoTranslation = createMetadata({ + multiscales: [{ + version: '0.4', + axes: [{ name: 'y', type: 'space' }, { name: 'x', type: 'space' }], + datasets: [{ path: '0', coordinateTransformations: [{ type: 'scale', scale: [1, 1] }] }] + }] + }); + const result = validateViewer(viewer, metadataNoTranslation); + expect(result.warnings.filter(w => w.capability === 'translation')).toHaveLength(0); + }); + }); + describe('multiple validation issues', () => { - it('collects multiple errors', () => { + it('collects errors for hard requirements and warnings for soft requirements', () => { const viewer = createViewer({ ome_zarr_versions: [0.4], channels: false, @@ -440,11 +585,14 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(false); - expect(result.errors).toHaveLength(3); + expect(result.dataCompatible).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0].capability).toBe('ome_zarr_versions'); + expect(result.warnings.some(w => w.capability === 'channels')).toBe(true); + expect(result.warnings.some(w => w.capability === 'timepoints')).toBe(true); }); - it('collects both errors and warnings', () => { + it('collects warnings from both feature and metadata checks', () => { const viewer = createViewer({ channels: false, axes: false, @@ -457,9 +605,10 @@ describe('validateViewer', () => { const result = validateViewer(viewer, metadata); - expect(result.compatible).toBe(false); - expect(result.errors.length).toBeGreaterThan(0); - expect(result.warnings.length).toBeGreaterThan(0); + expect(result.dataCompatible).toBe(true); + expect(result.dataFeaturesSupported).toBe(false); + expect(result.errors).toHaveLength(0); + expect(result.warnings.length).toBeGreaterThanOrEqual(3); }); }); }); diff --git a/src/validator.ts b/src/validator.ts index ce35cc9..0b7cc97 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -1,10 +1,23 @@ import type { ViewerManifest, OmeZarrMetadata, + MultiscaleMetadata, ValidationResult, ValidationError, - ValidationWarning -} from './types.js'; + ValidationWarning, +} from "./types.js"; + +function hasTransformationType( + multiscales: MultiscaleMetadata[], + type: "scale" | "translation", +): boolean { + return multiscales.some( + (ms) => + ms.datasets?.some((ds) => + ds.coordinateTransformations?.some((ct) => ct.type === type), + ) || ms.coordinateTransformations?.some((ct) => ct.type === type), + ); +} /** * Validates whether a viewer is compatible with a given OME-Zarr dataset. @@ -15,135 +28,193 @@ import type { */ export function validateViewer( viewer: ViewerManifest, - metadata: OmeZarrMetadata + metadata: OmeZarrMetadata, ): ValidationResult { const errors: ValidationError[] = []; const warnings: ValidationWarning[] = []; // Check version compatibility - this is critical - let dataVersion: number | null = null; + let dataVersion: string | null = null; // Try to extract version from metadata (could be in root or in multiscales) if (metadata.version) { - dataVersion = parseFloat(metadata.version); + dataVersion = metadata.version; } else if ( metadata.multiscales && metadata.multiscales.length > 0 && metadata.multiscales[0].version ) { - dataVersion = parseFloat(metadata.multiscales[0].version); + dataVersion = metadata.multiscales[0].version; } if (dataVersion === null) { errors.push({ - capability: 'ome_zarr_versions', - message: 'Metadata does not specify an OME-Zarr version', - required: 'version', - found: null + capability: "ome_zarr_versions", + message: "Metadata does not specify an OME-Zarr version", + required: "version", + found: null, }); } else if ( !viewer.capabilities.ome_zarr_versions || viewer.capabilities.ome_zarr_versions.length === 0 ) { errors.push({ - capability: 'ome_zarr_versions', + capability: "ome_zarr_versions", message: `Viewer does not specify OME-Zarr version support (data is v${dataVersion})`, required: dataVersion, - found: [] + found: [], }); - } else if (!viewer.capabilities.ome_zarr_versions.includes(dataVersion)) { + } else if ( + !viewer.capabilities.ome_zarr_versions.map(String).includes(dataVersion) + ) { errors.push({ - capability: 'ome_zarr_versions', - message: `Viewer does not support OME-Zarr v${dataVersion} (supports: ${viewer.capabilities.ome_zarr_versions.join(', ')})`, + capability: "ome_zarr_versions", + message: `Viewer does not support OME-Zarr v${dataVersion} (supports: ${viewer.capabilities.ome_zarr_versions.join(", ")})`, required: dataVersion, - found: viewer.capabilities.ome_zarr_versions + found: viewer.capabilities.ome_zarr_versions, }); } - // Check compression codecs - if (metadata.compressor) { - const codec = metadata.compressor.id || metadata.compressor; + // Collect codecs from Zarr v2 (compressor.id) or Zarr v3 (codecs[].name) + const dataCodecs: string[] = []; + if (metadata.compressor?.id) { + dataCodecs.push(metadata.compressor.id); + } else if (metadata.codecs && metadata.codecs.length > 0) { + dataCodecs.push(...metadata.codecs.map((c) => c.name)); + } + + if (dataCodecs.length > 0) { if ( !viewer.capabilities.compression_codecs || viewer.capabilities.compression_codecs.length === 0 ) { // Viewer doesn't declare codec support - can't guarantee compatibility + const codecList = dataCodecs.join("', '"); warnings.push({ - capability: 'compression_codecs', - message: `Data uses codec '${codec}' but viewer doesn't declare codec support - compatibility unknown` - }); - } else if (!viewer.capabilities.compression_codecs.includes(codec)) { - errors.push({ - capability: 'compression_codecs', - message: `Viewer does not support compression codec: ${codec}`, - required: codec, - found: viewer.capabilities.compression_codecs + capability: "compression_codecs", + message: `Data uses codec '${codecList}' but viewer doesn't declare codec support - compatibility unknown`, }); + } else { + for (const codec of dataCodecs) { + if (!viewer.capabilities.compression_codecs.includes(codec)) { + errors.push({ + capability: "compression_codecs", + message: `Viewer does not support compression codec: ${codec}`, + required: codec, + found: viewer.capabilities.compression_codecs, + }); + } + } } } + // TODO: Check rfcs_supported (hard compatibility requirement) + // Blocker: OME-NGFF metadata does not expose which RFCs a dataset requires. + // After determing how to implement this check, compare metadata.rfcs_required against + // viewer.capabilities.rfcs_supported and push to errors[] on mismatch. + // Check axes support if (metadata.axes && !viewer.capabilities.axes) { warnings.push({ - capability: 'axes', - message: 'Dataset has axis metadata but viewer may not respect it' + capability: "axes", + message: "Dataset has axis metadata but viewer may not respect it", + }); + } + + // Check support for respecting scaling factors on multiscales + if ( + metadata.multiscales && + hasTransformationType(metadata.multiscales, "scale") && + !viewer.capabilities.scale + ) { + warnings.push({ + capability: "scale", + message: + "Dataset has coordinate scale transformations but viewer may not respect them", + }); + } + + // Check translation support + if ( + metadata.multiscales && + hasTransformationType(metadata.multiscales, "translation") && + !viewer.capabilities.translation + ) { + warnings.push({ + capability: "translation", + message: + "Dataset has coordinate translation offsets but viewer may not respect them", }); } // Check for multiple channels const hasChannels = metadata.axes?.some( - ax => ax.name === 'c' || ax.type === 'channel' + (ax) => ax.name === "c" || ax.type === "channel", ); if (hasChannels && !viewer.capabilities.channels) { - errors.push({ - capability: 'channels', - message: 'Dataset has multiple channels but viewer does not support them', - required: true, - found: false + warnings.push({ + capability: "channels", + message: "Dataset has multiple channels but viewer may not support them", }); } // Check for timepoints - const hasTime = metadata.axes?.some(ax => ax.name === 't' || ax.type === 'time'); + const hasTime = metadata.axes?.some( + (ax) => ax.name === "t" || ax.type === "time", + ); if (hasTime && !viewer.capabilities.timepoints) { - errors.push({ - capability: 'timepoints', - message: 'Dataset has multiple timepoints but viewer does not support them', - required: true, - found: false + warnings.push({ + capability: "timepoints", + message: + "Dataset has multiple timepoints but viewer may not support them", }); } // Check for labels - if (metadata.labels && metadata.labels.length > 0 && !viewer.capabilities.labels) { + if ( + metadata.labels && + metadata.labels.length > 0 && + !viewer.capabilities.labels + ) { warnings.push({ - capability: 'labels', - message: 'Dataset has labels but viewer may not display them' + capability: "labels", + message: "Dataset has labels but viewer may not display them", }); } // Check for HCS plates if (metadata.plate && !viewer.capabilities.hcs_plates) { - errors.push({ - capability: 'hcs_plates', - message: 'Dataset is an HCS plate but viewer does not support plates', - required: true, - found: false + warnings.push({ + capability: "hcs_plates", + message: "Dataset is an HCS plate but viewer may not support plates", + }); + } + + // Check bioformats2raw layout support + if ( + metadata.bioformats2raw_layout && + !viewer.capabilities.bioformats2raw_layout + ) { + warnings.push({ + capability: "bioformats2raw_layout", + message: + "Dataset uses bioformats2raw layout but viewer may not support it", }); } // Check OMERO metadata if (metadata.omero && !viewer.capabilities.omero_metadata) { warnings.push({ - capability: 'omero_metadata', - message: 'Dataset has OMERO metadata but viewer may not use it' + capability: "omero_metadata", + message: "Dataset has OMERO metadata but viewer may not use it", }); } return { - compatible: errors.length === 0, + dataCompatible: errors.length === 0, + dataFeaturesSupported: warnings.length === 0, errors, - warnings + warnings, }; } @@ -153,11 +224,11 @@ export function validateViewer( * * @param viewer - The viewer manifest to check * @param metadata - The OME-Zarr metadata from the dataset - * @returns True if compatible (no errors), false otherwise + * @returns True if data compatible (no errors), false otherwise */ export function isCompatible( viewer: ViewerManifest, - metadata: OmeZarrMetadata + metadata: OmeZarrMetadata, ): boolean { - return validateViewer(viewer, metadata).compatible; + return validateViewer(viewer, metadata).dataCompatible; } diff --git a/tsconfig.lib.json b/tsconfig.lib.json index 75b9542..9fb9d9b 100644 --- a/tsconfig.lib.json +++ b/tsconfig.lib.json @@ -11,6 +11,7 @@ "src/index.ts", "src/loader.ts", "src/validator.ts", + "src/logo.ts", "src/types.ts" ], "exclude": ["src/app.ts"]