/** @module geotiff */ import GeoTIFFImage from './geotiffimage.js'; import DataView64 from './dataview64.js'; import DataSlice from './dataslice.js'; import Pool from './pool.js'; import { makeRemoteSource, makeCustomSource } from './source/remote.js'; import { makeBufferSource } from './source/arraybuffer.js'; import { makeFileReaderSource } from './source/filereader.js'; import { makeFileSource } from './source/file.js'; import { BaseClient, BaseResponse } from './source/client/base.js'; import { fieldTypes, fieldTagNames, arrayFields, geoKeyNames } from './globals.js'; import { writeGeotiff } from './geotiffwriter.js'; import * as globals from './globals.js'; import * as rgb from './rgb.js'; import { getDecoder, addDecoder } from './compression/index.js'; import { setLogger } from './logging.js'; export { globals }; export { rgb }; export { default as BaseDecoder } from './compression/basedecoder.js'; export { getDecoder, addDecoder }; export { setLogger }; /** * @typedef {Uint8Array | Int8Array | Uint16Array | Int16Array | Uint32Array | Int32Array | Float32Array | Float64Array} * TypedArray */ /** * @typedef {{ height:number, width: number }} Dimensions */ /** * The autogenerated docs are a little confusing here. The effective type is: * * `TypedArray & { height: number; width: number}` * @typedef {TypedArray & Dimensions} TypedArrayWithDimensions */ /** * The autogenerated docs are a little confusing here. The effective type is: * * `TypedArray[] & { height: number; width: number}` * @typedef {TypedArray[] & Dimensions} TypedArrayArrayWithDimensions */ /** * The autogenerated docs are a little confusing here. The effective type is: * * `(TypedArray | TypedArray[]) & { height: number; width: number}` * @typedef {TypedArrayWithDimensions | TypedArrayArrayWithDimensions} ReadRasterResult */ function getFieldTypeLength(fieldType) { switch (fieldType) { case fieldTypes.BYTE: case fieldTypes.ASCII: case fieldTypes.SBYTE: case fieldTypes.UNDEFINED: return 1; case fieldTypes.SHORT: case fieldTypes.SSHORT: return 2; case fieldTypes.LONG: case fieldTypes.SLONG: case fieldTypes.FLOAT: case fieldTypes.IFD: return 4; case fieldTypes.RATIONAL: case fieldTypes.SRATIONAL: case fieldTypes.DOUBLE: case fieldTypes.LONG8: case fieldTypes.SLONG8: case fieldTypes.IFD8: return 8; default: throw new RangeError(`Invalid field type: ${fieldType}`); } } function parseGeoKeyDirectory(fileDirectory) { const rawGeoKeyDirectory = fileDirectory.GeoKeyDirectory; if (!rawGeoKeyDirectory) { return null; } const geoKeyDirectory = {}; for (let i = 4; i <= rawGeoKeyDirectory[3] * 4; i += 4) { const key = geoKeyNames[rawGeoKeyDirectory[i]]; const location = (rawGeoKeyDirectory[i + 1]) ? (fieldTagNames[rawGeoKeyDirectory[i + 1]]) : null; const count = rawGeoKeyDirectory[i + 2]; const offset = rawGeoKeyDirectory[i + 3]; let value = null; if (!location) { value = offset; } else { value = fileDirectory[location]; if (typeof value === 'undefined' || value === null) { throw new Error(`Could not get value of geoKey '${key}'.`); } else if (typeof value === 'string') { value = value.substring(offset, offset + count - 1); } else if (value.subarray) { value = value.subarray(offset, offset + count); if (count === 1) { value = value[0]; } } } geoKeyDirectory[key] = value; } return geoKeyDirectory; } function getValues(dataSlice, fieldType, count, offset) { let values = null; let readMethod = null; const fieldTypeLength = getFieldTypeLength(fieldType); switch (fieldType) { case fieldTypes.BYTE: case fieldTypes.ASCII: case fieldTypes.UNDEFINED: values = new Uint8Array(count); readMethod = dataSlice.readUint8; break; case fieldTypes.SBYTE: values = new Int8Array(count); readMethod = dataSlice.readInt8; break; case fieldTypes.SHORT: values = new Uint16Array(count); readMethod = dataSlice.readUint16; break; case fieldTypes.SSHORT: values = new Int16Array(count); readMethod = dataSlice.readInt16; break; case fieldTypes.LONG: case fieldTypes.IFD: values = new Uint32Array(count); readMethod = dataSlice.readUint32; break; case fieldTypes.SLONG: values = new Int32Array(count); readMethod = dataSlice.readInt32; break; case fieldTypes.LONG8: case fieldTypes.IFD8: values = new Array(count); readMethod = dataSlice.readUint64; break; case fieldTypes.SLONG8: values = new Array(count); readMethod = dataSlice.readInt64; break; case fieldTypes.RATIONAL: values = new Uint32Array(count * 2); readMethod = dataSlice.readUint32; break; case fieldTypes.SRATIONAL: values = new Int32Array(count * 2); readMethod = dataSlice.readInt32; break; case fieldTypes.FLOAT: values = new Float32Array(count); readMethod = dataSlice.readFloat32; break; case fieldTypes.DOUBLE: values = new Float64Array(count); readMethod = dataSlice.readFloat64; break; default: throw new RangeError(`Invalid field type: ${fieldType}`); } // normal fields if (!(fieldType === fieldTypes.RATIONAL || fieldType === fieldTypes.SRATIONAL)) { for (let i = 0; i < count; ++i) { values[i] = readMethod.call( dataSlice, offset + (i * fieldTypeLength), ); } } else { // RATIONAL or SRATIONAL for (let i = 0; i < count; i += 2) { values[i] = readMethod.call( dataSlice, offset + (i * fieldTypeLength), ); values[i + 1] = readMethod.call( dataSlice, offset + ((i * fieldTypeLength) + 4), ); } } if (fieldType === fieldTypes.ASCII) { return new TextDecoder('utf-8').decode(values); } return values; } /** * Data class to store the parsed file directory, geo key directory and * offset to the next IFD */ class ImageFileDirectory { constructor(fileDirectory, geoKeyDirectory, nextIFDByteOffset) { this.fileDirectory = fileDirectory; this.geoKeyDirectory = geoKeyDirectory; this.nextIFDByteOffset = nextIFDByteOffset; } } /** * Error class for cases when an IFD index was requested, that does not exist * in the file. */ class GeoTIFFImageIndexError extends Error { constructor(index) { super(`No image at index ${index}`); this.index = index; } } class GeoTIFFBase { /** * (experimental) Reads raster data from the best fitting image. This function uses * the image with the lowest resolution that is still a higher resolution than the * requested resolution. * When specified, the `bbox` option is translated to the `window` option and the * `resX` and `resY` to `width` and `height` respectively. * Then, the [readRasters]{@link GeoTIFFImage#readRasters} method of the selected * image is called and the result returned. * @see GeoTIFFImage.readRasters * @param {import('./geotiffimage').ReadRasterOptions} [options={}] optional parameters * @returns {Promise} the decoded array(s), with `height` and `width`, as a promise */ async readRasters(options = {}) { const { window: imageWindow, width, height } = options; let { resX, resY, bbox } = options; const firstImage = await this.getImage(); let usedImage = firstImage; const imageCount = await this.getImageCount(); const imgBBox = firstImage.getBoundingBox(); if (imageWindow && bbox) { throw new Error('Both "bbox" and "window" passed.'); } // if width/height is passed, transform it to resolution if (width || height) { // if we have an image window (pixel coordinates), transform it to a BBox // using the origin/resolution of the first image. if (imageWindow) { const [oX, oY] = firstImage.getOrigin(); const [rX, rY] = firstImage.getResolution(); bbox = [ oX + (imageWindow[0] * rX), oY + (imageWindow[1] * rY), oX + (imageWindow[2] * rX), oY + (imageWindow[3] * rY), ]; } // if we have a bbox (or calculated one) const usedBBox = bbox || imgBBox; if (width) { if (resX) { throw new Error('Both width and resX passed'); } resX = (usedBBox[2] - usedBBox[0]) / width; } if (height) { if (resY) { throw new Error('Both width and resY passed'); } resY = (usedBBox[3] - usedBBox[1]) / height; } } // if resolution is set or calculated, try to get the image with the worst acceptable resolution if (resX || resY) { const allImages = []; for (let i = 0; i < imageCount; ++i) { const image = await this.getImage(i); const { SubfileType: subfileType, NewSubfileType: newSubfileType } = image.fileDirectory; if (i === 0 || subfileType === 2 || newSubfileType & 1) { allImages.push(image); } } allImages.sort((a, b) => a.getWidth() - b.getWidth()); for (let i = 0; i < allImages.length; ++i) { const image = allImages[i]; const imgResX = (imgBBox[2] - imgBBox[0]) / image.getWidth(); const imgResY = (imgBBox[3] - imgBBox[1]) / image.getHeight(); usedImage = image; if ((resX && resX > imgResX) || (resY && resY > imgResY)) { break; } } } let wnd = imageWindow; if (bbox) { const [oX, oY] = firstImage.getOrigin(); const [imageResX, imageResY] = usedImage.getResolution(firstImage); wnd = [ Math.round((bbox[0] - oX) / imageResX), Math.round((bbox[1] - oY) / imageResY), Math.round((bbox[2] - oX) / imageResX), Math.round((bbox[3] - oY) / imageResY), ]; wnd = [ Math.min(wnd[0], wnd[2]), Math.min(wnd[1], wnd[3]), Math.max(wnd[0], wnd[2]), Math.max(wnd[1], wnd[3]), ]; } return usedImage.readRasters({ ...options, window: wnd }); } } /** * @typedef {Object} GeoTIFFOptions * @property {boolean} [cache=false] whether or not decoded tiles shall be cached. */ /** * The abstraction for a whole GeoTIFF file. * @augments GeoTIFFBase */ class GeoTIFF extends GeoTIFFBase { /** * @constructor * @param {*} source The datasource to read from. * @param {boolean} littleEndian Whether the image uses little endian. * @param {boolean} bigTiff Whether the image uses bigTIFF conventions. * @param {number} firstIFDOffset The numeric byte-offset from the start of the image * to the first IFD. * @param {GeoTIFFOptions} [options] further options. */ constructor(source, littleEndian, bigTiff, firstIFDOffset, options = {}) { super(); this.source = source; this.littleEndian = littleEndian; this.bigTiff = bigTiff; this.firstIFDOffset = firstIFDOffset; this.cache = options.cache || false; this.ifdRequests = []; this.ghostValues = null; } async getSlice(offset, size) { const fallbackSize = this.bigTiff ? 4048 : 1024; return new DataSlice( (await this.source.fetch([{ offset, length: typeof size !== 'undefined' ? size : fallbackSize, }]))[0], offset, this.littleEndian, this.bigTiff, ); } /** * Instructs to parse an image file directory at the given file offset. * As there is no way to ensure that a location is indeed the start of an IFD, * this function must be called with caution (e.g only using the IFD offsets from * the headers or other IFDs). * @param {number} offset the offset to parse the IFD at * @returns {Promise} the parsed IFD */ async parseFileDirectoryAt(offset) { const entrySize = this.bigTiff ? 20 : 12; const offsetSize = this.bigTiff ? 8 : 2; let dataSlice = await this.getSlice(offset); const numDirEntries = this.bigTiff ? dataSlice.readUint64(offset) : dataSlice.readUint16(offset); // if the slice does not cover the whole IFD, request a bigger slice, where the // whole IFD fits: num of entries + n x tag length + offset to next IFD const byteSize = (numDirEntries * entrySize) + (this.bigTiff ? 16 : 6); if (!dataSlice.covers(offset, byteSize)) { dataSlice = await this.getSlice(offset, byteSize); } const fileDirectory = {}; // loop over the IFD and create a file directory object let i = offset + (this.bigTiff ? 8 : 2); for (let entryCount = 0; entryCount < numDirEntries; i += entrySize, ++entryCount) { const fieldTag = dataSlice.readUint16(i); const fieldType = dataSlice.readUint16(i + 2); const typeCount = this.bigTiff ? dataSlice.readUint64(i + 4) : dataSlice.readUint32(i + 4); let fieldValues; let value; const fieldTypeLength = getFieldTypeLength(fieldType); const valueOffset = i + (this.bigTiff ? 12 : 8); // check whether the value is directly encoded in the tag or refers to a // different external byte range if (fieldTypeLength * typeCount <= (this.bigTiff ? 8 : 4)) { fieldValues = getValues(dataSlice, fieldType, typeCount, valueOffset); } else { // resolve the reference to the actual byte range const actualOffset = dataSlice.readOffset(valueOffset); const length = getFieldTypeLength(fieldType) * typeCount; // check, whether we actually cover the referenced byte range; if not, // request a new slice of bytes to read from it if (dataSlice.covers(actualOffset, length)) { fieldValues = getValues(dataSlice, fieldType, typeCount, actualOffset); } else { const fieldDataSlice = await this.getSlice(actualOffset, length); fieldValues = getValues(fieldDataSlice, fieldType, typeCount, actualOffset); } } // unpack single values from the array if (typeCount === 1 && arrayFields.indexOf(fieldTag) === -1 && !(fieldType === fieldTypes.RATIONAL || fieldType === fieldTypes.SRATIONAL)) { value = fieldValues[0]; } else { value = fieldValues; } // write the tags value to the file directly fileDirectory[fieldTagNames[fieldTag]] = value; } const geoKeyDirectory = parseGeoKeyDirectory(fileDirectory); const nextIFDByteOffset = dataSlice.readOffset( offset + offsetSize + (entrySize * numDirEntries), ); return new ImageFileDirectory( fileDirectory, geoKeyDirectory, nextIFDByteOffset, ); } async requestIFD(index) { // see if we already have that IFD index requested. if (this.ifdRequests[index]) { // attach to an already requested IFD return this.ifdRequests[index]; } else if (index === 0) { // special case for index 0 this.ifdRequests[index] = this.parseFileDirectoryAt(this.firstIFDOffset); return this.ifdRequests[index]; } else if (!this.ifdRequests[index - 1]) { // if the previous IFD was not yet loaded, load that one first // this is the recursive call. try { this.ifdRequests[index - 1] = this.requestIFD(index - 1); } catch (e) { // if the previous one already was an index error, rethrow // with the current index if (e instanceof GeoTIFFImageIndexError) { throw new GeoTIFFImageIndexError(index); } // rethrow anything else throw e; } } // if the previous IFD was loaded, we can finally fetch the one we are interested in. // we need to wrap this in an IIFE, otherwise this.ifdRequests[index] would be delayed this.ifdRequests[index] = (async () => { const previousIfd = await this.ifdRequests[index - 1]; if (previousIfd.nextIFDByteOffset === 0) { throw new GeoTIFFImageIndexError(index); } return this.parseFileDirectoryAt(previousIfd.nextIFDByteOffset); })(); return this.ifdRequests[index]; } /** * Get the n-th internal subfile of an image. By default, the first is returned. * * @param {number} [index=0] the index of the image to return. * @returns {Promise} the image at the given index */ async getImage(index = 0) { const ifd = await this.requestIFD(index); return new GeoTIFFImage( ifd.fileDirectory, ifd.geoKeyDirectory, this.dataView, this.littleEndian, this.cache, this.source, ); } /** * Returns the count of the internal subfiles. * * @returns {Promise} the number of internal subfile images */ async getImageCount() { let index = 0; // loop until we run out of IFDs let hasNext = true; while (hasNext) { try { await this.requestIFD(index); ++index; } catch (e) { if (e instanceof GeoTIFFImageIndexError) { hasNext = false; } else { throw e; } } } return index; } /** * Get the values of the COG ghost area as a parsed map. * See https://gdal.org/drivers/raster/cog.html#header-ghost-area for reference * @returns {Promise} the parsed ghost area or null, if no such area was found */ async getGhostValues() { const offset = this.bigTiff ? 16 : 8; if (this.ghostValues) { return this.ghostValues; } const detectionString = 'GDAL_STRUCTURAL_METADATA_SIZE='; const heuristicAreaSize = detectionString.length + 100; let slice = await this.getSlice(offset, heuristicAreaSize); if (detectionString === getValues(slice, fieldTypes.ASCII, detectionString.length, offset)) { const valuesString = getValues(slice, fieldTypes.ASCII, heuristicAreaSize, offset); const firstLine = valuesString.split('\n')[0]; const metadataSize = Number(firstLine.split('=')[1].split(' ')[0]) + firstLine.length; if (metadataSize > heuristicAreaSize) { slice = await this.getSlice(offset, metadataSize); } const fullString = getValues(slice, fieldTypes.ASCII, metadataSize, offset); this.ghostValues = {}; fullString .split('\n') .filter((line) => line.length > 0) .map((line) => line.split('=')) .forEach(([key, value]) => { this.ghostValues[key] = value; }); } return this.ghostValues; } /** * Parse a (Geo)TIFF file from the given source. * * @param {*} source The source of data to parse from. * @param {GeoTIFFOptions} [options] Additional options. * @param {AbortSignal} [signal] An AbortSignal that may be signalled if the request is * to be aborted */ static async fromSource(source, options, signal) { const headerData = (await source.fetch([{ offset: 0, length: 1024 }], signal))[0]; const dataView = new DataView64(headerData); const BOM = dataView.getUint16(0, 0); let littleEndian; if (BOM === 0x4949) { littleEndian = true; } else if (BOM === 0x4D4D) { littleEndian = false; } else { throw new TypeError('Invalid byte order value.'); } const magicNumber = dataView.getUint16(2, littleEndian); let bigTiff; if (magicNumber === 42) { bigTiff = false; } else if (magicNumber === 43) { bigTiff = true; const offsetByteSize = dataView.getUint16(4, littleEndian); if (offsetByteSize !== 8) { throw new Error('Unsupported offset byte-size.'); } } else { throw new TypeError('Invalid magic number.'); } const firstIFDOffset = bigTiff ? dataView.getUint64(8, littleEndian) : dataView.getUint32(4, littleEndian); return new GeoTIFF(source, littleEndian, bigTiff, firstIFDOffset, options); } /** * Closes the underlying file buffer * N.B. After the GeoTIFF has been completely processed it needs * to be closed but only if it has been constructed from a file. */ close() { if (typeof this.source.close === 'function') { return this.source.close(); } return false; } } export { GeoTIFF }; export default GeoTIFF; /** * Wrapper for GeoTIFF files that have external overviews. * @augments GeoTIFFBase */ class MultiGeoTIFF extends GeoTIFFBase { /** * Construct a new MultiGeoTIFF from a main and several overview files. * @param {GeoTIFF} mainFile The main GeoTIFF file. * @param {GeoTIFF[]} overviewFiles An array of overview files. */ constructor(mainFile, overviewFiles) { super(); this.mainFile = mainFile; this.overviewFiles = overviewFiles; this.imageFiles = [mainFile].concat(overviewFiles); this.fileDirectoriesPerFile = null; this.fileDirectoriesPerFileParsing = null; this.imageCount = null; } async parseFileDirectoriesPerFile() { const requests = [this.mainFile.parseFileDirectoryAt(this.mainFile.firstIFDOffset)] .concat(this.overviewFiles.map((file) => file.parseFileDirectoryAt(file.firstIFDOffset))); this.fileDirectoriesPerFile = await Promise.all(requests); return this.fileDirectoriesPerFile; } /** * Get the n-th internal subfile of an image. By default, the first is returned. * * @param {number} [index=0] the index of the image to return. * @returns {Promise} the image at the given index */ async getImage(index = 0) { await this.getImageCount(); await this.parseFileDirectoriesPerFile(); let visited = 0; let relativeIndex = 0; for (let i = 0; i < this.imageFiles.length; i++) { const imageFile = this.imageFiles[i]; for (let ii = 0; ii < this.imageCounts[i]; ii++) { if (index === visited) { const ifd = await imageFile.requestIFD(relativeIndex); return new GeoTIFFImage( ifd.fileDirectory, ifd.geoKeyDirectory, imageFile.dataView, imageFile.littleEndian, imageFile.cache, imageFile.source, ); } visited++; relativeIndex++; } relativeIndex = 0; } throw new RangeError('Invalid image index'); } /** * Returns the count of the internal subfiles. * * @returns {Promise} the number of internal subfile images */ async getImageCount() { if (this.imageCount !== null) { return this.imageCount; } const requests = [this.mainFile.getImageCount()] .concat(this.overviewFiles.map((file) => file.getImageCount())); this.imageCounts = await Promise.all(requests); this.imageCount = this.imageCounts.reduce((count, ifds) => count + ifds, 0); return this.imageCount; } } export { MultiGeoTIFF }; /** * Creates a new GeoTIFF from a remote URL. * @param {string} url The URL to access the image from * @param {object} [options] Additional options to pass to the source. * See {@link makeRemoteSource} for details. * @param {AbortSignal} [signal] An AbortSignal that may be signalled if the request is * to be aborted * @returns {Promise} The resulting GeoTIFF file. */ export async function fromUrl(url, options = {}, signal) { return GeoTIFF.fromSource(makeRemoteSource(url, options), signal); } /** * Creates a new GeoTIFF from a custom {@link BaseClient}. * @param {BaseClient} client The client. * @param {object} [options] Additional options to pass to the source. * See {@link makeRemoteSource} for details. * @param {AbortSignal} [signal] An AbortSignal that may be signalled if the request is * to be aborted * @returns {Promise} The resulting GeoTIFF file. */ export async function fromCustomClient(client, options = {}, signal) { return GeoTIFF.fromSource(makeCustomSource(client, options), signal); } /** * Construct a new GeoTIFF from an * [ArrayBuffer]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer}. * @param {ArrayBuffer} arrayBuffer The data to read the file from. * @param {AbortSignal} [signal] An AbortSignal that may be signalled if the request is * to be aborted * @returns {Promise} The resulting GeoTIFF file. */ export async function fromArrayBuffer(arrayBuffer, signal) { return GeoTIFF.fromSource(makeBufferSource(arrayBuffer), signal); } /** * Construct a GeoTIFF from a local file path. This uses the node * [filesystem API]{@link https://nodejs.org/api/fs.html} and is * not available on browsers. * * N.B. After the GeoTIFF has been completely processed it needs * to be closed but only if it has been constructed from a file. * @param {string} path The file path to read from. * @param {AbortSignal} [signal] An AbortSignal that may be signalled if the request is * to be aborted * @returns {Promise} The resulting GeoTIFF file. */ export async function fromFile(path, signal) { return GeoTIFF.fromSource(makeFileSource(path), signal); } /** * Construct a GeoTIFF from an HTML * [Blob]{@link https://developer.mozilla.org/en-US/docs/Web/API/Blob} or * [File]{@link https://developer.mozilla.org/en-US/docs/Web/API/File} * object. * @param {Blob|File} blob The Blob or File object to read from. * @param {AbortSignal} [signal] An AbortSignal that may be signalled if the request is * to be aborted * @returns {Promise} The resulting GeoTIFF file. */ export async function fromBlob(blob, signal) { return GeoTIFF.fromSource(makeFileReaderSource(blob), signal); } /** * Construct a MultiGeoTIFF from the given URLs. * @param {string} mainUrl The URL for the main file. * @param {string[]} overviewUrls An array of URLs for the overview images. * @param {Object} [options] Additional options to pass to the source. * See [makeRemoteSource]{@link module:source.makeRemoteSource} * for details. * @param {AbortSignal} [signal] An AbortSignal that may be signalled if the request is * to be aborted * @returns {Promise} The resulting MultiGeoTIFF file. */ export async function fromUrls(mainUrl, overviewUrls = [], options = {}, signal) { const mainFile = await GeoTIFF.fromSource(makeRemoteSource(mainUrl, options), signal); const overviewFiles = await Promise.all( overviewUrls.map((url) => GeoTIFF.fromSource(makeRemoteSource(url, options))), ); return new MultiGeoTIFF(mainFile, overviewFiles); } /** * Main creating function for GeoTIFF files. * @param {(Array)} array of pixel values * @returns {metadata} metadata */ export function writeArrayBuffer(values, metadata) { return writeGeotiff(values, metadata); } export { Pool }; export { GeoTIFFImage }; export { BaseClient, BaseResponse };