Line data Source code
1 : /******************************************************************************
2 : *
3 : * Project: OpenGIS Simple Features Reference Implementation
4 : * Purpose: Implementation of PMTiles
5 : * Author: Even Rouault <even.rouault at spatialys.com>
6 : *
7 : ******************************************************************************
8 : * Copyright (c) 2023, Planet Labs
9 : *
10 : * SPDX-License-Identifier: MIT
11 : ****************************************************************************/
12 :
13 : #include "cpl_json.h"
14 :
15 : #include "ogrsf_frmts.h"
16 : #include "ogrpmtilesfrommbtiles.h"
17 :
18 : #include "include_pmtiles.h"
19 :
20 : #include "cpl_compressor.h"
21 : #include "cpl_md5.h"
22 : #include "cpl_string.h"
23 : #include "cpl_vsi_virtual.h"
24 :
25 : #include <algorithm>
26 : #include <array>
27 : #include <cassert>
28 : #include <unordered_map>
29 : #include <utility>
30 :
31 : /************************************************************************/
32 : /* ProcessMetadata() */
33 : /************************************************************************/
34 :
35 35 : static bool ProcessMetadata(GDALDataset *poSQLiteDS, pmtiles::headerv3 &sHeader,
36 : std::string &osMetadata)
37 : {
38 :
39 35 : auto poMetadata = poSQLiteDS->GetLayerByName("metadata");
40 35 : if (!poMetadata)
41 : {
42 0 : CPLError(CE_Failure, CPLE_AppDefined, "metadata table not found");
43 0 : return false;
44 : }
45 :
46 35 : const int iName = poMetadata->GetLayerDefn()->GetFieldIndex("name");
47 35 : const int iValue = poMetadata->GetLayerDefn()->GetFieldIndex("value");
48 35 : if (iName < 0 || iValue < 0)
49 : {
50 0 : CPLError(CE_Failure, CPLE_AppDefined,
51 : "Bad structure for metadata table");
52 0 : return false;
53 : }
54 :
55 70 : CPLJSONObject oObj;
56 70 : CPLJSONDocument oJsonDoc;
57 420 : for (auto &&poFeature : poMetadata)
58 : {
59 385 : const char *pszName = poFeature->GetFieldAsString(iName);
60 385 : const char *pszValue = poFeature->GetFieldAsString(iValue);
61 385 : if (EQUAL(pszName, "json"))
62 : {
63 35 : if (!oJsonDoc.LoadMemory(pszValue))
64 : {
65 0 : CPLError(CE_Failure, CPLE_AppDefined,
66 : "Cannot parse 'json' metadata item");
67 0 : return false;
68 : }
69 105 : for (const auto &oChild : oJsonDoc.GetRoot().GetChildren())
70 : {
71 70 : oObj.Add(oChild.GetName(), oChild);
72 : }
73 : }
74 : else
75 : {
76 350 : oObj.Add(pszName, pszValue);
77 : }
78 : }
79 :
80 : // MBTiles advertises scheme=tms. Override this
81 35 : oObj.Set("scheme", "xyz");
82 :
83 105 : const auto osFormat = oObj.GetString("format", "{missing}");
84 35 : if (osFormat != "pbf")
85 : {
86 0 : CPLError(CE_Failure, CPLE_AppDefined, "format=%s unhandled",
87 : osFormat.c_str());
88 0 : return false;
89 : }
90 :
91 35 : int nMinZoom = atoi(oObj.GetString("minzoom", "-1").c_str());
92 35 : if (nMinZoom < 0 || nMinZoom > 255)
93 : {
94 0 : CPLError(CE_Failure, CPLE_AppDefined, "Missing or invalid minzoom");
95 0 : return false;
96 : }
97 :
98 35 : int nMaxZoom = atoi(oObj.GetString("maxzoom", "-1").c_str());
99 35 : if (nMaxZoom < 0 || nMaxZoom > 255)
100 : {
101 0 : CPLError(CE_Failure, CPLE_AppDefined, "Missing or invalid maxzoom");
102 0 : return false;
103 : }
104 :
105 : const CPLStringList aosCenter(
106 105 : CSLTokenizeString2(oObj.GetString("center").c_str(), ",", 0));
107 35 : if (aosCenter.size() != 3)
108 : {
109 0 : CPLError(CE_Failure, CPLE_AppDefined, "Expected 3 values for center");
110 0 : return false;
111 : }
112 35 : const double dfCenterLong = CPLAtof(aosCenter[0]);
113 35 : const double dfCenterLat = CPLAtof(aosCenter[1]);
114 35 : if (std::fabs(dfCenterLong) > 180 || std::fabs(dfCenterLat) > 90)
115 : {
116 0 : CPLError(CE_Failure, CPLE_AppDefined, "Invalid center");
117 0 : return false;
118 : }
119 35 : const int nCenterZoom = atoi(aosCenter[2]);
120 35 : if (nCenterZoom < 0 || nCenterZoom > 255)
121 : {
122 0 : CPLError(CE_Failure, CPLE_AppDefined, "Missing or invalid center zoom");
123 0 : return false;
124 : }
125 :
126 : const CPLStringList aosBounds(
127 105 : CSLTokenizeString2(oObj.GetString("bounds").c_str(), ",", 0));
128 35 : if (aosBounds.size() != 4)
129 : {
130 0 : CPLError(CE_Failure, CPLE_AppDefined, "Expected 4 values for bounds");
131 0 : return false;
132 : }
133 35 : const double dfMinX = CPLAtof(aosBounds[0]);
134 35 : const double dfMinY = CPLAtof(aosBounds[1]);
135 35 : const double dfMaxX = CPLAtof(aosBounds[2]);
136 35 : const double dfMaxY = CPLAtof(aosBounds[3]);
137 35 : if (std::fabs(dfMinX) > 180 || std::fabs(dfMinY) > 90 ||
138 19 : std::fabs(dfMaxX) > 180 || std::fabs(dfMaxY) > 90)
139 : {
140 16 : CPLError(CE_Failure, CPLE_AppDefined, "Invalid bounds");
141 16 : return false;
142 : }
143 :
144 19 : CPLJSONDocument oMetadataDoc;
145 19 : oMetadataDoc.SetRoot(oObj);
146 19 : osMetadata = oMetadataDoc.SaveAsString();
147 : // CPLDebugOnly("PMTiles", "Metadata = %s", osMetadata.c_str());
148 :
149 19 : sHeader.root_dir_offset = 127;
150 19 : sHeader.root_dir_bytes = 0;
151 19 : sHeader.json_metadata_offset = 0;
152 19 : sHeader.json_metadata_bytes = 0;
153 19 : sHeader.leaf_dirs_offset = 0;
154 19 : sHeader.leaf_dirs_bytes = 0;
155 19 : sHeader.tile_data_offset = 0;
156 19 : sHeader.tile_data_bytes = 0;
157 19 : sHeader.addressed_tiles_count = 0;
158 19 : sHeader.tile_entries_count = 0;
159 19 : sHeader.tile_contents_count = 0;
160 19 : sHeader.clustered = true;
161 19 : sHeader.internal_compression = pmtiles::COMPRESSION_GZIP;
162 19 : sHeader.tile_compression = pmtiles::COMPRESSION_GZIP;
163 19 : sHeader.tile_type = pmtiles::TILETYPE_MVT;
164 19 : sHeader.min_zoom = static_cast<uint8_t>(nMinZoom);
165 19 : sHeader.max_zoom = static_cast<uint8_t>(nMaxZoom);
166 19 : sHeader.min_lon_e7 = static_cast<int32_t>(dfMinX * 10e6);
167 19 : sHeader.min_lat_e7 = static_cast<int32_t>(dfMinY * 10e6);
168 19 : sHeader.max_lon_e7 = static_cast<int32_t>(dfMaxX * 10e6);
169 19 : sHeader.max_lat_e7 = static_cast<int32_t>(dfMaxY * 10e6);
170 19 : sHeader.center_zoom = static_cast<uint8_t>(nCenterZoom);
171 19 : sHeader.center_lon_e7 = static_cast<int32_t>(dfCenterLong * 10e6);
172 19 : sHeader.center_lat_e7 = static_cast<int32_t>(dfCenterLat * 10e6);
173 :
174 19 : return true;
175 : }
176 :
177 : /************************************************************************/
178 : /* HashArray() */
179 : /************************************************************************/
180 :
181 : // From https://codereview.stackexchange.com/questions/171999/specializing-stdhash-for-stdarray
182 : // We do not use std::hash<std::array<T, N>> as the name of the struct
183 : // because with gcc 5.4 we get the following error:
184 : // https://stackoverflow.com/questions/25594644/warning-specialization-of-template-in-different-namespace
185 : template <class T, size_t N> struct HashArray
186 : {
187 : CPL_NOSANITIZE_UNSIGNED_INT_OVERFLOW
188 328 : size_t operator()(const std::array<T, N> &key) const
189 : {
190 : std::hash<T> hasher;
191 328 : size_t result = 0;
192 5576 : for (size_t i = 0; i < N; ++i)
193 : {
194 5248 : result = result * 31 + hasher(key[i]);
195 : }
196 328 : return result;
197 : }
198 : };
199 :
200 : /************************************************************************/
201 : /* OGRPMTilesConvertFromMBTiles() */
202 : /************************************************************************/
203 :
204 35 : bool OGRPMTilesConvertFromMBTiles(const char *pszDestName,
205 : const char *pszSrcName)
206 : {
207 35 : const char *const apszAllowedDrivers[] = {"SQLite", nullptr};
208 : auto poSQLiteDS = std::unique_ptr<GDALDataset>(
209 70 : GDALDataset::Open(pszSrcName, GDAL_OF_VECTOR, apszAllowedDrivers));
210 35 : if (!poSQLiteDS)
211 : {
212 0 : CPLError(CE_Failure, CPLE_AppDefined,
213 : "Cannot open %s with SQLite driver", pszSrcName);
214 0 : return false;
215 : }
216 :
217 : pmtiles::headerv3 sHeader;
218 70 : std::string osMetadata;
219 35 : if (!ProcessMetadata(poSQLiteDS.get(), sHeader, osMetadata))
220 16 : return false;
221 :
222 19 : auto poTilesLayer = poSQLiteDS->GetLayerByName("tiles");
223 19 : if (!poTilesLayer)
224 : {
225 0 : CPLError(CE_Failure, CPLE_AppDefined, "tiles table not found");
226 0 : return false;
227 : }
228 :
229 : const int iZoomLevel =
230 19 : poTilesLayer->GetLayerDefn()->GetFieldIndex("zoom_level");
231 : const int iTileColumn =
232 19 : poTilesLayer->GetLayerDefn()->GetFieldIndex("tile_column");
233 : const int iTileRow =
234 19 : poTilesLayer->GetLayerDefn()->GetFieldIndex("tile_row");
235 : const int iTileData =
236 19 : poTilesLayer->GetLayerDefn()->GetFieldIndex("tile_data");
237 19 : if (iZoomLevel < 0 || iTileColumn < 0 || iTileRow < 0 || iTileData < 0)
238 : {
239 0 : CPLError(CE_Failure, CPLE_AppDefined, "Bad structure for tiles table");
240 0 : return false;
241 : }
242 :
243 : struct TileEntry
244 : {
245 : uint64_t nTileId;
246 : std::array<unsigned char, 16> abyMD5;
247 : };
248 :
249 : // In a first step browse through the tiles table to compute the PMTiles
250 : // tile_id of each tile, and compute a hash of the tile data for
251 : // deduplication
252 38 : std::vector<TileEntry> asTileEntries;
253 276 : for (auto &&poFeature : poTilesLayer)
254 : {
255 257 : const int nZoomLevel = poFeature->GetFieldAsInteger(iZoomLevel);
256 257 : if (nZoomLevel < 0 || nZoomLevel > 30)
257 : {
258 0 : CPLError(CE_Warning, CPLE_AppDefined,
259 : "Skipping tile with missing or invalid zoom_level");
260 0 : continue;
261 : }
262 257 : const int nColumn = poFeature->GetFieldAsInteger(iTileColumn);
263 257 : if (nColumn < 0 || nColumn >= (1 << nZoomLevel))
264 : {
265 0 : CPLError(CE_Warning, CPLE_AppDefined,
266 : "Skipping tile with missing or invalid tile_column");
267 0 : continue;
268 : }
269 257 : const int nRow = poFeature->GetFieldAsInteger(iTileRow);
270 257 : if (nRow < 0 || nRow >= (1 << nZoomLevel))
271 : {
272 0 : CPLError(CE_Warning, CPLE_AppDefined,
273 : "Skipping tile with missing or invalid tile_row");
274 0 : continue;
275 : }
276 : // MBTiles uses a 0=bottom-most row, whereas PMTiles uses
277 : // 0=top-most row
278 257 : const int nY = (1 << nZoomLevel) - 1 - nRow;
279 : uint64_t nTileId;
280 : try
281 : {
282 257 : nTileId = pmtiles::zxy_to_tileid(static_cast<uint8_t>(nZoomLevel),
283 : nColumn, nY);
284 : }
285 0 : catch (const std::exception &e)
286 : {
287 : // shouldn't happen given previous checks
288 0 : CPLError(CE_Failure, CPLE_AppDefined, "Cannot compute tile id: %s",
289 0 : e.what());
290 0 : return false;
291 : }
292 257 : int nTileDataLength = 0;
293 : const GByte *pabyData =
294 257 : poFeature->GetFieldAsBinary(iTileData, &nTileDataLength);
295 257 : if (!pabyData)
296 : {
297 0 : CPLError(CE_Failure, CPLE_AppDefined, "Missing tile_data");
298 0 : return false;
299 : }
300 :
301 : TileEntry sEntry;
302 257 : sEntry.nTileId = nTileId;
303 :
304 : CPLMD5Context md5context;
305 257 : CPLMD5Init(&md5context);
306 257 : CPLMD5Update(&md5context, pabyData, nTileDataLength);
307 257 : CPLMD5Final(&sEntry.abyMD5[0], &md5context);
308 : try
309 : {
310 257 : asTileEntries.push_back(sEntry);
311 : }
312 0 : catch (const std::exception &e)
313 : {
314 0 : CPLError(CE_Failure, CPLE_AppDefined,
315 0 : "Out of memory browsing through tiles: %s", e.what());
316 0 : return false;
317 : }
318 : }
319 :
320 : // Sort the tiles by ascending tile_id. This is a requirement to build
321 : // the PMTiles directories.
322 19 : std::sort(asTileEntries.begin(), asTileEntries.end(),
323 840 : [](const TileEntry &a, const TileEntry &b)
324 840 : { return a.nTileId < b.nTileId; });
325 :
326 : // Let's build a temporary file that contains the tile data in
327 : // a way that corresponds to the "clustered" mode, that is
328 : // "offsets are either contiguous with the previous offset+length, or
329 : // refer to a lesser offset, when writing with deduplication."
330 57 : std::string osTmpFile(std::string(pszDestName) + ".tmp");
331 19 : if (!VSIIsLocal(pszDestName))
332 : {
333 0 : osTmpFile = CPLGenerateTempFilename(CPLGetFilename(pszDestName));
334 : }
335 :
336 : auto poTmpFile =
337 38 : VSIVirtualHandleUniquePtr(VSIFOpenL(osTmpFile.c_str(), "wb+"));
338 19 : VSIUnlink(osTmpFile.c_str());
339 19 : if (!poTmpFile)
340 : {
341 0 : CPLError(CE_Failure, CPLE_FileIO, "Cannot open %s for write",
342 : osTmpFile.c_str());
343 0 : return false;
344 : }
345 :
346 : struct ResetAndUnlinkTmpFile
347 : {
348 : VSIVirtualHandleUniquePtr &m_poFile;
349 : std::string m_osFilename;
350 :
351 19 : ResetAndUnlinkTmpFile(VSIVirtualHandleUniquePtr &poFile,
352 : const std::string &osFilename)
353 19 : : m_poFile(poFile), m_osFilename(osFilename)
354 : {
355 19 : }
356 :
357 19 : ~ResetAndUnlinkTmpFile()
358 19 : {
359 19 : m_poFile.reset();
360 19 : VSIUnlink(m_osFilename.c_str());
361 19 : }
362 : };
363 :
364 38 : ResetAndUnlinkTmpFile oReseer(poTmpFile, osTmpFile);
365 :
366 38 : std::vector<pmtiles::entryv3> asPMTilesEntries;
367 19 : uint64_t nLastTileId = 0;
368 19 : uint64_t nFileOffset = 0;
369 : std::array<unsigned char, 16> abyLastMD5;
370 : std::unordered_map<std::array<unsigned char, 16>,
371 : std::pair<uint64_t, uint32_t>,
372 : HashArray<unsigned char, 16>>
373 38 : oMapMD5ToOffsetLen;
374 276 : for (const auto &sEntry : asTileEntries)
375 : {
376 257 : if (sEntry.nTileId == nLastTileId + 1 && sEntry.abyMD5 == abyLastMD5)
377 : {
378 : // If the tile id immediately follows the previous one and
379 : // has the same tile data, increase the run_length
380 3 : asPMTilesEntries.back().run_length++;
381 : }
382 : else
383 : {
384 254 : pmtiles::entryv3 sPMTilesEntry;
385 254 : sPMTilesEntry.tile_id = sEntry.nTileId;
386 254 : sPMTilesEntry.run_length = 1;
387 :
388 254 : auto oIter = oMapMD5ToOffsetLen.find(sEntry.abyMD5);
389 254 : if (oIter != oMapMD5ToOffsetLen.end())
390 : {
391 : // Point to previously written tile data if this content
392 : // has already been written
393 180 : sPMTilesEntry.offset = oIter->second.first;
394 180 : sPMTilesEntry.length = oIter->second.second;
395 : }
396 : else
397 : {
398 : try
399 : {
400 74 : const auto sXYZ = pmtiles::tileid_to_zxy(sEntry.nTileId);
401 74 : poTilesLayer->SetAttributeFilter(CPLSPrintf(
402 : "zoom_level = %d AND tile_column = %u AND tile_row = "
403 : "%u",
404 74 : sXYZ.z, sXYZ.x, (1U << sXYZ.z) - 1U - sXYZ.y));
405 : }
406 0 : catch (const std::exception &e)
407 : {
408 : // shouldn't happen given previous checks
409 0 : CPLError(CE_Failure, CPLE_AppDefined,
410 0 : "Cannot compute xyz: %s", e.what());
411 0 : return false;
412 : }
413 74 : poTilesLayer->ResetReading();
414 : auto poFeature =
415 74 : std::unique_ptr<OGRFeature>(poTilesLayer->GetNextFeature());
416 74 : if (!poFeature)
417 : {
418 0 : CPLError(CE_Failure, CPLE_AppDefined, "Cannot find tile");
419 0 : return false;
420 : }
421 74 : int nTileDataLength = 0;
422 : const GByte *pabyData =
423 74 : poFeature->GetFieldAsBinary(iTileData, &nTileDataLength);
424 74 : if (!pabyData)
425 : {
426 0 : CPLError(CE_Failure, CPLE_AppDefined, "Missing tile_data");
427 0 : return false;
428 : }
429 :
430 74 : sPMTilesEntry.offset = nFileOffset;
431 74 : sPMTilesEntry.length = nTileDataLength;
432 :
433 74 : oMapMD5ToOffsetLen[sEntry.abyMD5] =
434 148 : std::pair<uint64_t, uint32_t>(nFileOffset, nTileDataLength);
435 :
436 74 : nFileOffset += nTileDataLength;
437 :
438 74 : if (poTmpFile->Write(pabyData, nTileDataLength, 1) != 1)
439 : {
440 0 : CPLError(CE_Failure, CPLE_FileIO, "Failed writing");
441 0 : return false;
442 : }
443 : }
444 :
445 254 : asPMTilesEntries.push_back(sPMTilesEntry);
446 :
447 254 : nLastTileId = sEntry.nTileId;
448 254 : abyLastMD5 = sEntry.abyMD5;
449 : }
450 : }
451 :
452 19 : const CPLCompressor *psCompressor = CPLGetCompressor("gzip");
453 19 : assert(psCompressor);
454 38 : std::string osCompressed;
455 :
456 : struct compression_exception : std::exception
457 : {
458 0 : const char *what() const noexcept override
459 : {
460 0 : return "Compression failed";
461 : }
462 : };
463 :
464 38 : const auto oCompressFunc = [psCompressor,
465 : &osCompressed](const std::string &osBytes,
466 228 : uint8_t) -> std::string
467 : {
468 38 : osCompressed.resize(32 + osBytes.size() * 2);
469 38 : size_t nOutputSize = osCompressed.size();
470 38 : void *pOutputData = &osCompressed[0];
471 38 : if (!psCompressor->pfnFunc(osBytes.data(), osBytes.size(), &pOutputData,
472 : &nOutputSize, nullptr,
473 38 : psCompressor->user_data))
474 : {
475 0 : throw compression_exception();
476 : }
477 38 : osCompressed.resize(nOutputSize);
478 76 : return osCompressed;
479 19 : };
480 :
481 38 : std::string osCompressedMetadata;
482 :
483 38 : std::string osRootBytes;
484 38 : std::string osLeaveBytes;
485 : int nNumLeaves;
486 : try
487 : {
488 : osCompressedMetadata =
489 19 : oCompressFunc(osMetadata, pmtiles::COMPRESSION_GZIP);
490 :
491 : // Build the root and leave directories (one depth max)
492 19 : std::tie(osRootBytes, osLeaveBytes, nNumLeaves) =
493 38 : pmtiles::make_root_leaves(oCompressFunc, pmtiles::COMPRESSION_GZIP,
494 19 : asPMTilesEntries);
495 : }
496 0 : catch (const std::exception &e)
497 : {
498 0 : CPLError(CE_Failure, CPLE_AppDefined, "Cannot build directories: %s",
499 0 : e.what());
500 0 : return false;
501 : }
502 :
503 : // Finalize the header fields related to offsets and size of the
504 : // different parts of the file
505 19 : sHeader.root_dir_bytes = osRootBytes.size();
506 19 : sHeader.json_metadata_offset =
507 19 : sHeader.root_dir_offset + sHeader.root_dir_bytes;
508 19 : sHeader.json_metadata_bytes = osCompressedMetadata.size();
509 19 : sHeader.leaf_dirs_offset =
510 19 : sHeader.json_metadata_offset + sHeader.json_metadata_bytes;
511 19 : sHeader.leaf_dirs_bytes = osLeaveBytes.size();
512 19 : sHeader.tile_data_offset =
513 19 : sHeader.leaf_dirs_offset + sHeader.leaf_dirs_bytes;
514 19 : sHeader.tile_data_bytes = nFileOffset;
515 :
516 : // Nomber of tiles that are addressable in the PMTiles archive, that is
517 : // the number of tiles we would have if not deduplicating them
518 19 : sHeader.addressed_tiles_count = asTileEntries.size();
519 :
520 : // Number of tile entries in root and leave directories
521 : // ie entries whose run_length >= 1
522 19 : sHeader.tile_entries_count = asPMTilesEntries.size();
523 :
524 : // Number of distinct tile blobs
525 19 : sHeader.tile_contents_count = oMapMD5ToOffsetLen.size();
526 :
527 : // Now build the final file!
528 38 : auto poFile = VSIVirtualHandleUniquePtr(VSIFOpenL(pszDestName, "wb"));
529 19 : if (!poFile)
530 : {
531 0 : CPLError(CE_Failure, CPLE_FileIO, "Cannot open %s for write",
532 : pszDestName);
533 0 : return false;
534 : }
535 38 : const auto osHeader = sHeader.serialize();
536 :
537 19 : if (poTmpFile->Seek(0, SEEK_SET) != 0 ||
538 19 : poFile->Write(osHeader.data(), osHeader.size(), 1) != 1 ||
539 19 : poFile->Write(osRootBytes.data(), osRootBytes.size(), 1) != 1 ||
540 38 : poFile->Write(osCompressedMetadata.data(), osCompressedMetadata.size(),
541 57 : 1) != 1 ||
542 19 : (!osLeaveBytes.empty() &&
543 0 : poFile->Write(osLeaveBytes.data(), osLeaveBytes.size(), 1) != 1))
544 : {
545 0 : CPLError(CE_Failure, CPLE_FileIO, "Failed writing");
546 0 : return false;
547 : }
548 :
549 : // Copy content of the temporary file at end of the output file.
550 38 : std::string oCopyBuffer;
551 19 : oCopyBuffer.resize(1024 * 1024);
552 19 : const uint64_t nTotalSize = nFileOffset;
553 19 : nFileOffset = 0;
554 33 : while (nFileOffset < nTotalSize)
555 : {
556 : const size_t nToRead = static_cast<size_t>(
557 14 : std::min<uint64_t>(nTotalSize - nFileOffset, oCopyBuffer.size()));
558 28 : if (poTmpFile->Read(&oCopyBuffer[0], nToRead, 1) != 1 ||
559 14 : poFile->Write(&oCopyBuffer[0], nToRead, 1) != 1)
560 : {
561 0 : CPLError(CE_Failure, CPLE_FileIO, "Failed writing");
562 0 : return false;
563 : }
564 14 : nFileOffset += nToRead;
565 : }
566 :
567 19 : if (poFile->Close() != 0)
568 0 : return false;
569 :
570 19 : return true;
571 : }
|