UNPKG

geotiff

Version:

GeoTIFF image decoding in JavaScript

601 lines 21.3 kB
/* Some parts of this file are based on UTIF.js, which was released under the MIT License. You can view that here: https://github.com/photopea/UTIF.js/blob/master/LICENSE */ import { tags, fieldTagTypes, fieldTypes, geoKeyNames } from './globals.js'; import { assign, endsWith, forEach, invert, times, typeMap, isTypedUintArray, isTypedIntArray, isTypedFloatArray } from './utils.js'; /** @import {GeotiffWriterMetadata} from './geotiff.js' */ /** @typedef {(buff: Uint8Array, p: number) => number} Read */ const tagName2Code = tags; const geoKeyName2Code = invert(geoKeyNames); /** @type {Record<number|string, keyof fieldTagTypes>} */ const name2code = {}; assign(name2code, tagName2Code); assign(name2code, geoKeyName2Code); const typeName2byte = fieldTypes; // config variables const numBytesInIfd = 1000; /** * @typedef {Object} BinBE * @property {(data: Uint8Array, o: number) => number} nextZero * @property {Read} readUshort * @property {Read} readShort * @property {Read} readInt * @property {Read} readUint * @property {(buff: Uint8Array, p: number, l: Array<number>) => string} readASCII * @property {Read} readFloat * @property {Read} readDouble * @property {(buff: Uint8Array, p: number, n: number) => void} writeUshort * @property {(buff: Uint8Array, p: number, n: number) => void} writeUint * @property {(buff: Uint8Array, p: number, s: string) => void} writeASCII * @property {Uint8Array} ui8 * @property {Float64Array} fl64 * @property {Float32Array} fl32 * @property {Uint32Array} ui32 * @property {Int32Array} i32 * @property {Int16Array} i16 * @property {(buff: Uint8Array, p: number, n: number) => void} writeDouble */ const ui8 = new Uint8Array(8); /** @type {BinBE} */ const _binBE = { /** @type {Read} */ nextZero: (data, o) => { let oincr = o; while (data[oincr] !== 0) { oincr++; } return oincr; }, /** @type {Read} */ readUshort: (buff, p) => { return (buff[p] << 8) | buff[p + 1]; }, /** @type {Read} */ readShort: (buff, p) => { const a = _binBE.ui8; a[0] = buff[p + 1]; a[1] = buff[p + 0]; return _binBE.i16[0]; }, /** @type {Read} */ readInt: (buff, p) => { const a = _binBE.ui8; a[0] = buff[p + 3]; a[1] = buff[p + 2]; a[2] = buff[p + 1]; a[3] = buff[p + 0]; return _binBE.i32[0]; }, /** @type {Read} */ readUint: (buff, p) => { const a = _binBE.ui8; a[0] = buff[p + 3]; a[1] = buff[p + 2]; a[2] = buff[p + 1]; a[3] = buff[p + 0]; return _binBE.ui32[0]; }, /** * @param {Uint8Array} buff * @param {number} p * @param {Array<number>} l * @returns {string} */ readASCII: (buff, p, l) => { return l.map((i) => String.fromCharCode(buff[p + i])).join(''); }, /** @type {Read} */ readFloat: (buff, p) => { const a = _binBE.ui8; times(4, (i) => { a[i] = buff[p + 3 - i]; }); return _binBE.fl32[0]; }, /** @type {Read} */ readDouble: (buff, p) => { const a = _binBE.ui8; times(8, (i) => { a[i] = buff[p + 7 - i]; }); return _binBE.fl64[0]; }, /** * @param {Uint8Array} buff * @param {number} p * @param {number} n */ writeUshort: (buff, p, n) => { buff[p] = (n >> 8) & 255; buff[p + 1] = n & 255; }, /** * @param {Uint8Array} buff * @param {number} p * @param {number} n */ writeUint: (buff, p, n) => { buff[p] = (n >> 24) & 255; buff[p + 1] = (n >> 16) & 255; buff[p + 2] = (n >> 8) & 255; buff[p + 3] = (n >> 0) & 255; }, /** * @param {Uint8Array} buff * @param {number} p * @param {string} s */ writeASCII: (buff, p, s) => { times(s.length, (i) => { buff[p + i] = s.charCodeAt(i); }); }, ui8, fl64: new Float64Array(ui8.buffer), fl32: new Float32Array(ui8.buffer), ui32: new Uint32Array(ui8.buffer), i32: new Int32Array(ui8.buffer), i16: new Int16Array(ui8.buffer), /** * @param {Uint8Array} buff * @param {number} p * @param {number} n */ writeDouble: (buff, p, n) => { _binBE.fl64[0] = n; times(8, (i) => { buff[p + i] = _binBE.ui8[7 - i]; }); }, }; /** * @param {BinBE} bin * @param {Uint8Array} data * @param {number} _offset * @param {Record<keyof fieldTagTypes, any>} ifd * @returns {[number, number]} */ const _writeIFD = (bin, data, _offset, ifd) => { let offset = _offset; const keys = /** @type {Array<keyof fieldTagTypes>} */ (Object.keys(ifd).filter((key) => { return key !== undefined && key !== null && key !== 'undefined'; }).map(Number)); bin.writeUshort(data, offset, keys.length); offset += 2; let eoff = offset + (12 * keys.length) + 4; for (const key of keys) { const typeName = /** @type {keyof typeName2byte} */ (fieldTagTypes[key]); const typeNum = typeName2byte[typeName]; if (typeName == null || typeName === undefined || typeof typeName === 'undefined') { throw new Error(`unknown type of tag: ${key}`); } let val = ifd[key]; if (val === undefined) { throw new Error(`failed to get value for key ${key}`); } // ASCIIZ format with trailing 0 character // http://www.fileformat.info/format/tiff/corion.htm // https://stackoverflow.com/questions/7783044/whats-the-difference-between-asciiz-vs-ascii if (typeName === 'ASCII' && typeof val === 'string' && endsWith(val, '\u0000') === false) { val += '\u0000'; } const num = val.length; bin.writeUshort(data, offset, key); offset += 2; bin.writeUshort(data, offset, typeNum); offset += 2; bin.writeUint(data, offset, num); offset += 4; let dlen = [-1, 1, 1, 2, 4, 8, 0, 0, 0, 0, 0, 0, 8][typeNum] * num; let toff = offset; if (dlen > 4) { bin.writeUint(data, offset, eoff); toff = eoff; } if (typeName === 'ASCII') { bin.writeASCII(data, toff, val); } else if (typeName === 'SHORT') { times(num, (i) => { bin.writeUshort(data, toff + (2 * i), val[i]); }); } else if (typeName === 'LONG') { times(num, (i) => { bin.writeUint(data, toff + (4 * i), val[i]); }); } else if (typeName === 'RATIONAL') { times(num, (i) => { bin.writeUint(data, toff + (8 * i), Math.round(val[i] * 10000)); bin.writeUint(data, toff + (8 * i) + 4, 10000); }); } else if (typeName === 'DOUBLE') { times(num, (i) => { bin.writeDouble(data, toff + (8 * i), val[i]); }); } if (dlen > 4) { dlen += (dlen & 1); eoff += dlen; } offset += 4; } return [offset, eoff]; }; /** * @param {Array<Record<keyof fieldTagTypes, unknown>>} ifds * @returns {ArrayBuffer} */ const encodeIfds = (ifds) => { const data = new Uint8Array(numBytesInIfd); let offset = 4; const bin = _binBE; // set big-endian byte-order // https://en.wikipedia.org/wiki/TIFF#Byte_order data[0] = 77; data[1] = 77; // set format-version number // https://en.wikipedia.org/wiki/TIFF#Byte_order data[3] = 42; let ifdo = 8; bin.writeUint(data, offset, ifdo); offset += 4; ifds.forEach((ifd, i) => { const noffs = _writeIFD(bin, data, ifdo, ifd); ifdo = noffs[1]; if (i < ifds.length - 1) { bin.writeUint(data, noffs[0], ifdo); } }); if (data.slice) { return data.slice(0, ifdo).buffer; } // node hasn't implemented slice on Uint8Array yet const result = new Uint8Array(ifdo); for (let i = 0; i < ifdo; i++) { result[i] = data[i]; } return result.buffer; }; /** * @param {Array<number>|import('./geotiff.js').TypedArray} values * @param {number} width * @param {number} height * @param {GeotiffWriterMetadata} metadata * @returns {ArrayBuffer} */ const encodeImage = (values, width, height, metadata) => { if (height === undefined || height === null) { throw new Error(`you passed into encodeImage a width of type ${height}`); } if (width === undefined || width === null) { throw new Error(`you passed into encodeImage a width of type ${width}`); } /** @type {Record<number|string, Array<number|string>|number|string|undefined>} */ const ifd = { 256: [width], // ImageWidth 257: [height], // ImageLength 273: [numBytesInIfd], // strips offset 278: [height], // RowsPerStrip 305: 'geotiff.js', // no array for ASCII(Z) }; if (metadata) { for (const i in metadata) { if (metadata.hasOwnProperty(i)) { ifd[i] = metadata[ /** @type {keyof GeotiffWriterMetadata} */(i)]; } } } const prfx = new Uint8Array(encodeIfds([ifd])); const dataType = /** @type {keyof typeMap} */ (values.constructor.name); const TypedArray = typeMap[dataType]; // default for Float64 let elementSize = 8; if (TypedArray) { elementSize = TypedArray.BYTES_PER_ELEMENT; } const data = new Uint8Array(numBytesInIfd + (values.length * elementSize)); times(prfx.length, (i) => { data[i] = prfx[i]; }); forEach(values, (value, i) => { if (!TypedArray) { data[numBytesInIfd + i] = value; return; } const buffer = new ArrayBuffer(elementSize); const view = new DataView(buffer); if (dataType === 'Float64Array') { view.setFloat64(0, value, false); } else if (dataType === 'Float32Array') { view.setFloat32(0, value, false); } else if (dataType === 'Uint32Array') { view.setUint32(0, value, false); } else if (dataType === 'Uint16Array') { view.setUint16(0, value, false); } else if (dataType === 'Uint8Array') { view.setUint8(0, value); } const typedArray = new Uint8Array(view.buffer); const idx = numBytesInIfd + (i * elementSize); for (let j = 0; j < elementSize; j++) { data[idx + j] = typedArray[j]; } }); return data.buffer; }; /** * @template T * @param {Record<number|string, T>} input * @returns {Record<number|string, T>} */ const convertToTids = (input) => { /** @type {Record<number|string, T>} */ const result = {}; for (const key in input) { if (key !== 'StripOffsets') { if (!name2code[key]) { console.error(key, 'not in name2code:', Object.keys(name2code)); } result[name2code[key]] = input[key]; } } return result; }; /** * @template T * @param {T} input * @returns {T extends any[] ? T : T[]} */ const toArray = (input) => { if (Array.isArray(input)) { return /** @type {T extends any[] ? T : T[]} */ (input); } return /** @type {T extends any[] ? T : T[]} */ ([input]); }; /** @type {Partial<GeotiffWriterMetadata>} */ const metadataDefaults = { Compression: 1, // no compression PlanarConfiguration: 1, ExtraSamples: 0, }; /** * @param {Array<number>|Array<Array<Array<number>>>|import('./geotiff.js').TypedArray} data * @param {GeotiffWriterMetadata} metadata * @returns {ArrayBuffer} */ export function writeGeotiff(data, metadata) { const isFlattened = typeof data[0] === 'number'; /** @type {number} */ let height; /** @type {number} */ let numBands; /** @type {number} */ let width; /** @type {Array<number>} */ let flattenedValues; if (isFlattened) { const arrayFlat = /** @type {Array<number>} */ (data); const metaHeight = metadata.height || metadata.ImageLength; if (metaHeight === undefined || typeof metaHeight !== 'number') { throw new Error('height is required to be a number in metadata if data is a flat array'); } height = metaHeight; const metaWidth = metadata.width || metadata.ImageWidth; if (metaWidth === undefined || typeof metaWidth !== 'number') { throw new Error('width is required to be a number in metadata if data is a flat array'); } width = metaWidth; numBands = arrayFlat.length / (height * width); flattenedValues = arrayFlat; } else { const array3d = /** @type {Array<Array<Array<number>>>} */ (data); numBands = array3d.length; height = array3d[0].length; width = array3d[0][0].length; flattenedValues = []; times(height, (rowIndex) => { times(width, (columnIndex) => { times(numBands, (bandIndex) => { flattenedValues.push(array3d[bandIndex][rowIndex][columnIndex]); }); }); }); } metadata.ImageLength = height; delete metadata.height; metadata.ImageWidth = width; delete metadata.width; // consult https://www.loc.gov/preservation/digital/formats/content/tiff_tags.shtml if (!metadata.BitsPerSample) { let bitsPerSample = 8; if (ArrayBuffer.isView(flattenedValues)) { bitsPerSample = 8 * Object.getPrototypeOf(flattenedValues).BYTES_PER_ELEMENT; } metadata.BitsPerSample = times(numBands, () => bitsPerSample); } const finalMetadata = metadata; if (!('Compression' in finalMetadata)) { finalMetadata.Compression = metadataDefaults.Compression; } if (!('PlanarConfiguration' in finalMetadata)) { finalMetadata.PlanarConfiguration = metadataDefaults.PlanarConfiguration; } if (!('ExtraSamples' in finalMetadata)) { finalMetadata.ExtraSamples = metadataDefaults.ExtraSamples; } // The color space of the image data. // 1=black is zero and 2=RGB. if (!finalMetadata.PhotometricInterpretation) { if (!Array.isArray(finalMetadata.BitsPerSample)) { throw new Error('BitsPerSample must be an array when PhotometricInterpretation is not provided'); } finalMetadata.PhotometricInterpretation = finalMetadata.BitsPerSample.length === 3 ? 2 : 1; } // The number of components per pixel. if (!finalMetadata.SamplesPerPixel) { finalMetadata.SamplesPerPixel = [numBands]; } if (!finalMetadata.StripByteCounts) { // we are only writing one strip // default for Float64 let elementSize = 8; if (ArrayBuffer.isView(flattenedValues)) { elementSize = Object.getPrototypeOf(flattenedValues).BYTES_PER_ELEMENT; } finalMetadata.StripByteCounts = [numBands * elementSize * height * width]; } if (!finalMetadata.ModelPixelScale && !finalMetadata.ModelTransformation) { // assumes raster takes up exactly the whole globe finalMetadata.ModelPixelScale = [360 / width, 180 / height, 0]; } if (!finalMetadata.SampleFormat) { let sampleFormat = 1; if (isTypedFloatArray(flattenedValues)) { sampleFormat = 3; } if (isTypedIntArray(flattenedValues)) { sampleFormat = 2; } if (isTypedUintArray(flattenedValues)) { sampleFormat = 1; } finalMetadata.SampleFormat = times(numBands, () => sampleFormat); } // if didn't pass in projection information, assume the popular 4326 "geographic projection" if (!finalMetadata.hasOwnProperty('GeographicTypeGeoKey') && !finalMetadata.hasOwnProperty('ProjectedCSTypeGeoKey')) { finalMetadata.GeographicTypeGeoKey = 4326; if (!finalMetadata.ModelTransformation) { finalMetadata.ModelTiepoint = [0, 0, 0, -180, 90, 0]; // raster fits whole globe } finalMetadata.GeogCitationGeoKey = 'WGS 84'; finalMetadata.GTModelTypeGeoKey = 2; } const geoKeys = Object.keys(finalMetadata) .filter((key) => endsWith(key, 'GeoKey')) .sort((a, b) => name2code[a] - name2code[b]); // If not provided, build GeoKeyDirectory as well as GeoAsciiParamsTag and GeoDoubleParamsTag // if GeoAsciiParams/GeoDoubleParams were passed in, we assume offsets are already correct // Spec http://geotiff.maptools.org/spec/geotiff2.4.html if (!finalMetadata.GeoKeyDirectory) { // Only build ASCII / DOUBLE params if not provided let geoAsciiParams = finalMetadata.GeoAsciiParams || ''; if (typeof geoAsciiParams !== 'string') { throw new Error('GeoAsciiParams must be a string if provided'); } let currentAsciiOffset = geoAsciiParams.length; const geoDoubleParams = finalMetadata.GeoDoubleParams || []; if (!Array.isArray(geoDoubleParams)) { throw new Error('GeoDoubleParams must be an array if provided'); } let currentDoubleIndex = geoDoubleParams.length; // Since geoKeys already sorted and filtered, do a single pass to append to corresponding directory for SHORT/ASCII/DOUBLE const GeoKeyDirectory = [1, 1, 0, 0]; let validKeys = 0; geoKeys.forEach((geoKey) => { const KeyID = name2code[geoKey]; const tagType = fieldTagTypes[KeyID]; const val = finalMetadata[ /** @type {keyof import('./geotiff.js').GeotiffWriterMetadata} */(geoKey)]; if (val === undefined) { return; } let Count; let TIFFTagLocation; /** @type {number} */ let valueOffset; if (tagType === 'SHORT') { Count = 1; TIFFTagLocation = 0; if (typeof val !== 'number') { throw new Error(`GeoKey ${geoKey} with type SHORT must have a number value`); } valueOffset = val; } else if (tagType === 'ASCII') { if (!finalMetadata.GeoAsciiParams) { const valStr = `${val.toString()}\u0000`; TIFFTagLocation = Number(name2code.GeoAsciiParams); // 34737 valueOffset = currentAsciiOffset; Count = valStr.length; geoAsciiParams += valStr; currentAsciiOffset += valStr.length; } else { return; } } else if (tagType === 'DOUBLE') { if (!finalMetadata.GeoDoubleParams) { const arr = toArray(val); TIFFTagLocation = Number(name2code.GeoDoubleParams); // 34736 valueOffset = currentDoubleIndex; Count = arr.length; for (const v of arr) { geoDoubleParams.push(Number(v)); currentDoubleIndex++; } } else { return; } } else { console.warn(`[geotiff.js] couldn't get TIFFTagLocation for ${geoKey}`); return; } GeoKeyDirectory.push(KeyID, TIFFTagLocation, Count, valueOffset); validKeys++; }); // Write GeoKeyDirectory, GeoAsciiParams, GeoDoubleParams GeoKeyDirectory[3] = validKeys; finalMetadata.GeoKeyDirectory = GeoKeyDirectory; if (!finalMetadata.GeoAsciiParams && geoAsciiParams.length > 0) { finalMetadata.GeoAsciiParams = geoAsciiParams; } if (!finalMetadata.GeoDoubleParams && geoDoubleParams.length > 0) { finalMetadata.GeoDoubleParams = geoDoubleParams; } } // cleanup original GeoKeys metadata, because stored in GeoKeyDirectory tag for (const geoKey of geoKeys) { if (finalMetadata.hasOwnProperty(geoKey)) { delete finalMetadata[ /** @type {keyof import('./geotiff.js').GeotiffWriterMetadata} */(geoKey)]; } } /** @type {const} */ ([ 'Compression', 'ExtraSamples', 'GeographicTypeGeoKey', 'GTModelTypeGeoKey', 'GTRasterTypeGeoKey', 'ImageLength', // synonym of ImageHeight 'ImageWidth', 'Orientation', 'PhotometricInterpretation', 'ProjectedCSTypeGeoKey', 'PlanarConfiguration', 'ResolutionUnit', 'SamplesPerPixel', 'XPosition', 'YPosition', 'RowsPerStrip', ]).forEach((name) => { if (finalMetadata[name]) { finalMetadata[name] = toArray(finalMetadata[name]); } }); const encodedMetadata = convertToTids(finalMetadata); const outputImage = encodeImage(flattenedValues, width, height, encodedMetadata); return outputImage; } //# sourceMappingURL=geotiffwriter.js.map