@georeferencing/plugins 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +321 -0
- package/dist/data-export.d.ts +9 -0
- package/dist/data-export.js +57 -0
- package/dist/data.d.ts +13 -0
- package/dist/data.js +38 -0
- package/dist/geotiff.d.ts +13 -0
- package/dist/geotiff.js +24 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +9 -0
- package/dist/jpeg.d.ts +13 -0
- package/dist/jpeg.js +33 -0
- package/dist/licenses/APACHE-2.0.txt +202 -0
- package/dist/licenses/GDAL-NOTICE.txt +24 -0
- package/dist/licenses/inventory.json +34 -0
- package/dist/licenses/is-any-array-LICENSE +21 -0
- package/dist/licenses/mgrs-license.md +19 -0
- package/dist/licenses/ml-array-max-LICENSE +21 -0
- package/dist/licenses/ml-array-min-LICENSE +21 -0
- package/dist/licenses/ml-array-rescale-LICENSE +21 -0
- package/dist/licenses/ml-matrix-LICENSE +22 -0
- package/dist/licenses/proj4-LICENSE.md +29 -0
- package/dist/licenses/wkt-parser-LICENSE.md +29 -0
- package/dist/pdf.d.ts +14 -0
- package/dist/pdf.js +23 -0
- package/dist/raster-export.d.ts +4 -0
- package/dist/raster-export.js +58 -0
- package/dist/report.d.ts +80 -0
- package/dist/report.js +224 -0
- package/dist/serializers.d.ts +33 -0
- package/dist/serializers.js +68 -0
- package/dist/tiff.d.ts +45 -0
- package/dist/tiff.js +249 -0
- package/dist/types.d.ts +14 -0
- package/dist/types.js +1 -0
- package/dist/workers/geotiff.d.ts +1 -0
- package/dist/workers/geotiff.js +4 -0
- package/dist/workers/jpeg.d.ts +1 -0
- package/dist/workers/jpeg.js +1 -0
- package/package.json +78 -0
package/dist/report.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { coordinateUnits, ENGINE_VERSION, fail, forward, project, } from "@georeferencing/core";
|
|
2
|
+
import { accuracyReport } from "./serializers.js";
|
|
3
|
+
async function png(canvas) {
|
|
4
|
+
try {
|
|
5
|
+
return await new Promise((resolve, reject) => canvas.toBlob((value) => value
|
|
6
|
+
? resolve(value)
|
|
7
|
+
: reject(Error("Could not encode report image.")), "image/png"));
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return fail("REPORT_CORS", "A map layer prevents canvas export. Configure CORS on its source, or generate the report without the optional host-map frame.");
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Capture currently rendered canvas layers as PNG without changing the host view.
|
|
15
|
+
*
|
|
16
|
+
* Calls renderSync and copies available canvas content; does not await outstanding tiles or capture arbitrary DOM overlays. WebGL/non-canvas renderers are outside this capture contract.
|
|
17
|
+
* @param map - Visible initialized host map.
|
|
18
|
+
* @throws {@link core.GeoreferenceError} If the viewport is unavailable or a source taints canvas export.
|
|
19
|
+
*/
|
|
20
|
+
export async function captureMap(map) {
|
|
21
|
+
map.renderSync();
|
|
22
|
+
const size = map.getSize();
|
|
23
|
+
if (!size || size[0] <= 0 || size[1] <= 0)
|
|
24
|
+
fail("REPORT_MAP", "Map has no visible viewport.");
|
|
25
|
+
const canvas = document.createElement("canvas");
|
|
26
|
+
canvas.width = size[0];
|
|
27
|
+
canvas.height = size[1];
|
|
28
|
+
const context = canvas.getContext("2d");
|
|
29
|
+
try {
|
|
30
|
+
for (const layer of map
|
|
31
|
+
.getViewport()
|
|
32
|
+
.querySelectorAll(".ol-layer canvas")) {
|
|
33
|
+
if (!layer.width || !layer.height)
|
|
34
|
+
continue;
|
|
35
|
+
context.save();
|
|
36
|
+
context.globalAlpha = Number(layer.parentElement?.style.opacity || layer.style.opacity || 1);
|
|
37
|
+
const transform = layer.style.transform;
|
|
38
|
+
if (transform && transform !== "none")
|
|
39
|
+
context.setTransform(new DOMMatrix(transform));
|
|
40
|
+
else
|
|
41
|
+
context.scale(parseFloat(layer.style.width || String(layer.width)) / layer.width, parseFloat(layer.style.height || String(layer.height)) / layer.height);
|
|
42
|
+
context.drawImage(layer, 0, 0);
|
|
43
|
+
context.restore();
|
|
44
|
+
}
|
|
45
|
+
return await png(canvas);
|
|
46
|
+
}
|
|
47
|
+
finally {
|
|
48
|
+
canvas.width = 0;
|
|
49
|
+
canvas.height = 0;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Create a local PDF with source identity, alignment diagnostics, GCPs, raster overview and an embedded full-precision alignment.json attachment. pdf-lib loads lazily.
|
|
54
|
+
*
|
|
55
|
+
* Training residuals are not independent accuracy measurements. The caller must pass document, fit and raster from the same alignment; use a controller export result for its exact revision.
|
|
56
|
+
* @param doc - Revisioned document snapshot.
|
|
57
|
+
* @param fit - Fit corresponding to that document alignment.
|
|
58
|
+
* @param raster - Georeferenced preview/export pixels for that alignment.
|
|
59
|
+
* @param options - Paper layout, projections and optional current map capture.
|
|
60
|
+
* @returns PDF Blob suitable for download; no host persistence is performed.
|
|
61
|
+
*/
|
|
62
|
+
export async function createPdfReport(doc, fit, raster, options = {}) {
|
|
63
|
+
const capturedMap = options.map
|
|
64
|
+
? {
|
|
65
|
+
frame: {
|
|
66
|
+
crs: options.map.getView().getProjection().getCode(),
|
|
67
|
+
extent: options.map.getView().calculateExtent(options.map.getSize()),
|
|
68
|
+
rotation: options.map.getView().getRotation(),
|
|
69
|
+
},
|
|
70
|
+
attribution: options.attribution ??
|
|
71
|
+
options.map.getViewport().querySelector(".ol-attribution")
|
|
72
|
+
?.textContent ??
|
|
73
|
+
"Attribution: supplied by the host map.",
|
|
74
|
+
blob: await captureMap(options.map),
|
|
75
|
+
}
|
|
76
|
+
: undefined;
|
|
77
|
+
const { PDFDocument, StandardFonts, rgb } = await import("pdf-lib");
|
|
78
|
+
const pdf = await PDFDocument.create(), font = await pdf.embedFont(StandardFonts.Helvetica), mono = await pdf.embedFont(StandardFonts.Courier);
|
|
79
|
+
const sizes = {
|
|
80
|
+
A4: [595.28, 841.89],
|
|
81
|
+
A3: [841.89, 1190.55],
|
|
82
|
+
Letter: [612, 792],
|
|
83
|
+
};
|
|
84
|
+
const paper = sizes[options.paper ?? "A4"], [pageWidth, pageHeight] = options.landscape ? [...paper].reverse() : paper;
|
|
85
|
+
const margin = options.margin ?? 40, contentWidth = pageWidth - margin * 2;
|
|
86
|
+
if (!Number.isFinite(margin) ||
|
|
87
|
+
margin < 15 ||
|
|
88
|
+
contentWidth < 200 ||
|
|
89
|
+
pageHeight - margin * 2 < 200)
|
|
90
|
+
fail("REPORT_LAYOUT", "PDF margins leave insufficient room for the report.");
|
|
91
|
+
pdf.setTitle("Georeferencing map and alignment report");
|
|
92
|
+
pdf.setProducer(ENGINE_VERSION);
|
|
93
|
+
await pdf.attach(new TextEncoder().encode(accuracyReport(doc, fit)), "alignment.json", {
|
|
94
|
+
mimeType: "application/json",
|
|
95
|
+
description: "Complete Unicode source identity, revisioned document, coefficients and diagnostics",
|
|
96
|
+
});
|
|
97
|
+
let page = pdf.addPage([pageWidth, pageHeight]), y = pageHeight - margin;
|
|
98
|
+
const newPage = () => {
|
|
99
|
+
page = pdf.addPage([pageWidth, pageHeight]);
|
|
100
|
+
y = pageHeight - margin;
|
|
101
|
+
};
|
|
102
|
+
const text = (value, size = 10, fixed = false) => {
|
|
103
|
+
const safe = value.replace(/[^\x20-\x7e]/g, "?"), width = Math.max(1, Math.floor(contentWidth / (size * (fixed ? 0.6 : 0.55))));
|
|
104
|
+
for (let i = 0; i < safe.length || i === 0; i += width) {
|
|
105
|
+
if (y < margin + size)
|
|
106
|
+
newPage();
|
|
107
|
+
page.drawText(safe.slice(i, i + width), {
|
|
108
|
+
x: margin,
|
|
109
|
+
y,
|
|
110
|
+
size,
|
|
111
|
+
font: fixed ? mono : font,
|
|
112
|
+
color: rgb(0.12, 0.2, 0.17),
|
|
113
|
+
});
|
|
114
|
+
y -= size + 5;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const drawImage = async (blob, maxHeight) => {
|
|
118
|
+
const image = await pdf.embedPng(await blob.arrayBuffer()), scale = Math.min(contentWidth / image.width, maxHeight / image.height);
|
|
119
|
+
const width = image.width * scale, height = image.height * scale;
|
|
120
|
+
if (y - height - 20 < margin)
|
|
121
|
+
newPage();
|
|
122
|
+
y -= height + 10;
|
|
123
|
+
page.drawImage(image, { x: margin, y, width, height });
|
|
124
|
+
return { x: margin, y, width, height };
|
|
125
|
+
};
|
|
126
|
+
text("Georeferencing map & alignment report", 20);
|
|
127
|
+
text(`Source: ${doc.sourceImage?.name} | ${doc.sourceImage?.width} x ${doc.sourceImage?.height} pixels`);
|
|
128
|
+
text(`Document ${doc.id} / revision ${doc.documentRevision}`);
|
|
129
|
+
text(`Model ${doc.model} | Working CRS ${doc.workingCrs} | Alignment revision ${doc.alignmentRevision}`);
|
|
130
|
+
text(`Confirmed: ${doc.confirmedAlignmentRevision === doc.alignmentRevision} | Feature review: ${doc.featuresReviewedAgainstAlignmentRevision === doc.alignmentRevision}`);
|
|
131
|
+
text(`Grid extent in ${raster.crs}: ${raster.bounds.map((v) => v.toPrecision(10)).join(", ")}`, 9);
|
|
132
|
+
const canvas = document.createElement("canvas");
|
|
133
|
+
canvas.width = raster.width;
|
|
134
|
+
canvas.height = raster.height;
|
|
135
|
+
canvas
|
|
136
|
+
.getContext("2d")
|
|
137
|
+
.putImageData(new ImageData(raster.data, raster.width, raster.height), 0, 0);
|
|
138
|
+
let imageBlob;
|
|
139
|
+
try {
|
|
140
|
+
imageBlob = await png(canvas);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
canvas.width = 0;
|
|
144
|
+
canvas.height = 0;
|
|
145
|
+
}
|
|
146
|
+
const box = await drawImage(imageBlob, Math.min(350, pageHeight * 0.4));
|
|
147
|
+
const toPage = (p) => [
|
|
148
|
+
box.x +
|
|
149
|
+
((p[0] - raster.bounds[0]) / (raster.bounds[2] - raster.bounds[0])) *
|
|
150
|
+
box.width,
|
|
151
|
+
box.y +
|
|
152
|
+
((p[1] - raster.bounds[1]) / (raster.bounds[3] - raster.bounds[1])) *
|
|
153
|
+
box.height,
|
|
154
|
+
];
|
|
155
|
+
const vectors = doc.gcps.map((gcp) => ({
|
|
156
|
+
gcp,
|
|
157
|
+
a: toPage(project(gcp.target, gcp.crs, raster.crs, options.definitions)),
|
|
158
|
+
b: toPage(project(forward(fit, gcp.image), doc.workingCrs, raster.crs, options.definitions)),
|
|
159
|
+
}));
|
|
160
|
+
const max = Math.max(0, ...vectors
|
|
161
|
+
.filter((v) => v.gcp.enabled)
|
|
162
|
+
.map(({ a, b }) => Math.hypot(b[0] - a[0], b[1] - a[1]))), exaggeration = max > 1e-6
|
|
163
|
+
? Math.min(1000, Math.max(1, (Math.min(box.width, box.height) * 0.12) / max))
|
|
164
|
+
: 1;
|
|
165
|
+
for (const { gcp, a, b } of vectors) {
|
|
166
|
+
if (a[0] < box.x ||
|
|
167
|
+
a[0] > box.x + box.width ||
|
|
168
|
+
a[1] < box.y ||
|
|
169
|
+
a[1] > box.y + box.height)
|
|
170
|
+
continue;
|
|
171
|
+
const color = gcp.enabled ? rgb(0.8, 0.25, 0.1) : rgb(0.4, 0.4, 0.4);
|
|
172
|
+
page.drawCircle({ x: a[0], y: a[1], size: 2, color });
|
|
173
|
+
page.drawText(String(gcp.label), {
|
|
174
|
+
x: a[0] + 3,
|
|
175
|
+
y: a[1] + 3,
|
|
176
|
+
size: 8,
|
|
177
|
+
font,
|
|
178
|
+
color,
|
|
179
|
+
});
|
|
180
|
+
if (gcp.enabled)
|
|
181
|
+
page.drawLine({
|
|
182
|
+
start: { x: a[0], y: a[1] },
|
|
183
|
+
end: {
|
|
184
|
+
x: a[0] + (b[0] - a[0]) * exaggeration,
|
|
185
|
+
y: a[1] + (b[1] - a[1]) * exaggeration,
|
|
186
|
+
},
|
|
187
|
+
thickness: 1,
|
|
188
|
+
color,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
y -= 20;
|
|
192
|
+
text(`Control-point residual vectors exaggerated ${exaggeration.toPrecision(4)}x. Grid north is up.`);
|
|
193
|
+
text(`RMSE: ${fit.rmse.toPrecision(12)} ${coordinateUnits(doc.workingCrs, options.definitions)} in ${doc.workingCrs}`);
|
|
194
|
+
text("r = T(image) - target; RMSE = sqrt(sum(||r||^2)/enabledCount).");
|
|
195
|
+
text("Training residuals do not measure independent positional accuracy.");
|
|
196
|
+
text(`Condition number: ${fit.condition}; backward mapping: ${fit.backward}`);
|
|
197
|
+
text(`Engine: ${ENGINE_VERSION}; output: ${JSON.stringify(doc.output)}`, 9);
|
|
198
|
+
text(`SHA-256: ${doc.sourceImage?.fingerprint}`, 8, true);
|
|
199
|
+
if (capturedMap) {
|
|
200
|
+
newPage();
|
|
201
|
+
text("Host map: currently rendered layers and drawings", 16);
|
|
202
|
+
text(JSON.stringify(capturedMap.frame), 9, true);
|
|
203
|
+
await drawImage(capturedMap.blob, pageHeight * 0.65);
|
|
204
|
+
y -= 20;
|
|
205
|
+
text(capturedMap.attribution, 8);
|
|
206
|
+
text("Snapshot includes currently loaded canvas layers. Geographic coordinates remain in the attached document.", 9);
|
|
207
|
+
}
|
|
208
|
+
newPage();
|
|
209
|
+
text("Ground control points", 16);
|
|
210
|
+
for (const gcp of doc.gcps) {
|
|
211
|
+
const r = fit.residuals.find((v) => v.id === gcp.id);
|
|
212
|
+
text(`#${gcp.label} ${gcp.enabled ? "enabled" : "disabled"} | pixel ${gcp.image.join(", ")} -> ${gcp.target.join(", ")} [${gcp.crs}]`, 9, true);
|
|
213
|
+
if (r)
|
|
214
|
+
text(`r=[${r.vector.join(", ")}]; length=${r.distance}; pixel residual=${r.pixels ?? "unavailable"}`, 8, true);
|
|
215
|
+
}
|
|
216
|
+
text("Normalized transformation parameters", 14);
|
|
217
|
+
for (const [key, value] of Object.entries(fit))
|
|
218
|
+
if (key !== "residuals")
|
|
219
|
+
text(`${key}: ${JSON.stringify(value)}`, 8, true);
|
|
220
|
+
text("Reproduce: restore the attached alignment.json document with its matching SHA-256 source bytes and recorded engine version. GDAL CLI model substitution is not implied.");
|
|
221
|
+
return new Blob([(await pdf.save())], {
|
|
222
|
+
type: "application/pdf",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure serializers for QGIS points, world files and alignment accuracy reports.
|
|
3
|
+
* Import from `@georeferencing/plugins/serializers`; no workers or UI are loaded.
|
|
4
|
+
* @module serializers
|
|
5
|
+
*/
|
|
6
|
+
import type { Definitions, Document, Fit } from "@georeferencing/core";
|
|
7
|
+
/**
|
|
8
|
+
* Export a QGIS-compatible `.points` text file. Targets are projected into the document working CRS; canonical image y is negated to QGIS source y-up coordinates.
|
|
9
|
+
*
|
|
10
|
+
* Residual columns are placeholders set to zero; use `accuracyReport` for actual diagnostics. IDs and snap provenance are not represented by this format.
|
|
11
|
+
*/
|
|
12
|
+
export declare function exportPoints(doc: Document, definitions?: Definitions): string;
|
|
13
|
+
/**
|
|
14
|
+
* Create a six-line world file plus an explicit CRS sidecar value for Linear or Helmert fits without reprojection.
|
|
15
|
+
*
|
|
16
|
+
* Line order is A, D, B, E, C, F. The final two values locate the first pixel centre, `[0.5, 0.5]`. A world file alone contains no CRS.
|
|
17
|
+
* @throws {@link core.GeoreferenceError} For ineligible models or different working/output CRSs.
|
|
18
|
+
*/
|
|
19
|
+
export declare function worldFile(fit: Fit, workingCrs: string, outputCrs: string): {
|
|
20
|
+
/**
|
|
21
|
+
* Six newline-separated values in A, D, B, E, C, F order, including the pixel-centre
|
|
22
|
+
* offset.
|
|
23
|
+
*/
|
|
24
|
+
text: string;
|
|
25
|
+
/** Explicit CRS to retain beside the world file. */
|
|
26
|
+
crs: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Serialize the supplied document, fit, residual formulas and engine identity as readable JSON.
|
|
30
|
+
*
|
|
31
|
+
* The caller must supply a fit for this exact document revision. Training residuals do not certify independent accuracy; the report explicitly records that limitation and does not claim plain GDAL CLI equivalence.
|
|
32
|
+
*/
|
|
33
|
+
export declare function accuracyReport(doc: Document, fit: Fit): string;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ENGINE_VERSION, fail, forward, project } from "@georeferencing/core";
|
|
2
|
+
/**
|
|
3
|
+
* Export a QGIS-compatible `.points` text file. Targets are projected into the document working CRS; canonical image y is negated to QGIS source y-up coordinates.
|
|
4
|
+
*
|
|
5
|
+
* Residual columns are placeholders set to zero; use `accuracyReport` for actual diagnostics. IDs and snap provenance are not represented by this format.
|
|
6
|
+
*/
|
|
7
|
+
export function exportPoints(doc, definitions = {}) {
|
|
8
|
+
return (`#CRS: ${doc.workingCrs}\nmapX,mapY,sourceX,sourceY,enable,dX,dY,residual\n` +
|
|
9
|
+
doc.gcps
|
|
10
|
+
.map((p) => [
|
|
11
|
+
...project(p.target, p.crs, doc.workingCrs, definitions),
|
|
12
|
+
p.image[0],
|
|
13
|
+
-p.image[1],
|
|
14
|
+
p.enabled ? 1 : 0,
|
|
15
|
+
0,
|
|
16
|
+
0,
|
|
17
|
+
0,
|
|
18
|
+
].join(","))
|
|
19
|
+
.join("\n") +
|
|
20
|
+
"\n");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Create a six-line world file plus an explicit CRS sidecar value for Linear or Helmert fits without reprojection.
|
|
24
|
+
*
|
|
25
|
+
* Line order is A, D, B, E, C, F. The final two values locate the first pixel centre, `[0.5, 0.5]`. A world file alone contains no CRS.
|
|
26
|
+
* @throws {@link core.GeoreferenceError} For ineligible models or different working/output CRSs.
|
|
27
|
+
*/
|
|
28
|
+
export function worldFile(fit, workingCrs, outputCrs) {
|
|
29
|
+
if (!["linear", "helmert"].includes(fit.model) || workingCrs !== outputCrs)
|
|
30
|
+
fail("WORLD_FILE", "World-file-only output requires Linear/Helmert without reprojection.");
|
|
31
|
+
const a = forward(fit, [0, 0]), b = forward(fit, [1, 0]), c = forward(fit, [0, 1]), center = forward(fit, [0.5, 0.5]);
|
|
32
|
+
return {
|
|
33
|
+
/**
|
|
34
|
+
* Six newline-separated values in A, D, B, E, C, F order, including the pixel-centre
|
|
35
|
+
* offset.
|
|
36
|
+
*/
|
|
37
|
+
text: `${[
|
|
38
|
+
b[0] - a[0],
|
|
39
|
+
b[1] - a[1],
|
|
40
|
+
c[0] - a[0],
|
|
41
|
+
c[1] - a[1],
|
|
42
|
+
...center,
|
|
43
|
+
].join("\n")}\n`,
|
|
44
|
+
/** Explicit CRS to retain beside the world file. */
|
|
45
|
+
crs: workingCrs,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Serialize the supplied document, fit, residual formulas and engine identity as readable JSON.
|
|
50
|
+
*
|
|
51
|
+
* The caller must supply a fit for this exact document revision. Training residuals do not certify independent accuracy; the report explicitly records that limitation and does not claim plain GDAL CLI equivalence.
|
|
52
|
+
*/
|
|
53
|
+
export function accuracyReport(doc, fit) {
|
|
54
|
+
return JSON.stringify({
|
|
55
|
+
document: doc,
|
|
56
|
+
engine: ENGINE_VERSION,
|
|
57
|
+
diagnostics: {
|
|
58
|
+
definition: "r = T(image) - target; RMSE = sqrt(sum(||r||²)/enabledCount)",
|
|
59
|
+
units: doc.workingCrs,
|
|
60
|
+
independentAccuracy: "Not measured; training residuals do not certify accuracy.",
|
|
61
|
+
...fit,
|
|
62
|
+
},
|
|
63
|
+
reproducibility: {
|
|
64
|
+
inverse: fit.backward,
|
|
65
|
+
recipe: "Restore this document and matching SHA-256 image using this package version. Plain GDAL CLI equivalence is unverified.",
|
|
66
|
+
},
|
|
67
|
+
}, null, 2);
|
|
68
|
+
}
|
package/dist/tiff.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Extent, OutputSettings } from "@georeferencing/core";
|
|
2
|
+
/**
|
|
3
|
+
* Supported TIFF strip creation and no-data options, shared with raster output settings.
|
|
4
|
+
*/
|
|
5
|
+
export type TiffOptions = Pick<OutputSettings, "compression" | "noData" | "rowsPerStrip" | "predictor">;
|
|
6
|
+
/**
|
|
7
|
+
* Validate byte no-data, strip height, compression and predictor compatibility before
|
|
8
|
+
* encoding.
|
|
9
|
+
*/
|
|
10
|
+
export declare function validateTiffOptions(options: TiffOptions): void;
|
|
11
|
+
/**
|
|
12
|
+
* Encode an uncompressed north-up RGBA GeoTIFF synchronously using PixelIsArea. Allocates a complete ArrayBuffer; prefer encodeGeoTiffBlob for bounded-strip encoding.
|
|
13
|
+
* @param data - Row-major 8-bit RGBA with unassociated alpha.
|
|
14
|
+
* @param width - Positive integer pixel width.
|
|
15
|
+
* @param height - Positive integer pixel height.
|
|
16
|
+
* @param bounds - Outer pixel-edge extent in the output CRS, with row zero at maxY.
|
|
17
|
+
* @param crs - EPSG identifier with numeric code below 32767. Custom WKT GeoKeys are not supported.
|
|
18
|
+
* @param geographic - True for geographic CRS GeoKeys, false for projected CRS GeoKeys; must agree with crs.
|
|
19
|
+
* @returns Encoded TIFF with CRS, tiepoint, pixel scale and unassociated-alpha metadata.
|
|
20
|
+
*/
|
|
21
|
+
export declare function encodeGeoTiff(data: Uint8ClampedArray, width: number, height: number, bounds: Extent, crs: string, geographic: boolean): ArrayBuffer;
|
|
22
|
+
/**
|
|
23
|
+
* Encode a north-up GeoTIFF as bounded strips assembled into a Blob, avoiding a second concatenated full TIFF buffer.
|
|
24
|
+
* @param data - Row-major 8-bit RGBA with unassociated alpha.
|
|
25
|
+
* @param width - Positive integer pixel width.
|
|
26
|
+
* @param height - Positive integer pixel height.
|
|
27
|
+
* @param bounds - Outer pixel-edge extent in the output CRS, with row zero at maxY.
|
|
28
|
+
* @param crs - EPSG identifier with numeric code below 32767. Custom WKT GeoKeys are not supported.
|
|
29
|
+
* @param geographic - True for geographic CRS GeoKeys, false for projected CRS GeoKeys; must agree with crs.
|
|
30
|
+
* @param options - Compression, strip height, predictor and optional RGB no-data.
|
|
31
|
+
* @param progress - Encoding progress fraction per completed strip.
|
|
32
|
+
* @throws {@link core.GeoreferenceError} For invalid layouts/settings, unavailable Deflate or partial transparency with numeric no-data.
|
|
33
|
+
*/
|
|
34
|
+
export declare function encodeGeoTiffBlob(data: Uint8ClampedArray, width: number, height: number, bounds: Extent, crs: string, geographic: boolean, options?: TiffOptions, progress?: (value: number) => void): Promise<Blob>;
|
|
35
|
+
/**
|
|
36
|
+
* Convenience ArrayBuffer encoder wrapping encodeGeoTiffBlob. Materializes a complete output copy; the worker uses the Blob API.
|
|
37
|
+
* @param data - Row-major 8-bit RGBA with unassociated alpha.
|
|
38
|
+
* @param width - Positive integer pixel width.
|
|
39
|
+
* @param height - Positive integer pixel height.
|
|
40
|
+
* @param bounds - Outer pixel-edge extent in the output CRS, with row zero at maxY.
|
|
41
|
+
* @param crs - EPSG identifier with numeric code below 32767. Custom WKT GeoKeys are not supported.
|
|
42
|
+
* @param geographic - True for geographic CRS GeoKeys, false for projected CRS GeoKeys; must agree with crs.
|
|
43
|
+
* @param compression - Strip codec; defaults to none.
|
|
44
|
+
*/
|
|
45
|
+
export declare function encodeGeoTiffOutput(data: Uint8ClampedArray, width: number, height: number, bounds: Extent, crs: string, geographic: boolean, compression?: NonNullable<OutputSettings["compression"]>): Promise<ArrayBuffer>;
|
package/dist/tiff.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { fail } from "@georeferencing/core";
|
|
2
|
+
const compressionCodes = { none: 1, deflate: 8, packbits: 32773 };
|
|
3
|
+
/**
|
|
4
|
+
* Validate byte no-data, strip height, compression and predictor compatibility before
|
|
5
|
+
* encoding.
|
|
6
|
+
*/
|
|
7
|
+
export function validateTiffOptions(options) {
|
|
8
|
+
if (!Object.hasOwn(compressionCodes, options.compression ?? "none"))
|
|
9
|
+
fail("OUTPUT", "Unsupported TIFF compression.");
|
|
10
|
+
if (options.noData !== undefined &&
|
|
11
|
+
(!Number.isInteger(options.noData) ||
|
|
12
|
+
options.noData < 0 ||
|
|
13
|
+
options.noData > 255))
|
|
14
|
+
fail("NODATA", "Byte-image no-data must be an integer from 0 to 255.");
|
|
15
|
+
if (options.rowsPerStrip !== undefined &&
|
|
16
|
+
(!Number.isInteger(options.rowsPerStrip) ||
|
|
17
|
+
options.rowsPerStrip < 1 ||
|
|
18
|
+
options.rowsPerStrip > 4096))
|
|
19
|
+
fail("OUTPUT", "Rows per strip must be an integer from 1 to 4096.");
|
|
20
|
+
if (![1, 2].includes(options.predictor ?? 1) ||
|
|
21
|
+
(options.predictor === 2 && options.compression !== "deflate"))
|
|
22
|
+
fail("OUTPUT", "Horizontal prediction requires Deflate compression.");
|
|
23
|
+
}
|
|
24
|
+
function check(data, width, height, bounds) {
|
|
25
|
+
if (!Number.isSafeInteger(width) ||
|
|
26
|
+
!Number.isSafeInteger(height) ||
|
|
27
|
+
width < 1 ||
|
|
28
|
+
height < 1 ||
|
|
29
|
+
data.length !== width * height * 4)
|
|
30
|
+
fail("RASTER", "Raster buffer dimensions disagree.");
|
|
31
|
+
if (!bounds.every(Number.isFinite) ||
|
|
32
|
+
bounds[0] >= bounds[2] ||
|
|
33
|
+
bounds[1] >= bounds[3])
|
|
34
|
+
fail("EXTENT", "Invalid TIFF bounds.");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Encode an uncompressed north-up RGBA GeoTIFF synchronously using PixelIsArea. Allocates a complete ArrayBuffer; prefer encodeGeoTiffBlob for bounded-strip encoding.
|
|
38
|
+
* @param data - Row-major 8-bit RGBA with unassociated alpha.
|
|
39
|
+
* @param width - Positive integer pixel width.
|
|
40
|
+
* @param height - Positive integer pixel height.
|
|
41
|
+
* @param bounds - Outer pixel-edge extent in the output CRS, with row zero at maxY.
|
|
42
|
+
* @param crs - EPSG identifier with numeric code below 32767. Custom WKT GeoKeys are not supported.
|
|
43
|
+
* @param geographic - True for geographic CRS GeoKeys, false for projected CRS GeoKeys; must agree with crs.
|
|
44
|
+
* @returns Encoded TIFF with CRS, tiepoint, pixel scale and unassociated-alpha metadata.
|
|
45
|
+
*/
|
|
46
|
+
export function encodeGeoTiff(data, width, height, bounds, crs, geographic) {
|
|
47
|
+
check(data, width, height, bounds);
|
|
48
|
+
const header = tiffHeader(width, height, bounds, crs, geographic, {}, [data.length], height, 4);
|
|
49
|
+
const output = new Uint8Array(header.byteLength + data.byteLength);
|
|
50
|
+
output.set(new Uint8Array(header));
|
|
51
|
+
output.set(data, header.byteLength);
|
|
52
|
+
return output.buffer;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Encode a north-up GeoTIFF as bounded strips assembled into a Blob, avoiding a second concatenated full TIFF buffer.
|
|
56
|
+
* @param data - Row-major 8-bit RGBA with unassociated alpha.
|
|
57
|
+
* @param width - Positive integer pixel width.
|
|
58
|
+
* @param height - Positive integer pixel height.
|
|
59
|
+
* @param bounds - Outer pixel-edge extent in the output CRS, with row zero at maxY.
|
|
60
|
+
* @param crs - EPSG identifier with numeric code below 32767. Custom WKT GeoKeys are not supported.
|
|
61
|
+
* @param geographic - True for geographic CRS GeoKeys, false for projected CRS GeoKeys; must agree with crs.
|
|
62
|
+
* @param options - Compression, strip height, predictor and optional RGB no-data.
|
|
63
|
+
* @param progress - Encoding progress fraction per completed strip.
|
|
64
|
+
* @throws {@link core.GeoreferenceError} For invalid layouts/settings, unavailable Deflate or partial transparency with numeric no-data.
|
|
65
|
+
*/
|
|
66
|
+
export async function encodeGeoTiffBlob(data, width, height, bounds, crs, geographic, options = {}, progress) {
|
|
67
|
+
check(data, width, height, bounds);
|
|
68
|
+
validateTiffOptions(options);
|
|
69
|
+
const channels = options.noData === undefined ? 4 : 3;
|
|
70
|
+
const rows = Math.min(height, options.rowsPerStrip ?? 256), strips = [];
|
|
71
|
+
const compression = options.compression ?? "none";
|
|
72
|
+
if (compression === "deflate" && typeof CompressionStream === "undefined")
|
|
73
|
+
fail("CAPABILITY", "Deflate is unavailable; select uncompressed or PackBits output.");
|
|
74
|
+
for (let y = 0; y < height; y += rows) {
|
|
75
|
+
const count = Math.min(rows, height - y), pixels = new Uint8Array(width * count * channels);
|
|
76
|
+
for (let i = 0; i < width * count; i++) {
|
|
77
|
+
const p = (y * width + i) * 4;
|
|
78
|
+
if (channels === 3 && data[p + 3] !== 0 && data[p + 3] !== 255)
|
|
79
|
+
fail("NODATA_ALPHA", "Numeric no-data cannot preserve partial transparency; select alpha output.");
|
|
80
|
+
for (let c = 0; c < channels; c++)
|
|
81
|
+
pixels[i * channels + c] =
|
|
82
|
+
channels === 3 && data[p + 3] === 0 ? options.noData : data[p + c];
|
|
83
|
+
}
|
|
84
|
+
if (options.predictor === 2)
|
|
85
|
+
for (let row = 0; row < count; row++)
|
|
86
|
+
for (let x = width - 1; x > 0; x--)
|
|
87
|
+
for (let c = 0; c < channels; c++) {
|
|
88
|
+
const p = (row * width + x) * channels + c;
|
|
89
|
+
pixels[p] -= pixels[p - channels];
|
|
90
|
+
}
|
|
91
|
+
let blob = new Blob([
|
|
92
|
+
compression === "packbits" ? packBits(pixels) : pixels,
|
|
93
|
+
]);
|
|
94
|
+
if (compression === "deflate")
|
|
95
|
+
blob = await new Response(blob.stream().pipeThrough(new CompressionStream("deflate"))).blob();
|
|
96
|
+
strips.push(blob);
|
|
97
|
+
progress?.(Math.min(1, (y + count) / height));
|
|
98
|
+
}
|
|
99
|
+
const header = tiffHeader(width, height, bounds, crs, geographic, options, strips.map((s) => s.size), rows, channels);
|
|
100
|
+
return new Blob([header, ...strips], { type: "image/tiff" });
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Convenience ArrayBuffer encoder wrapping encodeGeoTiffBlob. Materializes a complete output copy; the worker uses the Blob API.
|
|
104
|
+
* @param data - Row-major 8-bit RGBA with unassociated alpha.
|
|
105
|
+
* @param width - Positive integer pixel width.
|
|
106
|
+
* @param height - Positive integer pixel height.
|
|
107
|
+
* @param bounds - Outer pixel-edge extent in the output CRS, with row zero at maxY.
|
|
108
|
+
* @param crs - EPSG identifier with numeric code below 32767. Custom WKT GeoKeys are not supported.
|
|
109
|
+
* @param geographic - True for geographic CRS GeoKeys, false for projected CRS GeoKeys; must agree with crs.
|
|
110
|
+
* @param compression - Strip codec; defaults to none.
|
|
111
|
+
*/
|
|
112
|
+
export async function encodeGeoTiffOutput(data, width, height, bounds, crs, geographic, compression = "none") {
|
|
113
|
+
return (await encodeGeoTiffBlob(data, width, height, bounds, crs, geographic, {
|
|
114
|
+
compression,
|
|
115
|
+
})).arrayBuffer();
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Encode TIFF PackBits literal/repeated runs of at most 128 bytes, with a bounded
|
|
119
|
+
* worst-case output allocation.
|
|
120
|
+
*/
|
|
121
|
+
function packBits(data) {
|
|
122
|
+
const out = new Uint8Array(data.length + Math.ceil(data.length / 128) + 2);
|
|
123
|
+
let cursor = 0;
|
|
124
|
+
for (let i = 0; i < data.length;) {
|
|
125
|
+
let run = 1;
|
|
126
|
+
while (run < 128 && i + run < data.length && data[i + run] === data[i])
|
|
127
|
+
run++;
|
|
128
|
+
if (run >= 3) {
|
|
129
|
+
out[cursor++] = 257 - run;
|
|
130
|
+
out[cursor++] = data[i];
|
|
131
|
+
i += run;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const start = i;
|
|
135
|
+
i += run;
|
|
136
|
+
while (i < data.length && i - start < 128) {
|
|
137
|
+
run = 1;
|
|
138
|
+
while (run < 3 && i + run < data.length && data[i + run] === data[i])
|
|
139
|
+
run++;
|
|
140
|
+
if (run >= 3)
|
|
141
|
+
break;
|
|
142
|
+
i += Math.min(run, 128 - (i - start));
|
|
143
|
+
}
|
|
144
|
+
out[cursor++] = i - start - 1;
|
|
145
|
+
out.set(data.subarray(start, i), cursor);
|
|
146
|
+
cursor += i - start;
|
|
147
|
+
}
|
|
148
|
+
return out.subarray(0, cursor);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Build a little-endian classic TIFF IFD with PixelIsArea, upper-left tiepoint, GeoKeys
|
|
152
|
+
* and strip offsets. Rejects offsets reaching the 4 GiB classic-TIFF limit.
|
|
153
|
+
*/
|
|
154
|
+
function tiffHeader(width, height, bounds, crs, geographic, options, lengths, rows, channels) {
|
|
155
|
+
const epsg = Number(/^EPSG:(\d+)$/.exec(crs)?.[1]);
|
|
156
|
+
if (!epsg || epsg >= 32767)
|
|
157
|
+
fail("CRS", "GeoTIFF requires an EPSG code below 32767 with a registered definition.");
|
|
158
|
+
const keys = [
|
|
159
|
+
1,
|
|
160
|
+
1,
|
|
161
|
+
0,
|
|
162
|
+
3,
|
|
163
|
+
1024,
|
|
164
|
+
0,
|
|
165
|
+
1,
|
|
166
|
+
geographic ? 2 : 1,
|
|
167
|
+
1025,
|
|
168
|
+
0,
|
|
169
|
+
1,
|
|
170
|
+
1,
|
|
171
|
+
geographic ? 2048 : 3072,
|
|
172
|
+
0,
|
|
173
|
+
1,
|
|
174
|
+
epsg,
|
|
175
|
+
];
|
|
176
|
+
const entries = [
|
|
177
|
+
[256, 4, [width]],
|
|
178
|
+
[257, 4, [height]],
|
|
179
|
+
[258, 3, Array(channels).fill(8)],
|
|
180
|
+
[259, 3, [compressionCodes[options.compression ?? "none"]]],
|
|
181
|
+
[262, 3, [2]],
|
|
182
|
+
[273, 4, lengths.map(() => 0)],
|
|
183
|
+
[274, 3, [1]],
|
|
184
|
+
[277, 3, [channels]],
|
|
185
|
+
[278, 4, [rows]],
|
|
186
|
+
[279, 4, lengths],
|
|
187
|
+
[284, 3, [1]],
|
|
188
|
+
[339, 3, Array(channels).fill(1)],
|
|
189
|
+
[
|
|
190
|
+
33550,
|
|
191
|
+
12,
|
|
192
|
+
[(bounds[2] - bounds[0]) / width, (bounds[3] - bounds[1]) / height, 0],
|
|
193
|
+
],
|
|
194
|
+
[33922, 12, [0, 0, 0, bounds[0], bounds[3], 0]],
|
|
195
|
+
[34735, 3, keys],
|
|
196
|
+
];
|
|
197
|
+
if (channels === 4)
|
|
198
|
+
entries.push([338, 3, [2]]);
|
|
199
|
+
if (options.predictor === 2)
|
|
200
|
+
entries.push([317, 3, [2]]);
|
|
201
|
+
if (options.noData !== undefined)
|
|
202
|
+
entries.push([
|
|
203
|
+
42113,
|
|
204
|
+
2,
|
|
205
|
+
[...new TextEncoder().encode(String(options.noData)), 0],
|
|
206
|
+
]);
|
|
207
|
+
entries.sort((a, b) => a[0] - b[0]);
|
|
208
|
+
const size = (type) => type === 2 ? 1 : type === 3 ? 2 : type === 4 ? 4 : 8;
|
|
209
|
+
const align = (n) => Math.ceil(n / 8) * 8;
|
|
210
|
+
let end = align(8 + 2 + 12 * entries.length + 4);
|
|
211
|
+
for (const [, type, values] of entries)
|
|
212
|
+
if (values.length * size(type) > 4)
|
|
213
|
+
end = align(end + values.length * size(type));
|
|
214
|
+
let offset = end;
|
|
215
|
+
entries.find((e) => e[0] === 273)[2] = lengths.map((n) => {
|
|
216
|
+
const start = offset;
|
|
217
|
+
offset += n;
|
|
218
|
+
return start;
|
|
219
|
+
});
|
|
220
|
+
if (offset >= 0xffffffff)
|
|
221
|
+
fail("OUTPUT_BUDGET", "Classic TIFF is limited to 4 GiB. Reduce output dimensions.");
|
|
222
|
+
const header = new ArrayBuffer(end), view = new DataView(header);
|
|
223
|
+
view.setUint16(0, 0x4949, true);
|
|
224
|
+
view.setUint16(2, 42, true);
|
|
225
|
+
view.setUint32(4, 8, true);
|
|
226
|
+
view.setUint16(8, entries.length, true);
|
|
227
|
+
let payload = align(8 + 2 + 12 * entries.length + 4);
|
|
228
|
+
entries.forEach(([tag, type, values], index) => {
|
|
229
|
+
const p = 10 + index * 12, bytes = values.length * size(type), dest = bytes <= 4 ? p + 8 : payload;
|
|
230
|
+
view.setUint16(p, tag, true);
|
|
231
|
+
view.setUint16(p + 2, type, true);
|
|
232
|
+
view.setUint32(p + 4, values.length, true);
|
|
233
|
+
if (bytes > 4) {
|
|
234
|
+
view.setUint32(p + 8, payload, true);
|
|
235
|
+
payload = align(payload + bytes);
|
|
236
|
+
}
|
|
237
|
+
values.forEach((v, i) => {
|
|
238
|
+
if (type === 2)
|
|
239
|
+
view.setUint8(dest + i, v);
|
|
240
|
+
else if (type === 3)
|
|
241
|
+
view.setUint16(dest + i * 2, v, true);
|
|
242
|
+
else if (type === 4)
|
|
243
|
+
view.setUint32(dest + i * 4, v, true);
|
|
244
|
+
else
|
|
245
|
+
view.setFloat64(dest + i * 8, v, true);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
return header;
|
|
249
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Optional deployment overrides for one format's dedicated encoding worker. */
|
|
2
|
+
export interface RasterPluginOptions {
|
|
3
|
+
/** Host factory for a dedicated codec worker; takes precedence over workerUrl. */
|
|
4
|
+
workerFactory?: () => Worker;
|
|
5
|
+
/** Same-origin URL of this format's deployed module worker. */
|
|
6
|
+
workerUrl?: string | URL;
|
|
7
|
+
}
|
|
8
|
+
/** JPEG encoding options. Georeferencing is delivered in required sidecars. */
|
|
9
|
+
export interface JpegOptions extends RasterPluginOptions {
|
|
10
|
+
/** Browser JPEG quality in [0, 1]; defaults to 0.92. JPEG is lossy. */
|
|
11
|
+
quality?: number;
|
|
12
|
+
/** Opaque RGB background for transparent/no-data pixels; defaults to white. */
|
|
13
|
+
background?: [number, number, number];
|
|
14
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|