diff --git a/fixtures/geotiff-test-data b/fixtures/geotiff-test-data index 7fa07ac1..6f403786 160000 --- a/fixtures/geotiff-test-data +++ b/fixtures/geotiff-test-data @@ -1 +1 @@ -Subproject commit 7fa07ac10f9d54b2ac2e557117432cf8f2f80eef +Subproject commit 6f4037861ed474d39ef81d02207b3f252c897d7d diff --git a/packages/geotiff/src/array.ts b/packages/geotiff/src/array.ts index a0417089..b25be9c7 100644 --- a/packages/geotiff/src/array.ts +++ b/packages/geotiff/src/array.ts @@ -43,8 +43,13 @@ export type RasterArrayBase = { */ transform: Affine; - /** Coordinate reference system information. */ - crs: number | ProjJson; + /** Coordinate reference system information. + * + * - If `crs` is a number, it is an EPSG code. + * - If `crs` is an object, it is a PROJJSON object. + * - If `crs` is a string, it is an ESRI WKT (this is rare). + */ + crs: number | ProjJson | string; /** Nodata value from `GDAL_NODATA` TIFF tag. */ nodata: number | null; diff --git a/packages/geotiff/src/crs.ts b/packages/geotiff/src/crs.ts index a535edfc..6dce837c 100644 --- a/packages/geotiff/src/crs.ts +++ b/packages/geotiff/src/crs.ts @@ -74,10 +74,13 @@ const LINEAR_UNIT: Record = { * Parse a CRS from a GeoKeyDirectory. * * Returns the EPSG code as a number for EPSG-coded CRSes (letting the caller - * decide how to resolve it), or a PROJJSON object built from the geo keys for - * user-defined CRSes. + * decide how to resolve it), a PROJJSON object built from the geo keys for + * user-defined CRSes, or a raw WKT string when the GeoTIFF carries an + * `ESRI PE String = ...` citation under a fully user-defined model type. */ -export function crsFromGeoKeys(gkd: GeoKeyDirectory): number | ProjJson { +export function crsFromGeoKeys( + gkd: GeoKeyDirectory, +): number | ProjJson | string { const modelType = gkd.modelType; if (modelType === MODEL_TYPE_PROJECTED) { @@ -88,7 +91,35 @@ export function crsFromGeoKeys(gkd: GeoKeyDirectory): number | ProjJson { return _geographicCrs(gkd); } - throw new Error(`Unsupported GeoTIFF model type: ${modelType}`); + if (modelType === USER_DEFINED) { + const wkt = _esriPeString(gkd); + if (wkt !== null) { + return wkt; + } + } + + throw new Error( + `Unsupported GeoTIFF CRS definition with model type: ${modelType}`, + ); +} + +const ESRI_PE_STRING_PREFIX = "ESRI PE String ="; + +/** + * Extract a WKT string from an `ESRI PE String = ...` ProjectedCitation. + * + * The GeoTIFF spec defines no text-based CRS encoding; `ESRI PE String =` is + * a de facto ArcGIS convention that GDAL/PROJ also read. It is used when the + * model type itself is user-defined (32767) and no projection method/parameter + * geo keys are present — the WKT in the citation is then the only CRS signal. + */ +function _esriPeString(gkd: GeoKeyDirectory): string | null { + const citation = gkd.projectedCitation; + if (citation === null || !citation.includes(ESRI_PE_STRING_PREFIX)) { + return null; + } + const wkt = citation.split(ESRI_PE_STRING_PREFIX, 2)[1]?.trim(); + return wkt ? wkt : null; } function _geographicCrs(gkd: GeoKeyDirectory): number | GeographicCRS { diff --git a/packages/geotiff/src/fetch.ts b/packages/geotiff/src/fetch.ts index 299abc2d..11492ee2 100644 --- a/packages/geotiff/src/fetch.ts +++ b/packages/geotiff/src/fetch.ts @@ -25,7 +25,7 @@ interface HasTiffReference extends HasTransform { readonly maskImage: TiffImage | null; /** The coordinate reference system. */ - readonly crs: number | ProjJson; + readonly crs: number | ProjJson | string; /** The height of tiles in pixels. */ readonly tileHeight: number; diff --git a/packages/geotiff/src/geotiff.ts b/packages/geotiff/src/geotiff.ts index f23b0a17..62687c66 100644 --- a/packages/geotiff/src/geotiff.ts +++ b/packages/geotiff/src/geotiff.ts @@ -73,7 +73,7 @@ export class GeoTIFF { readonly overviews: Overview[]; /** A cached CRS value. */ - private _crs?: number | ProjJson; + private _crs?: number | ProjJson | string; /** Cached TIFF tags that are pre-fetched when opening the GeoTIFF. */ readonly cachedTags: CachedTags; @@ -361,15 +361,15 @@ export class GeoTIFF { // ── Properties from the primary image ───────────────────────────────── - /** - * The CRS parsed from the GeoKeyDirectory. + /** Coordinate reference system information. * - * Returns an EPSG code (number) for EPSG-coded CRSes, or a PROJJSON object - * for user-defined CRSes. The result is cached after the first access. + * - If `crs` is a number, it is an EPSG code. + * - If `crs` is an object, it is a PROJJSON object. + * - If `crs` is a string, it is an ESRI WKT (this is rare). * - * See also {@link GeoTIFF.epsg} for the EPSG code directly from the TIFF tags. + * The result is cached after the first access. */ - get crs(): number | ProjJson { + get crs(): number | ProjJson | string { if (this._crs === undefined) { this._crs = crsFromGeoKeys(this.gkd); } diff --git a/packages/geotiff/src/overview.ts b/packages/geotiff/src/overview.ts index 8af8c306..bd681ddb 100644 --- a/packages/geotiff/src/overview.ts +++ b/packages/geotiff/src/overview.ts @@ -48,15 +48,15 @@ export class Overview { this.dataSource = dataSource; } - /** - * The CRS parsed from the GeoKeyDirectory. + /** Coordinate reference system information. * - * Returns an EPSG code (number) for EPSG-coded CRSes, or a PROJJSON object - * for user-defined CRSes. The result is cached after the first access. + * - If `crs` is a number, it is an EPSG code. + * - If `crs` is an object, it is a PROJJSON object. + * - If `crs` is a string, it is an ESRI WKT (this is rare). * - * See also {@link GeoTIFF.epsg} for the EPSG code directly from the TIFF tags. + * The result is cached after the first access. */ - get crs(): number | ProjJson { + get crs(): number | ProjJson | string { return this.geotiff.crs; } diff --git a/packages/geotiff/src/tile-matrix-set.ts b/packages/geotiff/src/tile-matrix-set.ts index 9139f116..b5ee86a3 100644 --- a/packages/geotiff/src/tile-matrix-set.ts +++ b/packages/geotiff/src/tile-matrix-set.ts @@ -29,13 +29,19 @@ interface ProjectionDefinition { const SCREEN_PIXEL_SIZE = 0.28e-3; -function buildCrs(crs: number | ProjJson): CRS { +function buildCrs(crs: number | ProjJson | string): CRS { if (typeof crs === "number") { return { uri: `http://www.opengis.net/def/crs/EPSG/0/${crs}`, }; } + // Raw WKT string (e.g. from an ESRI PE String citation) — the OGC TMS CRS + // type allows a bare string form. + if (typeof crs === "string") { + return crs; + } + // @ts-expect-error - typing issues between different projjson definitions. return { wkt: crs, diff --git a/packages/geotiff/tests/crs.test.ts b/packages/geotiff/tests/crs.test.ts index b9402b83..bc322d21 100644 --- a/packages/geotiff/tests/crs.test.ts +++ b/packages/geotiff/tests/crs.test.ts @@ -108,4 +108,26 @@ describe("test GeoKey CRS parsing", () => { expect(proj.units).toBe("meter"); expect(proj.projName).toBe("Albers Equal Area"); }); + + it("can parse an ESRI PE String citation under a user-defined model type", async () => { + // hfp_2017_100m_v1-2_cog: GTModelTypeGeoKey = 32767 (ModelTypeUserDefined), + // CRS only reconstructable from the `ESRI PE String = ...` citation. + const geotiff = await loadGeoTIFF( + "hfp_2017_100m_v1-2_cog", + "source-coop-vizzuality", + ); + const crs = geotiff.crs; + + if (typeof crs !== "string") { + throw new Error("expected raw WKT string for ESRI PE String CRS"); + } + expect(crs.startsWith("PROJCS[")).toBe(true); + expect(crs).toContain("Mollweide"); + + // wkt-parser must be able to consume the WKT for downstream TMS use. + const proj = parseWkt(crs); + expect(proj.projName).toBe("Mollweide"); + expect(proj.units).toBe("meter"); + expect(proj.a ?? proj.datum?.a).toBe(6378137); + }); });