Skip to content
87 changes: 58 additions & 29 deletions src/geotiffwriter.js
Original file line number Diff line number Diff line change
Expand Up @@ -432,50 +432,79 @@ export function writeGeotiff(data, 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 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 (!metadata.GeoKeyDirectory) {
const NumberOfKeys = geoKeys.length;

const GeoKeyDirectory = [1, 1, 0, NumberOfKeys];
// Only build ASCII / DOUBLE params if not provided
let geoAsciiParams = metadata.GeoAsciiParams || '';
let currentAsciiOffset = geoAsciiParams.length;
const geoDoubleParams = metadata.GeoDoubleParams || [];
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 = Number(name2code[geoKey]);
GeoKeyDirectory.push(KeyID);
const tagType = fieldTagTypes[KeyID];
const val = metadata[geoKey];
if (val === undefined) {
return;
}

let Count;
let TIFFTagLocation;
let valueOffset;
if (fieldTagTypes[KeyID] === 'SHORT') {
if (tagType === 'SHORT') {
Count = 1;
TIFFTagLocation = 0;
valueOffset = metadata[geoKey];
} else if (geoKey === 'GeogCitationGeoKey') {
Count = metadata.GeoAsciiParams.length;
TIFFTagLocation = Number(name2code.GeoAsciiParams);
valueOffset = 0;
valueOffset = val;
} else if (tagType === 'ASCII') {
if (!metadata.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 (!metadata.GeoDoubleParams) {
const arr = toArray(val);
TIFFTagLocation = Number(name2code.GeoDoubleParams); // 34736
valueOffset = currentDoubleIndex;
Count = arr.length;
arr.forEach((v) => {
geoDoubleParams.push(Number(v));
currentDoubleIndex++;
});
} else {
return;
}
} else {
console.log(`[geotiff.js] couldn't get TIFFTagLocation for ${geoKey}`);
console.warn(`[geotiff.js] couldn't get TIFFTagLocation for ${geoKey}`);
return;
}
GeoKeyDirectory.push(TIFFTagLocation);
GeoKeyDirectory.push(Count);
GeoKeyDirectory.push(valueOffset);

GeoKeyDirectory.push(KeyID, TIFFTagLocation, Count, valueOffset);
validKeys++;
});

// Write GeoKeyDirectory, GeoAsciiParams, GeoDoubleParams
GeoKeyDirectory[3] = validKeys;
metadata.GeoKeyDirectory = GeoKeyDirectory;
if (!metadata.GeoAsciiParams && geoAsciiParams.length > 0) {
metadata.GeoAsciiParams = geoAsciiParams;
}
if (!metadata.GeoDoubleParams && geoDoubleParams.length > 0) {
metadata.GeoDoubleParams = geoDoubleParams;
}
}

// delete GeoKeys from metadata, because stored in GeoKeyDirectory tag
// cleanup original GeoKeys metadata, because stored in GeoKeyDirectory tag
for (const geoKey of geoKeys) {
if (metadata.hasOwnProperty(geoKey)) {
delete metadata[geoKey];
Expand Down
2 changes: 2 additions & 0 deletions src/globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,8 @@ export const fieldTagTypes = {
2049: 'ASCII',
2052: 'SHORT',
2054: 'SHORT',
2057: 'DOUBLE',
2059: 'DOUBLE',
2060: 'SHORT',
3072: 'SHORT',
3073: 'ASCII',
Expand Down
27 changes: 27 additions & 0 deletions test/geotiff.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1287,6 +1287,33 @@ describe('writeTests', () => {
expect(normalize(fileDirectory.StripByteCounts)).to.equal(normalize(metadata.StripByteCounts));
expect(fileDirectory.GDAL_NODATA).to.equal('0\u0000');
});

it('should write and read back GeoAsciiParams/GeoDoubleParams keys', async () => {
const width = 2;
const height = 2;
const data = new Float32Array(width * height).fill(1);

const metadata = {
width,
height,
GeographicTypeGeoKey: 4326,
GTModelTypeGeoKey: 2,
GeogSemiMajorAxisGeoKey: 6378137.0, // DOUBLE
GeogInvFlatteningGeoKey: 298.257223563, // DOUBLE
GeogCitationGeoKey: 'WGS 84', // ASCII
PCSCitationGeoKey: 'test-ascii', // ASCII
};

const buffer = await writeArrayBuffer(data, metadata);
const tiff = await fromArrayBuffer(buffer);
const image = await tiff.getImage();

const geoKeys = image.getGeoKeys();
expect(geoKeys.GeogSemiMajorAxisGeoKey).to.be.closeTo(6378137.0, 0.000001);
Copy link
Contributor

@DanielJDufour DanielJDufour Sep 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jo-chemla , could you provide a little more background on why this was necessary? I'm assuming it's a floating-point arithmetic thing, but would love to know a little more details. What part of the code do you think is causing this? Thank you!

on somewhat personal note: I've spent more time than I care to admit dealing with floating point arithmetic issues in the geo space, so I prefer it there's a way to preserves numbers while round-tripping that would be awesome!! It'll definitely save later downstream confusion.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was actually not necessary, just switched these lines from closeTo to equal. JS only uses double precision numbers, so it indeed looks like round-tripping does preserve these numbers!

expect(geoKeys.GeogSemiMajorAxisGeoKey).to.equal(6378137.0);

expect(geoKeys.GeogInvFlatteningGeoKey).to.be.closeTo(298.257223563, 0.000001);
expect(geoKeys.GeogCitationGeoKey).to.equal('WGS 84');
expect(geoKeys.PCSCitationGeoKey).to.equal('test-ascii');
});
});

describe('BlockedSource Test', () => {
Expand Down