From 4d2bc040350a48c49c8d65d8bcdb794350dec2ac Mon Sep 17 00:00:00 2001 From: Erik Price Date: Mon, 2 Jan 2023 10:39:24 -0800 Subject: [PATCH 1/4] Support unzipping GeoPackage sources at runtime --- .../com/onthegomap/planetiler/Planetiler.java | 22 +++++++- .../planetiler/reader/GeoPackageReader.java | 8 ++- .../onthegomap/planetiler/util/FileUtils.java | 53 ++++++++++++++---- .../planetiler/PlanetilerTests.java | 6 +- .../planetiler/util/FileUtilsTest.java | 14 +++++ .../src/test/resources/geopackage.gpkg | Bin 114688 -> 0 bytes .../src/test/resources/geopackage.gpkg.zip | Bin 0 -> 10501 bytes 7 files changed, 87 insertions(+), 16 deletions(-) delete mode 100644 planetiler-core/src/test/resources/geopackage.gpkg create mode 100644 planetiler-core/src/test/resources/geopackage.gpkg.zip diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java index 34e6fc0bbc..92c425c101 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java @@ -350,6 +350,9 @@ public Planetiler addShapefileSource(String projection, String name, Path defaul *

* To override the location of the {@code geopackage} file, set {@code name_path=newpath.gpkg} in the arguments and to * override the download URL set {@code name_url=http://url/of/file.gpkg}. + *

+ * If given a path to a ZIP file containing one or more GeoPackages, each {@code .gpkg} file within will be extracted + * to a temporary directory at runtime. * * @param name string to use in stats and logs to identify this stage * @param defaultPath path to the input file to use if {@code name_path} key is not set through arguments @@ -362,8 +365,18 @@ public Planetiler addShapefileSource(String projection, String name, Path defaul public Planetiler addGeoPackageSource(String name, Path defaultPath, String defaultUrl) { Path path = getPath(name, "geopackage", defaultPath, defaultUrl); return addStage(name, "Process features in " + path, - ifSourceUsed(name, - () -> GeoPackageReader.process(name, List.of(path), featureGroup, config, profile, stats))); + ifSourceUsed(name, () -> { + List sourcePaths = List.of(path); + if (FileUtils.hasExtension(path, "zip")) { + sourcePaths = FileUtils.unzipMatchingFiles(path, "*.gpkg", tmpDir); + } + + if (sourcePaths.isEmpty()) { + throw new IllegalArgumentException("No .gpkg files found in " + path); + } + + GeoPackageReader.process(name, sourcePaths, featureGroup, config, profile, stats); + })); } @@ -373,12 +386,14 @@ public Planetiler addGeoPackageSource(String name, Path defaultPath, String defa * To override the location of the {@code sqlite} file, set {@code name_path=newpath.zip} in the arguments and to * override the download URL set {@code name_url=http://url/of/natural_earth.zip}. * + * @deprecated can be replaced by {@link #addGeoPackageSource(String, Path, String)}. * @param name string to use in stats and logs to identify this stage * @param defaultPath path to the input file to use if {@code name} key is not set through arguments. Can be the * {@code .sqlite} file or a {@code .zip} file containing the sqlite file. * @return this runner instance for chaining * @see NaturalEarthReader */ + @Deprecated(forRemoval = true) public Planetiler addNaturalEarthSource(String name, Path defaultPath) { return addNaturalEarthSource(name, defaultPath, null); } @@ -392,6 +407,8 @@ public Planetiler addNaturalEarthSource(String name, Path defaultPath) { * To override the location of the {@code sqlite} file, set {@code name_path=newpath.zip} in the arguments and to * override the download URL set {@code name_url=http://url/of/natural_earth.zip}. * + * @deprecated can be replaced by {@link #addGeoPackageSource(String, Path, String)}. + * * @param name string to use in stats and logs to identify this stage * @param defaultPath path to the input file to use if {@code name} key is not set through arguments. Can be the * {@code .sqlite} file or a {@code .zip} file containing the sqlite file. @@ -401,6 +418,7 @@ public Planetiler addNaturalEarthSource(String name, Path defaultPath) { * @see NaturalEarthReader * @see Downloader */ + @Deprecated(forRemoval = true) public Planetiler addNaturalEarthSource(String name, Path defaultPath, String defaultUrl) { Path path = getPath(name, "sqlite db", defaultPath, defaultUrl); return addStage(name, "Process features in " + path, ifSourceUsed(name, () -> NaturalEarthReader diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/GeoPackageReader.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/GeoPackageReader.java index 15a42b65e6..b6b2ca861e 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/GeoPackageReader.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/GeoPackageReader.java @@ -74,8 +74,14 @@ public void readFeatures(Consumer next) throws Exception { for (var featureName : geoPackage.getFeatureTables()) { FeatureDao features = geoPackage.getFeatureDao(featureName); + // If left unset (e.g. in NaturalEarth's data), assume latlon + long srsId = features.getSrsId(); + if (srsId == 0) { + srsId = 4326; + } + MathTransform transform = CRS.findMathTransform( - CRS.decode("EPSG:" + features.getSrsId()), + CRS.decode("EPSG:" + srsId), latLonCRS); for (var feature : features.queryForAll()) { diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/FileUtils.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/FileUtils.java index d3c3b4e96a..dc5d4a98f9 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/FileUtils.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/FileUtils.java @@ -10,6 +10,7 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.PathMatcher; +import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.util.Comparator; import java.util.List; @@ -50,6 +51,19 @@ public static Stream walkFileSystem(FileSystem fileSystem) { }); } + /** Returns a stream that lists all files in {@param fileSystem} matching {@param matcher} */ + static Stream walkFileSystemWithMatcher(FileSystem fileSystem, PathMatcher matcher) { + try (var walk = FileUtils.walkFileSystem(fileSystem)) { + return walk + .filter(Files::isRegularFile) + .filter(p -> matcher.matches(p.getFileName())); + } + } + + static PathMatcher createGlobMatcher(FileSystem fs, String pattern) { + return fs.getPathMatcher("glob:" + pattern); + } + /** * Returns list of paths matching {@param pattern} within {@param basePath}. *

@@ -60,19 +74,13 @@ public static Stream walkFileSystem(FileSystem fileSystem) { * @param pattern pattern to match filenames against, as described in {@link FileSystem#getPathMatcher(String)}. * @param walkZipFile callback function to recurse into matching {@code .zip} files. */ - public static List walkPathWithPattern(Path basePath, String pattern, - Function> walkZipFile) { - PathMatcher matcher = basePath.getFileSystem().getPathMatcher("glob:" + pattern); + public static List walkPathWithPattern(Path basePath, String pattern, Function> walkZipFile) { + PathMatcher matcher = createGlobMatcher(basePath.getFileSystem(), pattern); try { if (FileUtils.hasExtension(basePath, "zip")) { - try ( - var zipFs = FileSystems.newFileSystem(basePath); - var walkStream = FileUtils.walkFileSystem(zipFs) - ) { - return walkStream - .filter(p -> p.getFileName() != null && matcher.matches(p.getFileName())) - .toList(); + try (var zipFs = FileSystems.newFileSystem(basePath)) { + return walkFileSystemWithMatcher(zipFs, matcher).toList(); } } else if (Files.isDirectory(basePath)) { try (var walk = Files.walk(basePath)) { @@ -314,4 +322,29 @@ public static void unzip(InputStream input, Path destDir) { throw new UncheckedIOException(e); } } + + /** + * Unzips files from {@param zipPath} matching the glob {@param pattern} to {@param tmpLocation}. Extracted files will + * be deleted when the JVM exits. + * + * @return list of paths to extracted files + */ + public static List unzipMatchingFiles(Path zipPath, String pattern, Path tmpLocation) throws IOException { + PathMatcher matcher = createGlobMatcher(zipPath.getFileSystem(), pattern); + + try (var zipFs = FileSystems.newFileSystem(zipPath)) { + return walkFileSystemWithMatcher(zipFs, matcher) + .map(path -> { + try { + Path unzippedPath = tmpLocation.resolve(path.getFileName().toString()); + Files.copy(path, unzippedPath, StandardCopyOption.REPLACE_EXISTING); + unzippedPath.toFile().deleteOnExit(); + return unzippedPath; + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }) + .toList(); + } + } } diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java index d858721de7..e523bad9aa 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java @@ -1668,7 +1668,7 @@ public void processFeature(SourceFeature source, FeatureCollector features) { .addOsmSource("osm", tempOsm) .addNaturalEarthSource("ne", TestUtils.pathToResource("natural_earth_vector.sqlite")) .addShapefileSource("shapefile", TestUtils.pathToResource("shapefile.zip")) - .addGeoPackageSource("geopackage", TestUtils.pathToResource("geopackage.gpkg"), null) + .addGeoPackageSource("geopackage", TestUtils.pathToResource("geopackage.gpkg.zip"), null) .setOutput("mbtiles", mbtiles) .run(); @@ -1762,7 +1762,7 @@ public void processFeature(SourceFeature source, FeatureCollector features) { .setAttr("name", source.getString("name")); } }) - .addGeoPackageSource("geopackage", TestUtils.pathToResource("geopackage.gpkg"), null) + .addGeoPackageSource("geopackage", TestUtils.pathToResource("geopackage.gpkg.zip"), null) .setOutput("mbtiles", mbtiles) .run(); @@ -1790,7 +1790,7 @@ private void runWithProfile(Path tempDir, Profile profile, boolean force) throws .addOsmSource("osm", TestUtils.pathToResource("monaco-latest.osm.pbf")) .addNaturalEarthSource("ne", TestUtils.pathToResource("natural_earth_vector.sqlite")) .addShapefileSource("shapefile", TestUtils.pathToResource("shapefile.zip")) - .addGeoPackageSource("geopackage", TestUtils.pathToResource("geopackage.gpkg"), null) + .addGeoPackageSource("geopackage", TestUtils.pathToResource("geopackage.gpkg.zip"), null) .setOutput("mbtiles", tempDir.resolve("output.mbtiles")) .run(); } diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/FileUtilsTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/FileUtilsTest.java index 8a94fc13ee..ce0b4410c7 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/FileUtilsTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/FileUtilsTest.java @@ -142,4 +142,18 @@ void testWalkPathWithPatternSingleZip() { List.of("/shapefile/stations.shp", "/shapefile/stations.shx"), matchingPaths.stream().map(Path::toString).sorted().toList()); } + + @Test + void testUnzipMatchingFiles() throws IOException { + Path zipPath = TestUtils.pathToResource("shapefile.zip"); + List expectedPaths = List.of(tmpDir.resolve("stations.shp"), tmpDir.resolve("stations.shx")); + + var extractedPaths = FileUtils.unzipMatchingFiles(zipPath, "stations.sh[px]", tmpDir); + + assertEquals(expectedPaths, extractedPaths.stream().sorted().toList()); + + for (var path : expectedPaths) { + assertTrue(Files.exists(path), "missing expected file: " + path); + } + } } diff --git a/planetiler-core/src/test/resources/geopackage.gpkg b/planetiler-core/src/test/resources/geopackage.gpkg deleted file mode 100644 index 04cc66ee356ce6a53c3202dbd4b44955a3de46a5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 114688 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 O|1*sL*u=jUh5KKSmOHcn literal 0 HcmV?d00001 From c985d7778e55c0ccd5bd7c517fd5402cd7975b7a Mon Sep 17 00:00:00 2001 From: Erik Price Date: Mon, 2 Jan 2023 10:58:20 -0800 Subject: [PATCH 2/4] Fix GeoPackageReaderTest. --- .../planetiler/reader/GeoPackageReaderTest.java | 14 ++++++++++++-- .../test/resources/validSchema/road_motorway.yml | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/GeoPackageReaderTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/GeoPackageReaderTest.java index e8594137b1..12c5c2ce26 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/GeoPackageReaderTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/GeoPackageReaderTest.java @@ -7,21 +7,31 @@ import com.onthegomap.planetiler.collection.IterableOnce; import com.onthegomap.planetiler.geo.GeoUtils; import com.onthegomap.planetiler.stats.Stats; +import com.onthegomap.planetiler.util.FileUtils; import com.onthegomap.planetiler.worker.WorkerPipeline; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.function.Consumer; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; import org.locationtech.jts.geom.Geometry; class GeoPackageReaderTest { + @TempDir + static Path tmpDir; @Test @Timeout(30) - void testReadGeoPackage() { - Path path = TestUtils.pathToResource("geopackage.gpkg"); + void testReadGeoPackage() throws IOException { + Path path = tmpDir.resolve("geopackage.gpkg"); + Path zipPath = TestUtils.pathToResource("geopackage.gpkg.zip"); + try (var stream = Files.newInputStream(zipPath)) { + FileUtils.unzip(stream, tmpDir); + } try ( var reader = new GeoPackageReader("test", path) diff --git a/planetiler-custommap/src/test/resources/validSchema/road_motorway.yml b/planetiler-custommap/src/test/resources/validSchema/road_motorway.yml index 558e65d0ee..ec1bd45c57 100644 --- a/planetiler-custommap/src/test/resources/validSchema/road_motorway.yml +++ b/planetiler-custommap/src/test/resources/validSchema/road_motorway.yml @@ -11,7 +11,7 @@ sources: url: geofabrik:rhode-island gpkg: type: geopackage - url: https://example.com/geopackage.gpkg + url: https://example.com/geopackage.gpkg.zip tag_mappings: bridge: boolean # input=bridge, output=bridge, type=boolean layer: long From ac527858c9c6db88decc51f51d04ce8d5dd72331 Mon Sep 17 00:00:00 2001 From: Erik Price Date: Mon, 16 Jan 2023 09:50:55 -0800 Subject: [PATCH 3/4] Push Geopackage extraction to reader itself. --- .../com/onthegomap/planetiler/Planetiler.java | 31 ++++++- .../planetiler/reader/GeoPackageReader.java | 86 ++++++++++++++----- .../onthegomap/planetiler/util/FileUtils.java | 80 ++++++++--------- .../reader/GeoPackageReaderTest.java | 17 ++-- .../planetiler/util/FileUtilsTest.java | 14 --- 5 files changed, 141 insertions(+), 87 deletions(-) diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java index 92c425c101..292580e7f1 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/Planetiler.java @@ -354,6 +354,8 @@ public Planetiler addShapefileSource(String projection, String name, Path defaul * If given a path to a ZIP file containing one or more GeoPackages, each {@code .gpkg} file within will be extracted * to a temporary directory at runtime. * + * @param projection the Coordinate Reference System authority code to use, parsed with + * {@link org.geotools.referencing.CRS#decode(String)} * @param name string to use in stats and logs to identify this stage * @param defaultPath path to the input file to use if {@code name_path} key is not set through arguments * @param defaultUrl remote URL that the file to download if {@code download=true} argument is set and {@code @@ -362,23 +364,46 @@ public Planetiler addShapefileSource(String projection, String name, Path defaul * @see GeoPackageReader * @see Downloader */ - public Planetiler addGeoPackageSource(String name, Path defaultPath, String defaultUrl) { + public Planetiler addGeoPackageSource(String projection, String name, Path defaultPath, String defaultUrl) { Path path = getPath(name, "geopackage", defaultPath, defaultUrl); return addStage(name, "Process features in " + path, ifSourceUsed(name, () -> { List sourcePaths = List.of(path); if (FileUtils.hasExtension(path, "zip")) { - sourcePaths = FileUtils.unzipMatchingFiles(path, "*.gpkg", tmpDir); + sourcePaths = FileUtils.walkPathWithPattern(path, "*.gpkg"); } if (sourcePaths.isEmpty()) { throw new IllegalArgumentException("No .gpkg files found in " + path); } - GeoPackageReader.process(name, sourcePaths, featureGroup, config, profile, stats); + GeoPackageReader.process(projection, name, sourcePaths, tmpDir, featureGroup, config, profile, stats); })); } + /** + * Adds a new OGC GeoPackage source that will be processed when {@link #run()} is called. + *

+ * If the file does not exist and {@code download=true} argument is set, then the file will first be downloaded from + * {@code defaultUrl}. + *

+ * To override the location of the {@code geopackage} file, set {@code name_path=newpath.gpkg} in the arguments and to + * override the download URL set {@code name_url=http://url/of/file.gpkg}. + *

+ * If given a path to a ZIP file containing one or more GeoPackages, each {@code .gpkg} file within will be extracted + * to a temporary directory at runtime. + * + * @param name string to use in stats and logs to identify this stage + * @param defaultPath path to the input file to use if {@code name_path} key is not set through arguments + * @param defaultUrl remote URL that the file to download if {@code download=true} argument is set and {@code + * name_url} argument is not set + * @return this runner instance for chaining + * @see GeoPackageReader + * @see Downloader + */ + public Planetiler addGeoPackageSource(String name, Path defaultPath, String defaultUrl) { + return addGeoPackageSource(null, name, defaultPath, defaultUrl); + } /** * Adds a new Natural Earth sqlite file source that will be processed when {@link #run()} is called. diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/GeoPackageReader.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/GeoPackageReader.java index b6b2ca861e..b03ed1b6a4 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/GeoPackageReader.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/reader/GeoPackageReader.java @@ -4,6 +4,10 @@ import com.onthegomap.planetiler.collection.FeatureGroup; import com.onthegomap.planetiler.config.PlanetilerConfig; import com.onthegomap.planetiler.stats.Stats; +import com.onthegomap.planetiler.util.FileUtils; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.HashMap; import java.util.List; @@ -17,7 +21,7 @@ import org.geotools.geometry.jts.WKBReader; import org.geotools.referencing.CRS; import org.locationtech.jts.geom.Geometry; -import org.opengis.referencing.crs.CoordinateReferenceSystem; +import org.opengis.referencing.FactoryException; import org.opengis.referencing.operation.MathTransform; /** @@ -25,32 +29,72 @@ */ public class GeoPackageReader extends SimpleReader { + private Path extractedPath = null; private final GeoPackage geoPackage; + private final MathTransform coordinateTransform; - GeoPackageReader(String sourceName, Path input) { + GeoPackageReader(String sourceProjection, String sourceName, Path input, Path tmpDir) { super(sourceName); - geoPackage = GeoPackageManager.open(false, input.toFile()); + if (sourceProjection != null) { + try { + var sourceCRS = CRS.decode(sourceProjection); + var latLonCRS = CRS.decode("EPSG:4326"); + coordinateTransform = CRS.findMathTransform(sourceCRS, latLonCRS); + } catch (FactoryException e) { + throw new FileFormatException("Bad reference system", e); + } + } else { + coordinateTransform = null; + } + + try { + geoPackage = openGeopackage(input, tmpDir); + } catch (IOException e) { + throw new UncheckedIOException(e); + } } + /** + * Create a {@link GeoPackageManager} for the given path. If {@code input} refers to a file within a ZIP archive, + * first extract it to a temporary location. + */ + private GeoPackage openGeopackage(Path input, Path tmpDir) throws IOException { + var inputUri = input.toUri(); + if ("jar".equals(inputUri.getScheme())) { + extractedPath = Files.createTempFile(tmpDir, "", ".gpkg"); + try (var inputStream = inputUri.toURL().openStream()) { + FileUtils.safeCopy(inputStream, extractedPath); + } + return GeoPackageManager.open(false, extractedPath.toFile()); + } + + return GeoPackageManager.open(false, input.toFile()); + } + + /** * Renders map features for all elements from an OGC GeoPackage based on the mapping logic defined in {@code * profile}. * - * @param sourceName string ID for this reader to use in logs and stats - * @param sourcePaths paths to the {@code .gpkg} files on disk - * @param writer consumer for rendered features - * @param config user-defined parameters controlling number of threads and log interval - * @param profile logic that defines what map features to emit for each source feature - * @param stats to keep track of counters and timings + * @param sourceProjection code for the coordinate reference system of the input data, to be parsed by + * {@link CRS#decode(String)} + * @param sourceName string ID for this reader to use in logs and stats + * @param sourcePaths paths to the {@code .gpkg} files on disk + * @param tmpDir path to temporary directory for extracting data from zip files + * @param writer consumer for rendered features + * @param config user-defined parameters controlling number of threads and log interval + * @param profile logic that defines what map features to emit for each source feature + * @param stats to keep track of counters and timings * @throws IllegalArgumentException if a problem occurs reading the input file */ - public static void process(String sourceName, List sourcePaths, FeatureGroup writer, PlanetilerConfig config, + public static void process(String sourceProjection, String sourceName, List sourcePaths, Path tmpDir, + FeatureGroup writer, PlanetilerConfig config, Profile profile, Stats stats) { SourceFeatureProcessor.processFiles( sourceName, sourcePaths, - path -> new GeoPackageReader(sourceName, path), + path -> new GeoPackageReader(sourceProjection, sourceName, path, tmpDir), writer, config, profile, stats ); } @@ -68,21 +112,19 @@ public long getFeatureCount() { @Override public void readFeatures(Consumer next) throws Exception { - CoordinateReferenceSystem latLonCRS = CRS.decode("EPSG:4326"); + var latLonCRS = CRS.decode("EPSG:4326"); long id = 0; for (var featureName : geoPackage.getFeatureTables()) { FeatureDao features = geoPackage.getFeatureDao(featureName); - // If left unset (e.g. in NaturalEarth's data), assume latlon + // GeoPackage spec allows this to be 0 (undefined geographic CRS) or + // -1 (undefined cartesian CRS). Both cases will throw when trying to + // call CRS.decode long srsId = features.getSrsId(); - if (srsId == 0) { - srsId = 4326; - } - MathTransform transform = CRS.findMathTransform( - CRS.decode("EPSG:" + srsId), - latLonCRS); + MathTransform transform = (coordinateTransform != null) ? coordinateTransform : + CRS.findMathTransform(CRS.decode("EPSG:" + srsId), latLonCRS); for (var feature : features.queryForAll()) { GeoPackageGeometryData geometryData = feature.getGeometry(); @@ -109,7 +151,11 @@ public void readFeatures(Consumer next) throws Exception { } @Override - public void close() { + public void close() throws IOException { geoPackage.close(); + + if (extractedPath != null) { + Files.deleteIfExists(extractedPath); + } } } diff --git a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/FileUtils.java b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/FileUtils.java index dc5d4a98f9..ccca9b45fa 100644 --- a/planetiler-core/src/main/java/com/onthegomap/planetiler/util/FileUtils.java +++ b/planetiler-core/src/main/java/com/onthegomap/planetiler/util/FileUtils.java @@ -10,7 +10,6 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.PathMatcher; -import java.nio.file.StandardCopyOption; import java.nio.file.StandardOpenOption; import java.util.Comparator; import java.util.List; @@ -51,19 +50,6 @@ public static Stream walkFileSystem(FileSystem fileSystem) { }); } - /** Returns a stream that lists all files in {@param fileSystem} matching {@param matcher} */ - static Stream walkFileSystemWithMatcher(FileSystem fileSystem, PathMatcher matcher) { - try (var walk = FileUtils.walkFileSystem(fileSystem)) { - return walk - .filter(Files::isRegularFile) - .filter(p -> matcher.matches(p.getFileName())); - } - } - - static PathMatcher createGlobMatcher(FileSystem fs, String pattern) { - return fs.getPathMatcher("glob:" + pattern); - } - /** * Returns list of paths matching {@param pattern} within {@param basePath}. *

@@ -74,13 +60,19 @@ static PathMatcher createGlobMatcher(FileSystem fs, String pattern) { * @param pattern pattern to match filenames against, as described in {@link FileSystem#getPathMatcher(String)}. * @param walkZipFile callback function to recurse into matching {@code .zip} files. */ - public static List walkPathWithPattern(Path basePath, String pattern, Function> walkZipFile) { - PathMatcher matcher = createGlobMatcher(basePath.getFileSystem(), pattern); + public static List walkPathWithPattern(Path basePath, String pattern, + Function> walkZipFile) { + PathMatcher matcher = basePath.getFileSystem().getPathMatcher("glob:" + pattern); try { if (FileUtils.hasExtension(basePath, "zip")) { - try (var zipFs = FileSystems.newFileSystem(basePath)) { - return walkFileSystemWithMatcher(zipFs, matcher).toList(); + try ( + var zipFs = FileSystems.newFileSystem(basePath); + var walkStream = FileUtils.walkFileSystem(zipFs) + ) { + return walkStream + .filter(p -> p.getFileName() != null && matcher.matches(p.getFileName())) + .toList(); } } else if (Files.isDirectory(basePath)) { try (var walk = Files.walk(basePath)) { @@ -261,6 +253,31 @@ public static void unzipResource(String resource, Path dest) { } } + /** + * Copies bytes from {@code input} to {@code destPath}, ensuring that the size is limited to a reasonable value. + * + * @throws UncheckedIOException if an IO exception occurs + */ + public static void safeCopy(InputStream inputStream, Path destPath) { + try (var outputStream = Files.newOutputStream(destPath, StandardOpenOption.CREATE, StandardOpenOption.WRITE)) { + int totalSize = 0; + + int nBytes; + byte[] buffer = new byte[2048]; + while ((nBytes = inputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, nBytes); + totalSize += nBytes; + + if (totalSize > ZIP_THRESHOLD_SIZE) { + throw new IOException("The uncompressed data size " + FORMAT.storage(totalSize) + + "B is too much for the application resource capacity"); + } + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + /** * Unzips a zip file from an input stream to {@code destDir}. * @@ -312,7 +329,7 @@ public static void unzip(InputStream input, Path destDir) { } if (totalEntryArchive > ZIP_THRESHOLD_ENTRIES) { - throw new IOException("Too much entries in this archive " + FORMAT.integer(totalEntryArchive) + + throw new IOException("Too many entries in this archive " + FORMAT.integer(totalEntryArchive) + ", can lead to inodes exhaustion of the system"); } } @@ -322,29 +339,4 @@ public static void unzip(InputStream input, Path destDir) { throw new UncheckedIOException(e); } } - - /** - * Unzips files from {@param zipPath} matching the glob {@param pattern} to {@param tmpLocation}. Extracted files will - * be deleted when the JVM exits. - * - * @return list of paths to extracted files - */ - public static List unzipMatchingFiles(Path zipPath, String pattern, Path tmpLocation) throws IOException { - PathMatcher matcher = createGlobMatcher(zipPath.getFileSystem(), pattern); - - try (var zipFs = FileSystems.newFileSystem(zipPath)) { - return walkFileSystemWithMatcher(zipFs, matcher) - .map(path -> { - try { - Path unzippedPath = tmpLocation.resolve(path.getFileName().toString()); - Files.copy(path, unzippedPath, StandardCopyOption.REPLACE_EXISTING); - unzippedPath.toFile().deleteOnExit(); - return unzippedPath; - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }) - .toList(); - } - } } diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/GeoPackageReaderTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/GeoPackageReaderTest.java index 12c5c2ce26..4883ecc7d9 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/GeoPackageReaderTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/GeoPackageReaderTest.java @@ -33,10 +33,13 @@ void testReadGeoPackage() throws IOException { FileUtils.unzip(stream, tmpDir); } - try ( - var reader = new GeoPackageReader("test", path) - ) { - for (int i = 1; i <= 2; i++) { + var projections = new String[]{null, "EPSG:4326"}; + + var iter = 1; + for (var proj : projections) { + try ( + var reader = new GeoPackageReader(proj, "test", path, tmpDir) + ) { assertEquals(86, reader.getFeatureCount()); List points = new ArrayList<>(); List names = new ArrayList<>(); @@ -55,9 +58,11 @@ void testReadGeoPackage() throws IOException { assertTrue(names.contains("Van Dörn Street")); var gc = GeoUtils.JTS_FACTORY.createGeometryCollection(points.toArray(new Geometry[0])); var centroid = gc.getCentroid(); - assertEquals(-77.0297995, centroid.getX(), 5, "iter " + i); - assertEquals(38.9119684, centroid.getY(), 5, "iter " + i); + assertEquals(-77.0297995, centroid.getX(), 5, "iter " + iter); + assertEquals(38.9119684, centroid.getY(), 5, "iter " + iter); } + + iter += 1; } } } diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/FileUtilsTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/FileUtilsTest.java index ce0b4410c7..8a94fc13ee 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/FileUtilsTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/FileUtilsTest.java @@ -142,18 +142,4 @@ void testWalkPathWithPatternSingleZip() { List.of("/shapefile/stations.shp", "/shapefile/stations.shx"), matchingPaths.stream().map(Path::toString).sorted().toList()); } - - @Test - void testUnzipMatchingFiles() throws IOException { - Path zipPath = TestUtils.pathToResource("shapefile.zip"); - List expectedPaths = List.of(tmpDir.resolve("stations.shp"), tmpDir.resolve("stations.shx")); - - var extractedPaths = FileUtils.unzipMatchingFiles(zipPath, "stations.sh[px]", tmpDir); - - assertEquals(expectedPaths, extractedPaths.stream().sorted().toList()); - - for (var path : expectedPaths) { - assertTrue(Files.exists(path), "missing expected file: " + path); - } - } } From ea963902abfc3bbe4d95512eea93d34730ae83f0 Mon Sep 17 00:00:00 2001 From: Mike Barry Date: Wed, 25 Jan 2023 20:39:14 -0500 Subject: [PATCH 4/4] tweaks to tests --- .../planetiler/PlanetilerTests.java | 4 +- .../reader/GeoPackageReaderTest.java | 64 +++++++++--------- .../planetiler/util/FileUtilsTest.java | 10 +++ .../src/test/resources/geopackage.gpkg | Bin 0 -> 114688 bytes 4 files changed, 45 insertions(+), 33 deletions(-) create mode 100644 planetiler-core/src/test/resources/geopackage.gpkg diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java index e523bad9aa..f0fdfcfb65 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/PlanetilerTests.java @@ -1749,9 +1749,11 @@ public void processFeature(SourceFeature source, FeatureCollector features) { @ValueSource(strings = { "", "--write-threads=2 --process-threads=2 --feature-read-threads=2 --threads=4", + "--input-file=geopackage.gpkg" }) void testPlanetilerRunnerGeoPackage(String args) throws Exception { Path mbtiles = tempDir.resolve("output.mbtiles"); + String inputFile = Arguments.fromArgs(args).getString("input-file", "", "geopackage.gpkg.zip"); Planetiler.create(Arguments.fromArgs((args + " --tmpdir=" + tempDir.resolve("data")).split("\\s+"))) .setProfile(new Profile.NullProfile() { @@ -1762,7 +1764,7 @@ public void processFeature(SourceFeature source, FeatureCollector features) { .setAttr("name", source.getString("name")); } }) - .addGeoPackageSource("geopackage", TestUtils.pathToResource("geopackage.gpkg.zip"), null) + .addGeoPackageSource("geopackage", TestUtils.pathToResource(inputFile), null) .setOutput("mbtiles", mbtiles) .run(); diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/GeoPackageReaderTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/GeoPackageReaderTest.java index 4883ecc7d9..3ba212b564 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/GeoPackageReaderTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/reader/GeoPackageReaderTest.java @@ -10,7 +10,6 @@ import com.onthegomap.planetiler.util.FileUtils; import com.onthegomap.planetiler.worker.WorkerPipeline; import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -27,42 +26,43 @@ class GeoPackageReaderTest { @Test @Timeout(30) void testReadGeoPackage() throws IOException { - Path path = tmpDir.resolve("geopackage.gpkg"); + Path pathOutsideZip = TestUtils.pathToResource("geopackage.gpkg"); Path zipPath = TestUtils.pathToResource("geopackage.gpkg.zip"); - try (var stream = Files.newInputStream(zipPath)) { - FileUtils.unzip(stream, tmpDir); - } + Path pathInZip = FileUtils.walkPathWithPattern(zipPath, "*.gpkg").get(0); var projections = new String[]{null, "EPSG:4326"}; - var iter = 1; - for (var proj : projections) { - try ( - var reader = new GeoPackageReader(proj, "test", path, tmpDir) - ) { - assertEquals(86, reader.getFeatureCount()); - List points = new ArrayList<>(); - List names = new ArrayList<>(); - WorkerPipeline.start("test", Stats.inMemory()) - .readFromTiny("files", List.of(Path.of("dummy-path"))) - .addWorker("geopackage", 1, (IterableOnce p, Consumer next) -> reader.readFeatures(next)) - .addBuffer("reader_queue", 100, 1) - .sinkToConsumer("counter", 1, elem -> { - assertTrue(elem.getTag("name") instanceof String); - assertEquals("test", elem.getSource()); - assertEquals("stations", elem.getSourceLayer()); - points.add(elem.latLonGeometry()); - names.add(elem.getTag("name").toString()); - }).await(); - assertEquals(86, points.size()); - assertTrue(names.contains("Van Dörn Street")); - var gc = GeoUtils.JTS_FACTORY.createGeometryCollection(points.toArray(new Geometry[0])); - var centroid = gc.getCentroid(); - assertEquals(-77.0297995, centroid.getX(), 5, "iter " + iter); - assertEquals(38.9119684, centroid.getY(), 5, "iter " + iter); + for (var path : List.of(pathOutsideZip, pathInZip)) { + for (var proj : projections) { + try ( + var reader = new GeoPackageReader(proj, "test", path, tmpDir) + ) { + for (int iter = 0; iter < 2; iter++) { + String id = "path=" + path + " proj=" + proj + " iter=" + iter; + assertEquals(86, reader.getFeatureCount(), id); + List points = new ArrayList<>(); + List names = new ArrayList<>(); + WorkerPipeline.start("test", Stats.inMemory()) + .readFromTiny("files", List.of(Path.of("dummy-path"))) + .addWorker("geopackage", 1, + (IterableOnce p, Consumer next) -> reader.readFeatures(next)) + .addBuffer("reader_queue", 100, 1) + .sinkToConsumer("counter", 1, elem -> { + assertTrue(elem.getTag("name") instanceof String); + assertEquals("test", elem.getSource()); + assertEquals("stations", elem.getSourceLayer()); + points.add(elem.latLonGeometry()); + names.add(elem.getTag("name").toString()); + }).await(); + assertEquals(86, points.size(), id); + assertTrue(names.contains("Van Dörn Street"), id); + var gc = GeoUtils.JTS_FACTORY.createGeometryCollection(points.toArray(new Geometry[0])); + var centroid = gc.getCentroid(); + assertEquals(-77.0297995, centroid.getX(), 5, id); + assertEquals(38.9119684, centroid.getY(), 5, id); + } + } } - - iter += 1; } } } diff --git a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/FileUtilsTest.java b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/FileUtilsTest.java index 8a94fc13ee..0dbb5b713f 100644 --- a/planetiler-core/src/test/java/com/onthegomap/planetiler/util/FileUtilsTest.java +++ b/planetiler-core/src/test/java/com/onthegomap/planetiler/util/FileUtilsTest.java @@ -5,7 +5,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.onthegomap.planetiler.TestUtils; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -90,6 +92,14 @@ void testUnzip() throws IOException { ); } + @Test + void testSafeCopy() throws IOException { + var dest = tmpDir.resolve("unzipped"); + String input = "a1".repeat(1200); + FileUtils.safeCopy(new ByteArrayInputStream(input.getBytes(StandardCharsets.UTF_8)), dest); + assertEquals(input, Files.readString(dest)); + } + @Test void testWalkPathWithPatternDirectory() throws IOException { Path parent = tmpDir.resolve(Path.of("a", "b", "c")); diff --git a/planetiler-core/src/test/resources/geopackage.gpkg b/planetiler-core/src/test/resources/geopackage.gpkg new file mode 100644 index 0000000000000000000000000000000000000000..04cc66ee356ce6a53c3202dbd4b44955a3de46a5 GIT binary patch literal 114688 zcmeHw349yXwZ7y{UZw0NQJmbE7+FM$w>XZoT9Rd3i7h)?5|RKyvBvV$TAa~1j?)yd z%?>GTc$C`Z6;dd)^wH8sOIx7DRoVBnr94V`g+j~H*RqrbD75^)8IAU_34#70?bXlc zT65;knRCB;&biCnJ92LAWO;`4g}I=YCk={O3Z+ufLWn}4C z|05i?_iSlT;irBrR;=_a&Icz)b5!6j(!8rVTBoyjiH=E~Jzn|g!g9CmK&juI|Dve|J#s3YnKEB_>h9V5dXXddu ztR|P0xE!|jcB_NvGSAD&&=Iq>t=nNGHoMd6aFK3%_H{af9K>p}bPz}Pc3jYIvlFMa z(`s=Mhsox&R-4S-4wr%GVtSEqi16Os0fy+ZbkOO@UN#)?@=S#EhC>ma^Rgj+Es;to z7LW9LL!mHFb~7ZvL?VRm_l8KFPEWRXSfOdP-MW2cTK@H*H7e~^z_@HW z825+6K~I3$%LF__gFdK{3DO6Gm?a?rp}VJ5z(3qal6gKNpfMef2hbE1zBZ`@LoPfZ zkS*37sTdeiTug6BxvUSD1%sXxw1ntpYqw`g&=ZHX4dKLYu{vXLBOLKeh>xT|t=DfV zD=2pwl?pcGWA;V%1kjRtyhD6gJoaQ(lcz3|qaI&UtSWD8RGuHxDf7H1!eqs9fmbr4 zIS4sY;Yc@$RL~3&F5+Q**|i&FL!NzEwIgsxvbo-Ua(Thc&YNBrem)ra*eWBVo<-Rx zp3piaYdBWk?AnbCdU@6x@Ni6@Co&Qt)lx=%dr3k0uEt}oVIZGf%R+sWo~KZ$%A1;$ z7cNaYyq^gN8J-*Q^o9dN!B9l3{!C_Jc3o#?nZB|OG0Ad~At8hx8D!)#OlKSWiOXi+ zA$VcJZG+jYl$wUK3O5r<8A-LiRJ9Fg?*$A*MbdnB2v_%}T8Z0k+v-L`pY-Ci{P1xh ze<;1;Q!*cuYiC+hujixNlC7wq{EVgoxe8NH50@@+^A8Vv8Lwx=v>Zcq6>07u{Mv8c6gRUAC@d6G`G3tI<2| zde6FgFwN+hftNM2847yNwSf|$;QX{#6q(*Q6hD{vlA1{zFR~##(;}GU zWH=_&%Mgqoc_z5){rE?$@sj(eMKu2 z^K;H)M=Q3sJIR_=f_grNXM3$Z&h}YcJC!Q^&zbUSRW9lI(8$(4r6rrL(u$}`T8 zIkUQen-P$>Z2@SKo{gsAVne1ztYwLvUn8k7Js`N2_WJk`GwRKIa8XM}H z>l^Eun^!f+>CjwPUoYx#nhk~Q!~mZk^5J4T9*%ZEwHP}b@=JWH$?1~QcvVc}Ks;v+ z9+!%jUl@bAtzOwww`S**b1QfKHJ`bm#j?Ai_uIQ~n{qZj|N6EkZ*8$$zT>{F zr=2_N55gjUrRGfq{^*AyKoOt_Py{Ff6ak6=MSvne5ugZA1SkR&fqx+cN^&cT;xh+y z{Qoahv9u@@0g3=cfFeK8?X);qbMSvne5ugZA1SkR&0g3=cfFeKqIOC)tMqd5ugZA1SkR&0g3=cfFeKj& zP^NxEF!sfbj&qVC{?l8)kiczh`LB=jEBqJQj!5G5r@g< zv{swU-42(5=wf=3aES2U-2sN^vUJkv$X+%aKxrbRHyn!aoR^2Q4#xfAaL^NA_A-IvwaqLE2?!<24EK>_o{tFV z@!MsJ3UXFin{JgUx!03g$tKsUV_@bV1DMBYk;(gdK3(>hRE!)ae4EM&%AH1~f(`kY zeUUu@mS;TPAwDb~doq33Q2cV)OpYC7i*-jT28I+L)f-YS z>%(QipeF?_q1Caq+cPETiNo54ux__loiVu4EbvT-kEB4Y*N-nLR+Tq4D$kFJ%&Zqr zgvpBI0oi`myKOc;I zY*=PglPDX-6B_ws1;xsnT{)3KFVA`d9**hrL`EW{TFR(zFDWSB)p*Q)3gokWRj7~B z^Asvoc~g_}!lg-n72dzh@Z5-}HyjuWh9YA1XEF=3>pC;b^p$OhNtSc$gb;pYkdezU zoo(zVE}MOa;6w?p3ud!YY8t*K+)OBCB-Q#-)i$8pBw!#alIC(lxVkshO5AqaRyX=f zNheIp4;K~khtexPCG#*@_Cv&uE&_mJ*dBS%jxv)+UN=g&$s2tPRP$ zL(%Ahd{uc(jdFBX(lEn*4z(4FSE2%`jDl=~Og)`aFEa$5KE}%raSZM<6iPM@t=2Y^ zyEE4E=uHI$5JbyS@wzP7^4|2216hD<}XfqTDBN?wp zaHaG-2G)n5&-Sql7dsKNUW1Q`^m6PV&tgy^Hn{Pk>vV>IH^O^*F*@+0eZy$RW$Q|| za3r3w8iR~Jo((e9x~d(1M3qcr8ed5I@Xs&OP=^V}>8UG!MhrLSZO7 zma7!o-f?}>I5OG+(e^WVIoYO?kt5c6$n>SAiKwYD(GceR-VnPV%~9Mk5>Ynw6dWha z`Os9!Hs3y`j}5V+hzxVgv^#xS(R+&0oP0&$cUA6!qP&}Py*c@s2K94gFP43ybh7xt zk}He9P+U~3OdI?cy{CMR5b!By zQ+=qH3v!-dAM5EGNOz!MLTGjuC5}-w>q~3zV!{Sxg(b9@S)6`sM|o_5Vcm3txzv93 z@s5pUWMqzxF>jNa>k}IvrzsH}iioo|eh!Tm;56FiDy=a7^eY=sDh1p`xAAf1yrk$zpNvKJLyQ zWQYT^c#pVz`T+%jsl|K$BxOn}=ouVH9d;3u(~>fs<&zt8Bu3skVVHwCJ)Hslby!}= z=jD8!A&$)$YXvddkeL!l%Hf%Pe9gXK0D`2!Q@Vs?PMv;(wxE3LiW$eskj8`}7&k_w zxsW7d!RYWJRr!h)%JHfsn90*3oXNxa*?=ZbX3d9WkG&F^lLNFQxG7`P^d}8+!@QI# z^8?FXhGftFrA-|gNczq7`ksXaLcw>4<(8hbM3_B8!U#3va86peGfq|u19}6l)Q@gH zNmX7^p&awaWFqzmpvPn7>@+EradsuCCY61xuQFkesSh7aZdbR>jw`#YsS~TDQkbu+ zBt1BZC()IH4wa&jRM$7I(t{K9asv!!#C)MFKQe-=1)Ssg18gW&f<7O?U#WRlXaeSG z-c|oXeW}`7_Ey<{lo?8YUpi6hC{>r-Uvh3qL-9++hl@`yo?G-t(RoE{3tua|sc?7U z0@b~$1FE`$=L!xNcnX%}Kb3zde@lKr-uLs^yvp21axco=kn?uVtvUW2t@0`5WlF0u zM{$SP+I$>|wNa&LtI{r;-`=B8{sF&XpcG~)?8>P(nk>#K_Lh>z9t&DpEZhjHAV4fE zKXP&{K7D<7I3F;=<+$)}%nq1VK}A`T`>ko%yMOYgk zLZ@9eH%*h19?9>z&+MG)e*Tj02YE|N>(C(PRG|ph8(>cE>l2hos=_h8E%gvnm#N9$ zuf2C(eyelJ)@~lyu*cG3;!uZvJ{&q=VNeK$lP^|F9fWB!g*~|P%DXRyu)CT*v*$aU zrNt4BL;@r7rP^E@QzyMt&M*Ggfw~sw)ZtmYb|Fe-4|jQqk+gdQ0q>9xPoCUzMcZ5h zSv8s3eDBF^uRq`DoC-V~-ttt$($eYW{9)n>4~K{acr%80Cc2*&2F_nw?WOJoL=6&kYL-g?wR% znMR1s03rHJP3E>Tn;vd*PObdWHMv_yfDkiGEMATa2LhH^j@rZPi0)mYYq@J%5FIy>rU??zcZz0z)!0p%Bbc6e=rbb2Y>mwad!WYOUdG z&P&TSI;ZL$`_G?l+;3^wj`=y#G2-R<$X+%8H>s^%ljbIvgkeJ6I>>6ulvVi1VZCvK zb86u~=1hI@vv66CMT{ry3?@z)9UeUey!*ZK0xhRN;K`W|{L1$|CoPAms~+jE_!TO$ zEzI$-P9DuHDj>^!tyK_`6$SS7EQ{Q*$vO4>tlFUnOv=oK!vg`>mC@oh$%+0V#Ij1^JH{a2Gj-|!P24I)OIfz2~<-}M{mMhZ2SLasUg_!iyYY*nXGAb5HK)gL5 zU!+Zy5P~3*9si#vm*V=yOj~*J zHT~{$aecApXSx?O2AuK0C_fXa)Iw~M)MW(1gL15M{1hT;e{=8qqT`k^0mJbI=hq#a;A}h(h zSgw$>Uv>2LA$nZn{PQ!G&p{F*5QB$GJ?JwS-2_XKx&*~!9Bi{TR>*W`t>3iYJs-w% zbkqC3pY0Xp4Fz`twWMk?vf36w7J_8D3Fyyw`>oqx0!2UD+kFEnz#=ZYf?J5%$yH?Q zLdZcJN|!UTtM)Aw8nm3gpS*JCK1<6sEUL3(d7aVSYLS!DaT27!Nz)sT_jW%zeifqG zrkq>;`y@0(U2G`CM8dpi5D+8Rf^?L}FiO|wg#&e=AH&^eoqlu2@6il7`olgvfQSel zFIbnHPVEa~2&J3U4I7`o<|2f~!MBfo`yAMT6VF%Jp&&7F!SFO~e(QV)LKsUI^t;(d zcg}%IkAA;u{Ud{ji(wwV(kp5sIBaHzsC}NCIyaAwefB4il5@nvdjwG(e(O?$5HJr z<+Fv?FqW1MJVuW7iU6<>o0A=@5hf4_58GzP3YA{(x2x}5C3J_3H@mj;eJB(gVM6`k zVMNwgUCD{sG7F-zx@k|p`(4)yFzE2vKV7}iZ)w>gw1!R|U8C-RPwdmkdTyHr!eEc- zRkrAvCFpcIr`BCN?>CM8LZjjJ`NF*e(>JfJ>KIJv9_zm;H{?bvdHOBxANRAOoKSDS zRP!>vYA%BmgzI!EhgRJgy$*3iwdiq`Dga9yVtK)yr>U?~$bb>1%b54u(f$TB&bQq2 zw z)R_7>7E1$-rXWno!lExjwtG3!#lUy5(5MFeL0@=(SRO%dA?~v)^FIH1_n{I$?(;<1 zlBEUc3wH5j8yrwjc@M0tiSHN84S?(wRvdtx=t9e&+o{4W&?=?}uYso|Dni&xX**56c46S^#NTp}Fy;8&wZO+IQZ2=dE{zHc=RH#kG>@KegpU zN|q~JN;a=@p_cBgJL`5^N^|H84~b25cKmA1lhdZ`x+kxm1#RZ^^e?y-+H4;3vAwJp zrE~6)tL)9WkdifGd14iLFBejLZx@wqhBj@CkKu&Tr8rz6&h4^JZqI=%^jFiJ{FayA z&!5`toch7;Z}(R0hAa$Buu6gT8e_dWEE);OcN}a=$jj=31djggbV~=C9`7Bubt9Mw zO=0WLZbOWA3X{26F&cyAxsAg6|CE|H6!@bbiU37`B0v$K2v7tl0u%v?07ZZzKoOt_ zPz3&E5GYmVRw(1E61EBB|3*BN*L+{YXcp4v|M6u6^!YzSpa0Y6|5zVGpa0Y6|LKnj z==1-qxp(^fzn>SDCdfQ$qR;>7^Z(QZn)La9>grSa{C}Ed1oZiT#tI7h{GUGmmzJ!5 zJkS5d)$hXie_6rl3e7DVR#TyVLj47`t?bRR!)1=Lg3_Oso?W`Gt^O3o@-Qv8eJ zON*^VZx!8GdKfFeK|Z$B^kj=Lt%Rpv5N~avAE8}a)VfqFT1p^ z)s1^#d2sq_)AN3L>Z41rOt00Xda(vep0^7x?&#x$Ee5h<)GimKG-OIKzCJ0gxV*n} z<2AilGHBv@``Nur#Ax^K9TC=Y>H_lOY)-lLc}LoHuL$cQ59c;cIUz2-cF=_tp&`Gx z*Cl%~W19n_vKB=?+qCV#V^|;jM%e+q18b<`yVIuM<<`0tapv^kkYBMhWfCbXr>)E?runG z&s>1oX{k7}6Xn|d=l`f>vBO|H_BsU^EI<`@EbI)2iOJ^;Mo4UTh1{ymHhWCL^a-o0 zzqxJb5}2HdpR@F8Ebni_ZY-~Vh$nV&pNHI9)Go-$g8lt1uTM=v&X?XjxaQ1MIl{Ij zS-{#lAqTLhFFTKT&Z*wm?woqItmn)>!aU7bXB&aZ8@q(fb8?#v>{}oQ2AVGCf*;IX zc~zTp>Y=(bm(Kz4+gQ$z7e9Hiq;&`{ATqX3E3b1iGbm~*DE`oM{8n<66Y+$eigqI@urriS9)**-0 zJ8%B)=dZ9jr`9aL()zj}B|L;(SRLV^h_IJJ4l7$bdw_uY!ap8-MYX@?2qQG!`Q#mJ!Ptc4sT(WG&d9_u!V1uS3o+58XBYu5m2b=E4yy z{|<9TEcx~F{a9@-yW_-Sfi#$9x|`f~{q2)$V3w)_zxlcT0%2blUQ0{bu-#O4XO_tf zA*l6qA^eL?pS=S@)_wl^*ZzUMU9Mp^v|HHoCRZwxNlwJVZ6__+X>(3}ukYp3hb|FX zh~9zNZa^7NwY10{3)`n|pp0(j(i;B4LmA=uK9UX+eIHy#>< z=_xlXJo9mEPHf{?*e}|r!Nhh08HKtw#$=^C!wsLg=6Y@`YU+(g*SX>H+aN0Bg>$i7 zpLgF3p1O4d#5HI7R?fgH4_|BtM1kL3y9i#ilGztH!-e*t00-b=GwdiinvG;f?bL|5^?R zpV|52qY-SObTJ{sb73EW(ImT3*s>O4vi1x;c3R+WJNB`R)|=J_v2C$k*af3iu_9LV>Q;|a>Ha|90_5b|E|Nh1RY(m&UAZ%e0w))6+3pTHToUA=4mweH!5Oztt zcSG-E|A&nju&rTZM|c=pItccV?3I^gZHnoXz6ank=j$3BT9oJy$9^lcC|$ihP)XRG zi(LtFo3rezAtq}(<8xQs?Qmj~gy*XpE~4%)9`ItW_O3RvdAUyw9s=ukgAN^1SC;}7#iU37`B0v$K2v7tl0u%v?07c-F zM_|l_m)>hvRGc>;{rsZz;M?!>288GK@gGxM-`;BK>>3L2Y%6}{gbm?W2gKi{-&jn@ z`qTsAu+>Hen2`;Yt>!jQtI1{cbefzlPwc02D>v5H*4GuZozlu(l@A|a3X z#CvV1bat3}tZlYVYo(Ao^%j<%cF#6bryFP7uC_G^iK%(4c1w3FekRq^=IHM7;0iIX zQ9iS?+wHJeJ@IQYv*HGclCQ6s78G&(uQ{T?AN^1SC;}7#iU37`B0v$K2v7tl0u%v? z07ZZzaH1e!R2G(3C=?rt6uftL0Pm#{-=Y)|=Q8T*jdk^|+BIwI8`jpXUfEQ)YG;|| z2F13r2XvaxXl7|%Q@^ZvQT?vwIn9&mziJ*;Kcu-|bC>#N&5zVy(cGl@o90^elbWkE zUsNB_tWh7c}scBO`qiIp!qq#^^uf9uT&@55ksyRvh zy!u8>u|}!BQhmAlV)eNCbLwIB9`%5lQG3)o)J}Dmx?OEjuTwXwYt(x6$?6Jqxq7y` zM4eyue%U)^e<}M@*^6b*l|5bdMA>6y51c4fLG`2vPy{Ff6ak6=MSvne5ugZA1SkUk z6a@0NQVy#I!(68<6I1Q{?5<3t#eb zgtR6%uSPs+6w@U_dX^&3ARe76rY8xhE;mmn9#x6ya-_L8K-9^?m%L>{dNvM;cvdMK z-j2fx@lY!q3YixPU-I+<^KKk25YNsR(g$!jSNNiN2H$4mTW(&ZkZN)jc_18?38#g# zrNWoI;`m{q@I`YJCsg8zd?8ifFgNxE-{#<3Zdu7yic-b>isC~>2a5^{_p9DeomrsH zo5($1`JCc@jp3hStN#R{V+*QPT1!RY_;#LS{eHXxlgB$EJ@M%ekFbxzGc<^`Bh0Ee z)8epV%?EKgZ0+q<2dSLll1gG~b0JS`X$a}IlghZ1O0vDfYA4;Dtt%`0SYIVswt?8K z+oc1Oy_Hmphk^!`#OAcSJ3C3YgJ7YFEn*E0@*^qd^?G8qw%hEa)!J!=Zo)zkS;fS= zI;@2C5#+8;+})78w|*V5+FQq$=|o`84}Ksp4e=r!J1`9&8!+~6mxzo27`}ge@N7s{ z%{pM^v^rb@{0?hRr^#Xk$JIRz!s2orr&N7H)f!M|U2N#I)Cw~Qns#~jr88q0cgST- zW5()eZ1yQJvHi=G`rt3%B2O35~b81NwiLR{#l z%dtmipR6i3nUteOaWY=6fx)YJnGiqynZ@y7Y%+IRWdoHbCZe)B6!ziW-$F#Q3hh~s z!`5YT>>yjLI}EWbLI(Y4ex<5>!v^JO#k4vJ`Q$Vb_}UNFN60p#Ho^){{bcNGFS)ZID;)#VHtcjSfvReEni;JLYD6AjNBdYR-hQiUh>2#6NLc0Ano5STc zbrRRK6;w&wPOR!AqKs0`Y75XX3W!$ToH(YKk-6OZZQxKm2~jr0~F))kTZJmR*|u%E--Lp)42LY!6?NmGez z$T&8VI-z%jeo7!b%y88rx}K0X$mo$b#?!_1*GY|G&JuYLVo~HcL74I4I0*3|k`|dh zG6*Y<0|dO3>&o(V>y#J73^HxY?lfC{lkAhy9AlbmNp<3dWu8cHc#t73>uCtNQrI;l zt4cg!=nV&kf+4Z)VhlKyhxK{(dhr{CWqUJ(Ayfxl4#IYIX5JY`>_>oLANy>?@zlR`c-Fdo%WKNxagSMc2>k6epd=p<} zPXI60^LU5&uz2hd@kqRUFXE}oU@RCNUZg5tu|hdsl>{@HKO^`2Y(SHFNlHYT#>^9$ zJ)zO9da-vj)-8ornMOeh)$y1Xzxhl<%vf-b#~|52ERHb40bf?<=tB<{GrQU1`~Q^} zDoTt6JMoWxJ{bfq?#azlT(KNCKUGp7Cpx)J2~wD~v;GMBKZy^XSqoZZ*47^Yoh|X_ zL?=%f2aTGwhPOfQNc_6!CD=Tq0hyClK8WtlS@wqEi-GIUjw}%@fSrWD}D(2fm!?GdeAC~zbZPZ z9R;0Z*6w$Kwn=;qcn3hx(e!Q5cO?EY@B*L#^WsgdAgjc$j7~262IvO!;!^^kki>6@ zPA*svT5Hx`7XZci&~g6XK<}6rYkELirToBh*0(_4G;43uf)-2s<!;+|8WpP03wvq9%d{HEyS?6sgZX6@ur&|4CJU39V<`Kyutp|66jmH5!{te=C9 zAa5VYFYz}-C;1)_>g%E>K);gs8>5q#ehGA?Svv+@$8M7No1&8|&IgT~wZ9qzjZ1vg zp=%3hvw89UM?sHCd{cCC^AXVF=Ed7i1MQUf=IG>x-+-P5#z#SKOMFXoviTm+-R8x0 zU7&7>-x{54G=Nr^7gxLmirbqtI$3u;=sNRag&L%h^0!4NYhgpRwPx*Wd7ylW-wrJA zis3og4IJY2(h;3Bd=+%HS$p{3g7mNPO6Gj}znouL;yF@qxjr zJ3v1ImR|(L-*MWBGQ9zM-K@QECuom=M5=I;p%2bSZSa9~57> zb_#e1Yf56)K6(r2yHfsn(TTsm3Hl4d>#sq-k@)4{{RxDy`<+ukDwGas5^$0@Uk$9*pjZVCLHRvm5t^an=ol<@scmp8F{>|q>mrMMU zq7yIt7<8vu`{lbq_ey+ybmG|vXpdR@g(_^n8iG z2KL_^^NSnNq}B*GPPX%ebFju^8pTvQ+WohHt$Mg6)i= z8!&pV#Q)U)ySPUwhBT!DkJ7{sLWR&O3%vyN(vq!gjtGfZbugJEw4eGSQUxgj6oG#q z1cdRwuued8RP*k?PpN51DFPG$iU37`B0v$K2v7tl0u%v?07ZZz@b7>?E_OK-