You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
457 lines
11 KiB
457 lines
11 KiB
/*
|
|
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 { fieldTagNames, fieldTagTypes, fieldTypeNames, geoKeyNames } from './globals.js';
|
|
import { assign, endsWith, forEach, invert, times } from './utils.js';
|
|
|
|
const tagName2Code = invert(fieldTagNames);
|
|
const geoKeyName2Code = invert(geoKeyNames);
|
|
const name2code = {};
|
|
assign(name2code, tagName2Code);
|
|
assign(name2code, geoKeyName2Code);
|
|
const typeName2byte = invert(fieldTypeNames);
|
|
|
|
// config variables
|
|
const numBytesInIfd = 1000;
|
|
|
|
const _binBE = {
|
|
nextZero: (data, o) => {
|
|
let oincr = o;
|
|
while (data[oincr] !== 0) {
|
|
oincr++;
|
|
}
|
|
return oincr;
|
|
},
|
|
readUshort: (buff, p) => {
|
|
return (buff[p] << 8) | buff[p + 1];
|
|
},
|
|
readShort: (buff, p) => {
|
|
const a = _binBE.ui8;
|
|
a[0] = buff[p + 1];
|
|
a[1] = buff[p + 0];
|
|
return _binBE.i16[0];
|
|
},
|
|
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];
|
|
},
|
|
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];
|
|
},
|
|
readASCII: (buff, p, l) => {
|
|
return l.map((i) => String.fromCharCode(buff[p + i])).join('');
|
|
},
|
|
readFloat: (buff, p) => {
|
|
const a = _binBE.ui8;
|
|
times(4, (i) => {
|
|
a[i] = buff[p + 3 - i];
|
|
});
|
|
return _binBE.fl32[0];
|
|
},
|
|
readDouble: (buff, p) => {
|
|
const a = _binBE.ui8;
|
|
times(8, (i) => {
|
|
a[i] = buff[p + 7 - i];
|
|
});
|
|
return _binBE.fl64[0];
|
|
},
|
|
writeUshort: (buff, p, n) => {
|
|
buff[p] = (n >> 8) & 255;
|
|
buff[p + 1] = n & 255;
|
|
},
|
|
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;
|
|
},
|
|
writeASCII: (buff, p, s) => {
|
|
times(s.length, (i) => {
|
|
buff[p + i] = s.charCodeAt(i);
|
|
});
|
|
},
|
|
ui8: new Uint8Array(8),
|
|
};
|
|
|
|
_binBE.fl64 = new Float64Array(_binBE.ui8.buffer);
|
|
|
|
_binBE.writeDouble = (buff, p, n) => {
|
|
_binBE.fl64[0] = n;
|
|
times(8, (i) => {
|
|
buff[p + i] = _binBE.ui8[7 - i];
|
|
});
|
|
};
|
|
|
|
const _writeIFD = (bin, data, _offset, ifd) => {
|
|
let offset = _offset;
|
|
|
|
const keys = Object.keys(ifd).filter((key) => {
|
|
return key !== undefined && key !== null && key !== 'undefined';
|
|
});
|
|
|
|
bin.writeUshort(data, offset, keys.length);
|
|
offset += 2;
|
|
|
|
let eoff = offset + (12 * keys.length) + 4;
|
|
|
|
for (const key of keys) {
|
|
let tag = null;
|
|
if (typeof key === 'number') {
|
|
tag = key;
|
|
} else if (typeof key === 'string') {
|
|
tag = parseInt(key, 10);
|
|
}
|
|
|
|
const typeName = fieldTagTypes[tag];
|
|
const typeNum = typeName2byte[typeName];
|
|
|
|
if (typeName == null || typeName === undefined || typeof typeName === 'undefined') {
|
|
throw new Error(`unknown type of tag: ${tag}`);
|
|
}
|
|
|
|
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, tag);
|
|
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];
|
|
};
|
|
|
|
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;
|
|
};
|
|
|
|
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}`);
|
|
}
|
|
|
|
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[i];
|
|
}
|
|
}
|
|
}
|
|
|
|
const prfx = new Uint8Array(encodeIfds([ifd]));
|
|
|
|
const img = new Uint8Array(values);
|
|
|
|
const samplesPerPixel = ifd[277];
|
|
|
|
const data = new Uint8Array(numBytesInIfd + (width * height * samplesPerPixel));
|
|
times(prfx.length, (i) => {
|
|
data[i] = prfx[i];
|
|
});
|
|
forEach(img, (value, i) => {
|
|
data[numBytesInIfd + i] = value;
|
|
});
|
|
|
|
return data.buffer;
|
|
};
|
|
|
|
const convertToTids = (input) => {
|
|
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;
|
|
};
|
|
|
|
const toArray = (input) => {
|
|
if (Array.isArray(input)) {
|
|
return input;
|
|
}
|
|
return [input];
|
|
};
|
|
|
|
const metadataDefaults = [
|
|
['Compression', 1], // no compression
|
|
['PlanarConfiguration', 1],
|
|
['ExtraSamples', 0],
|
|
];
|
|
|
|
export function writeGeotiff(data, metadata) {
|
|
const isFlattened = typeof data[0] === 'number';
|
|
|
|
let height;
|
|
let numBands;
|
|
let width;
|
|
let flattenedValues;
|
|
|
|
if (isFlattened) {
|
|
height = metadata.height || metadata.ImageLength;
|
|
width = metadata.width || metadata.ImageWidth;
|
|
numBands = data.length / (height * width);
|
|
flattenedValues = data;
|
|
} else {
|
|
numBands = data.length;
|
|
height = data[0].length;
|
|
width = data[0][0].length;
|
|
flattenedValues = [];
|
|
times(height, (rowIndex) => {
|
|
times(width, (columnIndex) => {
|
|
times(numBands, (bandIndex) => {
|
|
flattenedValues.push(data[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) {
|
|
metadata.BitsPerSample = times(numBands, () => 8);
|
|
}
|
|
|
|
metadataDefaults.forEach((tag) => {
|
|
const key = tag[0];
|
|
if (!metadata[key]) {
|
|
const value = tag[1];
|
|
metadata[key] = value;
|
|
}
|
|
});
|
|
|
|
// The color space of the image data.
|
|
// 1=black is zero and 2=RGB.
|
|
if (!metadata.PhotometricInterpretation) {
|
|
metadata.PhotometricInterpretation = metadata.BitsPerSample.length === 3 ? 2 : 1;
|
|
}
|
|
|
|
// The number of components per pixel.
|
|
if (!metadata.SamplesPerPixel) {
|
|
metadata.SamplesPerPixel = [numBands];
|
|
}
|
|
|
|
if (!metadata.StripByteCounts) {
|
|
// we are only writing one strip
|
|
metadata.StripByteCounts = [numBands * height * width];
|
|
}
|
|
|
|
if (!metadata.ModelPixelScale) {
|
|
// assumes raster takes up exactly the whole globe
|
|
metadata.ModelPixelScale = [360 / width, 180 / height, 0];
|
|
}
|
|
|
|
if (!metadata.SampleFormat) {
|
|
metadata.SampleFormat = times(numBands, () => 1);
|
|
}
|
|
|
|
// if didn't pass in projection information, assume the popular 4326 "geographic projection"
|
|
if (!metadata.hasOwnProperty('GeographicTypeGeoKey') && !metadata.hasOwnProperty('ProjectedCSTypeGeoKey')) {
|
|
metadata.GeographicTypeGeoKey = 4326;
|
|
metadata.ModelTiepoint = [0, 0, 0, -180, 90, 0]; // raster fits whole globe
|
|
metadata.GeogCitationGeoKey = 'WGS 84';
|
|
metadata.GTModelTypeGeoKey = 2;
|
|
}
|
|
|
|
const geoKeys = Object.keys(metadata)
|
|
.filter((key) => endsWith(key, 'GeoKey'))
|
|
.sort((a, b) => name2code[a] - name2code[b]);
|
|
|
|
if (!metadata.GeoAsciiParams) {
|
|
let geoAsciiParams = '';
|
|
geoKeys.forEach((name) => {
|
|
const code = Number(name2code[name]);
|
|
const tagType = fieldTagTypes[code];
|
|
if (tagType === 'ASCII') {
|
|
geoAsciiParams += `${metadata[name].toString()}\u0000`;
|
|
}
|
|
});
|
|
if (geoAsciiParams.length > 0) {
|
|
metadata.GeoAsciiParams = geoAsciiParams;
|
|
}
|
|
}
|
|
|
|
if (!metadata.GeoKeyDirectory) {
|
|
const NumberOfKeys = geoKeys.length;
|
|
|
|
const GeoKeyDirectory = [1, 1, 0, NumberOfKeys];
|
|
geoKeys.forEach((geoKey) => {
|
|
const KeyID = Number(name2code[geoKey]);
|
|
GeoKeyDirectory.push(KeyID);
|
|
|
|
let Count;
|
|
let TIFFTagLocation;
|
|
let valueOffset;
|
|
if (fieldTagTypes[KeyID] === 'SHORT') {
|
|
Count = 1;
|
|
TIFFTagLocation = 0;
|
|
valueOffset = metadata[geoKey];
|
|
} else if (geoKey === 'GeogCitationGeoKey') {
|
|
Count = metadata.GeoAsciiParams.length;
|
|
TIFFTagLocation = Number(name2code.GeoAsciiParams);
|
|
valueOffset = 0;
|
|
} else {
|
|
console.log(`[geotiff.js] couldn't get TIFFTagLocation for ${geoKey}`);
|
|
}
|
|
GeoKeyDirectory.push(TIFFTagLocation);
|
|
GeoKeyDirectory.push(Count);
|
|
GeoKeyDirectory.push(valueOffset);
|
|
});
|
|
metadata.GeoKeyDirectory = GeoKeyDirectory;
|
|
}
|
|
|
|
// delete GeoKeys from metadata, because stored in GeoKeyDirectory tag
|
|
for (const geoKey in geoKeys) {
|
|
if (geoKeys.hasOwnProperty(geoKey)) {
|
|
delete metadata[geoKey];
|
|
}
|
|
}
|
|
|
|
[
|
|
'Compression',
|
|
'ExtraSamples',
|
|
'GeographicTypeGeoKey',
|
|
'GTModelTypeGeoKey',
|
|
'GTRasterTypeGeoKey',
|
|
'ImageLength', // synonym of ImageHeight
|
|
'ImageWidth',
|
|
'Orientation',
|
|
'PhotometricInterpretation',
|
|
'ProjectedCSTypeGeoKey',
|
|
'PlanarConfiguration',
|
|
'ResolutionUnit',
|
|
'SamplesPerPixel',
|
|
'XPosition',
|
|
'YPosition',
|
|
].forEach((name) => {
|
|
if (metadata[name]) {
|
|
metadata[name] = toArray(metadata[name]);
|
|
}
|
|
});
|
|
|
|
const encodedMetadata = convertToTids(metadata);
|
|
|
|
const outputImage = encodeImage(flattenedValues, width, height, encodedMetadata);
|
|
|
|
return outputImage;
|
|
}
|