diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a6af05..cb254ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,21 +15,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `Shapefile`, `Geometry` and `GeometryCollection` abstract classes - Custom *DBF* charset support - Support for emulated `null` values in *DBF* files -- Reading optional *DBT* files (support for `MEMO` fields) -- Reading optional *CPG* and *CST* files +- Reading and writing optional *DBT* files (support for `MEMO` fields) +- Reading and writing optional *CPG* files +- `ShapefileException::getDetails()` method - Constructor options constants: - - `Shapefile::OPTION_INVERT_POLYGONS_ORIENTATION` - - `Shapefile::OPTION_SUPPRESS_Z` - - `Shapefile::OPTION_SUPPRESS_M` + - `Shapefile::OPTION_CPG_ENABLE_FOR_DEFAULT_CHARSET` + - `Shapefile::OPTION_DBF_CONVERT_TO_UTF8` - `Shapefile::OPTION_DBF_FORCE_ALL_CAPS` - - `Shapefile::OPTION_NULL_PADDING_CHAR` - - `Shapefile::OPTION_FORCE_MULTIPART_GEOMETRIES` - - `Shapefile::OPTION_ENFORCE_POLYGON_CLOSED_RINGS` - - `Shapefile::OPTION_IGNORE_SHAPEFILE_BBOX` - - `Shapefile::OPTION_IGNORE_GEOMETRIES_BBOXES` - `Shapefile::OPTION_DBF_IGNORED_FIELDS` + - `Shapefile::OPTION_DBF_NULL_PADDING_CHAR` - `Shapefile::OPTION_DBF_NULLIFY_INVALID_DATES` - - `Shapefile::OPTION_DBF_CONVERT_TO_UTF8` + - `Shapefile::OPTION_DBF_RETURN_DATES_AS_OBJECTS` + - `Shapefile::OPTION_DELETE_EMPTY_FILES` + - `Shapefile::OPTION_ENFORCE_GEOMETRY_DATA_STRUCTURE` + - `Shapefile::OPTION_ENFORCE_POLYGON_CLOSED_RINGS` + - `Shapefile::OPTION_FORCE_MULTIPART_GEOMETRIES` + - `Shapefile::OPTION_IGNORE_GEOMETRIES_BBOXES` + - `Shapefile::OPTION_IGNORE_SHAPEFILE_BBOX` + - `Shapefile::OPTION_INVERT_POLYGONS_ORIENTATION` + - `Shapefile::OPTION_OVERWRITE_EXISTING_FILES` + - `Shapefile::OPTION_SUPPRESS_M` + - `Shapefile::OPTION_SUPPRESS_Z` - File types constants: - `Shapefile::FILE_SHP` - `Shapefile::FILE_SHX` @@ -37,7 +43,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `Shapefile::FILE_DBT` - `Shapefile::FILE_PRJ` - `Shapefile::FILE_CPG` - - `Shapefile::FILE_CST` - Shape types constants: - `Shapefile::SHAPE_TYPE_NULL` - `Shapefile::SHAPE_TYPE_POINT` @@ -63,7 +68,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `Shapefile::ERR_UNDEFINED` - `Shapefile::ERR_FILE_MISSING` - `Shapefile::ERR_FILE_EXISTS` + - `Shapefile::ERR_FILE_INVALID_RESOURCE` - `Shapefile::ERR_FILE_OPEN` + - `Shapefile::ERR_FILE_READING` + - `Shapefile::ERR_FILE_WRITING` - `Shapefile::ERR_SHP_TYPE_NOT_SUPPORTED` - `Shapefile::ERR_SHP_TYPE_NOT_SET` - `Shapefile::ERR_SHP_TYPE_ALREADY_SET` @@ -81,12 +89,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `Shapefile::ERR_DBF_FIELD_SIZE_NOT_VALID` - `Shapefile::ERR_DBF_FIELD_DECIMALS_NOT_VALID` - `Shapefile::ERR_DBF_CHARSET_CONVERSION` + - `Shapefile::ERR_DBT_EOF_REACHED` - `Shapefile::ERR_GEOM_NOT_EMPTY` - `Shapefile::ERR_GEOM_COORD_VALUE_NOT_VALID` - `Shapefile::ERR_GEOM_MISMATCHED_DIMENSIONS` - `Shapefile::ERR_GEOM_MISMATCHED_BBOX` - - `Shapefile::ERR_GEOM_SHAPEFILE_NOT_SET` - - `Shapefile::ERR_GEOM_SHAPEFILE_ALREADY_SET` + - `Shapefile::ERR_GEOM_MISSING_FIELD` - `Shapefile::ERR_GEOM_POINT_NOT_VALID` - `Shapefile::ERR_GEOM_POLYGON_OPEN_RING` - `Shapefile::ERR_GEOM_POLYGON_AREA_TOO_SMALL` @@ -98,6 +106,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - `Shapefile::ERR_INPUT_ARRAY_NOT_VALID` - `Shapefile::ERR_INPUT_WKT_NOT_VALID` - `Shapefile::ERR_INPUT_GEOJSON_NOT_VALID` + - `Shapefile::ERR_INPUT_NUMERIC_VALUE_OVERFLOW` ### Changed - Folder structure under `src/` reflects namespaces hierarchy @@ -111,7 +120,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Fixed - Stricter invalid date format detection -- Logical (`bool`) not initialized values (`null`) detection +- Logical (`bool`) not initialized values (`null`) detection in *DBF* files ### Removed - `ShapefileReader` public methods: diff --git a/src/Shapefile/Geometry/Geometry.php b/src/Shapefile/Geometry/Geometry.php index 9a42112..c80e176 100644 --- a/src/Shapefile/Geometry/Geometry.php +++ b/src/Shapefile/Geometry/Geometry.php @@ -25,21 +25,11 @@ abstract class Geometry */ private $custom_bounding_box = null; - /** - * @var array DBF data of the Geometry. + * @var array Data of the Geometry. */ private $data = []; - /** - * @var Shapefile|null Shapefile the Geometry belongs to. - * It is used only for geometries that belong to a Shapefile and - * NOT for the ones contained in a collection. - * The structure of the Shapefile will dictate the structure of Geometry data. - */ - private $Shapefile = null; - - /** * @var bool Flag representing whether the Geometry is empty. */ @@ -198,17 +188,18 @@ public function setFlagDeleted($value) /** * Sets a custom bounding box for the Geometry. - * No formal check is carried out except the compliance of dimensions. + * No check is carried out except a formal compliance of dimensions. * * @param array $bounding_box Associative array with the xmin, xmax, ymin, ymax and optional zmin, zmax, mmin, mmax values. */ public function setCustomBoundingBox($bounding_box) { + $bounding_box = array_intersect_key($bounding_box, array_flip(['xmin', 'xmax', 'ymin', 'ymax', 'zmin', 'zmax', 'mmin', 'mmax'])); if ( - $this->isEmpty() || - !isset($bounding_box['xmin'], $bounding_box['xmax'], $bounding_box['ymin'], $bounding_box['ymax']) || - ($this->isZ() && !isset($bounding_box['zmin'], $bounding_box['zmax'])) || - ($this->isM() && !isset($bounding_box['mmin'], $bounding_box['mmax'])) + $this->isEmpty() + || !isset($bounding_box['xmin'], $bounding_box['xmax'], $bounding_box['ymin'], $bounding_box['ymax']) + || (($this->isZ() && !isset($bounding_box['zmin'], $bounding_box['zmax'])) || (!$this->isZ() && (isset($bounding_box['zmin']) || isset($bounding_box['zmax'])))) + || (($this->isM() && !isset($bounding_box['mmin'], $bounding_box['mmax'])) || (!$this->isM() && (isset($bounding_box['mmin']) || isset($bounding_box['mmax'])))) ) { throw new ShapefileException(Shapefile::ERR_GEOM_MISMATCHED_BBOX); } @@ -227,78 +218,51 @@ public function resetCustomBoundingBox() /** * Gets data value for speficied field name. - * It requires the Geometry to belong to a Shapefile. * - * @param string $field Name of the field. + * @param string $fieldname Name of the field. * * @return mixed */ - public function getData($field) + public function getData($fieldname) { - $this->checkShapefile(); - $this->Shapefile->checkField($field); - return $this->data[$field]; + if (!isset($this->data[$fieldname])) { + throw new ShapefileException(Shapefile::ERR_INPUT_FIELD_NOT_FOUND, $fieldname); + } + return $this->data[$fieldname]; } /** * Sets data value for speficied field name. - * It requires the Geometry to belong to a Shapefile. * - * @param string $field Name of the field. + * @param string $fieldname Name of the field. * @param mixed $value Value to assign to the field. */ - public function setData($field, $value) + public function setData($fieldname, $value) { - $this->checkShapefile(); - $this->Shapefile->checkField($field); - $this->data[$field] = $value; + $this->data[$fieldname] = $value; } /** - * Gets an array of data of all the defined fields. - * It requires the Geometry to belong to a Shapefile. + * Gets an array of defined data. * * @return array */ public function getDataArray() { - $this->checkShapefile(); return $this->data; } /** * Sets an array of data. - * It requires the Geometry to belong to a Shapefile. * - * @param array $data Associative array of values. + * @param array $data Associative array of values. */ public function setDataArray($data) { - $this->checkShapefile(); - foreach ($data as $field => $value) { - $this->Shapefile->checkField($field); - $this->data[$field] = $value; + foreach ($data as $fieldname => $value) { + $this->data[$fieldname] = $value; } } - - - /** - * Sets the Shapefile the Geometry belongs to. - * It can be called just once for an instance of the class. - * This is not intended for users, but Shapefile requires it for internal mechanisms. - * - * @internal - * - * @param Shapefile $Shapefile - */ - public function setShapefile(\Shapefile\Shapefile $Shapefile) - { - if ($this->Shapefile !== null) { - throw new ShapefileException(Shapefile::ERR_GEOM_SHAPEFILE_ALREADY_SET); - } - $this->Shapefile = $Shapefile; - $this->data = array_fill_keys(array_keys($Shapefile->getFields()), null); - } @@ -335,17 +299,13 @@ protected function setFlagM($value) /** - * Checks if the Geometry has been initialized (it is not empty or has been added to a Shapefile) - * and if YES throws and exception. + * Checks if the Geometry has been initialized (it is not empty) and if YES throws and exception. */ protected function checkInit() { if (!$this->isEmpty()) { throw new ShapefileException(Shapefile::ERR_GEOM_NOT_EMPTY); } - if ($this->Shapefile !== null) { - throw new ShapefileException(Shapefile::ERR_GEOM_SHAPEFILE_ALREADY_SET); - } } @@ -629,15 +589,4 @@ private function parseCoordinatesArray($coordinates, $force_z, $force_m, $err_co return $ret; } - - /** - * Checks if the Geometry belongs to a Shapefile and if NOT throws and exception. - */ - private function checkShapefile() - { - if ($this->Shapefile === null) { - throw new ShapefileException(Shapefile::ERR_GEOM_SHAPEFILE_NOT_SET); - } - } - } diff --git a/src/Shapefile/Geometry/GeometryCollection.php b/src/Shapefile/Geometry/GeometryCollection.php index 8789955..214cf1a 100644 --- a/src/Shapefile/Geometry/GeometryCollection.php +++ b/src/Shapefile/Geometry/GeometryCollection.php @@ -46,14 +46,9 @@ abstract protected function getCollectionClass(); */ public function __construct(array $geometries = null) { - $classname = $this->getCollectionClass(); if ($geometries !== null) { foreach ($geometries as $Geometry) { - if (is_a($Geometry, $classname)) { - $this->addGeometry($Geometry); - } else { - throw new ShapefileException(Shapefile::ERR_INPUT_GEOMETRY_TYPE_NOT_VALID, $classname); - } + $this->addGeometry($Geometry); } } } diff --git a/src/Shapefile/Geometry/MultiPolygon.php b/src/Shapefile/Geometry/MultiPolygon.php index c0342ac..c2ffbc8 100644 --- a/src/Shapefile/Geometry/MultiPolygon.php +++ b/src/Shapefile/Geometry/MultiPolygon.php @@ -165,7 +165,7 @@ public function getArray() } return [ 'numparts' => $this->getNumGeometries(), - 'rings' => $parts, + 'parts' => $parts, ]; } diff --git a/src/Shapefile/Shapefile.php b/src/Shapefile/Shapefile.php index fa95f65..1dbb3e6 100644 --- a/src/Shapefile/Shapefile.php +++ b/src/Shapefile/Shapefile.php @@ -21,39 +21,37 @@ abstract class Shapefile { /** - * Invert Polygons orientation when reading/writing a Shapefile. - * ESRI Shapefile specifications establish clockwise order for external rings - * and counterclockwise order for internal ones. - * Simple Features standards and GeoJSON require the opposite! - * ShapefileReader and ShapefileWriter + * Converts from input charset to UTF-8 all strings read from DBF files. + * ShapefileWriter * @var bool */ - const OPTION_INVERT_POLYGONS_ORIENTATION = 'OPTION_INVERT_POLYGONS_ORIENTATION'; - const OPTION_INVERT_POLYGONS_ORIENTATION_DEFAULT = true; + const OPTION_CPG_ENABLE_FOR_DEFAULT_CHARSET = 'OPTION_CPG_ENABLE_FOR_DEFAULT_CHARSET'; + const OPTION_CPG_ENABLE_FOR_DEFAULT_CHARSET_DEFAULT = false; /** - * Suppress Z dimension. - * ShapefileReader and ShapefileWriter + * Converts from input charset to UTF-8 all strings read from DBF files. + * ShapefileReader * @var bool */ - const OPTION_SUPPRESS_Z = 'OPTION_SUPPRESS_Z'; - const OPTION_SUPPRESS_Z_DEFAULT = false; + const OPTION_DBF_CONVERT_TO_UTF8 = 'OPTION_DBF_CONVERT_TO_UTF8'; + const OPTION_DBF_CONVERT_TO_UTF8_DEFAULT = true; /** - * Suppress M dimension. + * Forces all capitals field names in DBF files. * ShapefileReader and ShapefileWriter * @var bool */ - const OPTION_SUPPRESS_M = 'OPTION_SUPPRESS_M'; - const OPTION_SUPPRESS_M_DEFAULT = false; + const OPTION_DBF_FORCE_ALL_CAPS = 'OPTION_DBF_FORCE_ALL_CAPS'; + const OPTION_DBF_FORCE_ALL_CAPS_DEFAULT = true; /** - * Force all capitals field names in DBF files. - * ShapefileReader and ShapefileWriter - * @var bool + * Ignored fields in DBF file. + * An array of fields to ignore when reading the DBF file. + * ShapefileReader + * @var array|null */ - const OPTION_DBF_FORCE_ALL_CAPS = 'OPTION_DBF_FORCE_ALL_CAPS'; - const OPTION_DBF_FORCE_ALL_CAPS_DEFAULT = true; + const OPTION_DBF_IGNORED_FIELDS = 'OPTION_DBF_IGNORED_FIELDS'; + const OPTION_DBF_IGNORED_FIELDS_DEFAULT = null; /** * Defines a null padding character to represent null values in DBF files. @@ -64,7 +62,39 @@ abstract class Shapefile const OPTION_DBF_NULL_PADDING_CHAR_DEFAULT = null; /** - * Enforce all polygons rings to be closed. + * Returns a null value for invalid dates when reading DBF files and nullify invalid dates when writing them. + * ShapefileReader and ShapefileWriter + * @var bool + */ + const OPTION_DBF_NULLIFY_INVALID_DATES = 'OPTION_DBF_NULLIFY_INVALID_DATES'; + const OPTION_DBF_NULLIFY_INVALID_DATES_DEFAULT = true; + + /** + * Returns dates as DateTime objects instead of ISO strings (YYYY-MM-DD). + * ShapefileReader + * @var bool + */ + const OPTION_DBF_RETURN_DATES_AS_OBJECTS = 'OPTION_DBF_RETURN_DATES_AS_OBJECTS'; + const OPTION_DBF_RETURN_DATES_AS_OBJECTS_DEFAULT = false; + + /** + * Deletes empty files after closing them (only if they were passed as resource handles). + * ShapefileWriter + * @var bool + */ + const OPTION_DELETE_EMPTY_FILES = 'OPTION_DELETE_EMPTY_FILES'; + const OPTION_DELETE_EMPTY_FILES_DEFAULT = true; + + /** + * Enforces Geometries to have all data fields defined in Shapefile. + * ShapefileWriter + * @var bool + */ + const OPTION_ENFORCE_GEOMETRY_DATA_STRUCTURE = 'OPTION_ENFORCE_GEOMETRY_DATA_STRUCTURE'; + const OPTION_ENFORCE_GEOMETRY_DATA_STRUCTURE_DEFAULT = true; + + /** + * Enforces all Polygons rings to be closed. * ShapefileReader * @var bool */ @@ -79,6 +109,14 @@ abstract class Shapefile const OPTION_FORCE_MULTIPART_GEOMETRIES = 'OPTION_FORCE_MULTIPART_GEOMETRIES'; const OPTION_FORCE_MULTIPART_GEOMETRIES_DEFAULT = false; + /** + * Ignore Geometries bounding box found in Shapefile. + * ShapefileReader + * @var bool + */ + const OPTION_IGNORE_GEOMETRIES_BBOXES = 'OPTION_IGNORE_GEOMETRIES_BBOXES'; + const OPTION_IGNORE_GEOMETRIES_BBOXES_DEFAULT = false; + /** * Ignore bounding box found in Shapefile. * ShapefileReader @@ -88,37 +126,39 @@ abstract class Shapefile const OPTION_IGNORE_SHAPEFILE_BBOX_DEFAULT = false; /** - * Ignore Geometries bounding box found in Shapefile. - * ShapefileReader + * Invert Polygons orientation when reading/writing a Shapefile. + * ESRI Shapefile specifications establish clockwise order for external rings + * and counterclockwise order for internal ones. + * Simple Features standards and GeoJSON require the opposite! + * ShapefileReader and ShapefileWriter * @var bool */ - const OPTION_IGNORE_GEOMETRIES_BBOXES = 'OPTION_IGNORE_GEOMETRIES_BBOXES'; - const OPTION_IGNORE_GEOMETRIES_BBOXES_DEFAULT = false; + const OPTION_INVERT_POLYGONS_ORIENTATION = 'OPTION_INVERT_POLYGONS_ORIENTATION'; + const OPTION_INVERT_POLYGONS_ORIENTATION_DEFAULT = true; /** - * Ignored fields in DBF file. - * An array of fields to ignore when reading the DBF file. - * ShapefileReader - * @var array|null + * Overwrites existing files with the same name. + * ShapefileWriter + * @var bool */ - const OPTION_DBF_IGNORED_FIELDS = 'OPTION_DBF_IGNORED_FIELDS'; - const OPTION_DBF_IGNORED_FIELDS_DEFAULT = null; + const OPTION_OVERWRITE_EXISTING_FILES = 'OPTION_OVERWRITE_EXISTING_FILES'; + const OPTION_OVERWRITE_EXISTING_FILES_DEFAULT = false; /** - * Return a null value for invalid dates found in DBF files. - * ShapefileReader + * Suppress M dimension. + * ShapefileReader and ShapefileWriter * @var bool */ - const OPTION_DBF_NULLIFY_INVALID_DATES = 'OPTION_DBF_NULLIFY_INVALID_DATES'; - const OPTION_DBF_NULLIFY_INVALID_DATES_DEFAULT = true; + const OPTION_SUPPRESS_M = 'OPTION_SUPPRESS_M'; + const OPTION_SUPPRESS_M_DEFAULT = false; /** - * Converts from input charset to UTF-8 all strings read from DBF files. - * ShapefileReader + * Suppress Z dimension. + * ShapefileReader and ShapefileWriter * @var bool */ - const OPTION_DBF_CONVERT_TO_UTF8 = 'OPTION_DBF_CONVERT_TO_UTF8'; - const OPTION_DBF_CONVERT_TO_UTF8_DEFAULT = true; + const OPTION_SUPPRESS_Z = 'OPTION_SUPPRESS_Z'; + const OPTION_SUPPRESS_Z_DEFAULT = false; /** File types */ @@ -128,7 +168,6 @@ abstract class Shapefile const FILE_DBT = 'dbt'; const FILE_PRJ = 'prj'; const FILE_CPG = 'cpg'; - const FILE_CST = 'cst'; /** Shape types */ @@ -163,8 +202,12 @@ abstract class Shapefile self::SHAPE_TYPE_MULTIPOINTM => 'MultiPointM', ]; + /** Shape type return formats */ + const FORMAT_INT = 0; + const FORMAT_STR = 1; - /** DBF types */ + + /** DBF fields types */ const DBF_TYPE_CHAR = 'C'; const DBF_TYPE_DATE = 'D'; const DBF_TYPE_LOGICAL = 'L'; @@ -173,19 +216,29 @@ abstract class Shapefile const DBF_TYPE_FLOAT = 'F'; - /** Return format types */ - const FORMAT_INT = 0; - const FORMAT_STR = 1; - - /** Misc */ - const DEFAULT_DBF_CHARSET = 'ISO-8859-1'; - const EOF = 0; - const DBF_MAX_FIELD_COUNT = 255; - const DBF_FIELD_TERMINATOR = 0x0d; - const DBF_EOF_MARKER = 0x1a; + const EOF = 0; + + + /** SHP files constants */ + const SHP_FILE_CODE = 9994; + const SHP_HEADER_SIZE = 100; + const SHP_NO_DATA_THRESHOLD = -1e38; + const SHP_NO_DATA_VALUE = -1e40; + const SHP_VERSION = 1000; + /** SHX files constants */ + const SHX_HEADER_SIZE = 100; + const SHX_RECORD_SIZE = 8; + /** DBF files constants */ const DBF_BLANK = 0x20; - const DBF_NULL = 0x00; + const DBF_DEFAULT_CHARSET = 'ISO-8859-1'; + const DBF_DELETED_MARKER = 0x2a; + const DBF_EOF_MARKER = 0x1a; + const DBF_FIELD_TERMINATOR = 0x0d; + const DBF_MAX_FIELD_COUNT = 128; + const DBF_VERSION = 0x03; + const DBF_VERSION_WITH_DBT = 0x83; + /** DBT files constants */ const DBT_BLOCK_SIZE = 512; const DBT_FIELD_TERMINATOR = 0x1a; @@ -198,10 +251,19 @@ abstract class Shapefile const ERR_FILE_MISSING_MESSAGE = "A required file is missing"; const ERR_FILE_EXISTS = 'ERR_FILE_EXISTS'; - const ERR_FILE_EXISTS_MESSAGE = "File not found. Check if the file exists and is readable"; + const ERR_FILE_EXISTS_MESSAGE = "Check if the file exists and is readable and/or writable"; + + const ERR_FILE_INVALID_RESOURCE = 'ERR_FILE_INVALID_RESOURCE'; + const ERR_FILE_INVALID_RESOURCE_MESSAGE = "File pointer resource not valid"; const ERR_FILE_OPEN = 'ERR_FILE_OPEN'; - const ERR_FILE_OPEN_MESSAGE = "Unable to read file"; + const ERR_FILE_OPEN_MESSAGE = "Unable to open file"; + + const ERR_FILE_READING = 'ERR_FILE_READING'; + const ERR_FILE_READING_MESSAGE = "Error during binary file reading"; + + const ERR_FILE_WRITING = 'ERR_FILE_WRITING'; + const ERR_FILE_WRITING_MESSAGE = "Error during binary file writing"; const ERR_SHP_TYPE_NOT_SUPPORTED = 'ERR_SHP_TYPE_NOT_SUPPORTED'; const ERR_SHP_TYPE_NOT_SUPPORTED_MESSAGE = "Shape type not supported"; @@ -269,11 +331,8 @@ abstract class Shapefile const ERR_GEOM_MISMATCHED_BBOX = 'ERR_GEOM_MISMATCHED_BBOX'; const ERR_GEOM_MISMATCHED_BBOX_MESSAGE = "Bounding box must have the same dimensions as the Geometry (2D, 3D or 4D)"; - const ERR_GEOM_SHAPEFILE_NOT_SET = 'ERR_GEOM_SHAPEFILE_NOT_SET'; - const ERR_GEOM_SHAPEFILE_NOT_SET_MESSAGE = "Shapefile not set. Cannot retrieve data definition"; - - const ERR_GEOM_SHAPEFILE_ALREADY_SET = 'ERR_GEOM_SHAPEFILE_ALREADY_SET'; - const ERR_GEOM_SHAPEFILE_ALREADY_SET_MESSAGE = "Shapefile already set. Cannot change Geometry or data definition"; + const ERR_GEOM_MISSING_FIELD = 'ERR_GEOM_MISSING_FIELD'; + const ERR_GEOM_MISSING_FIELD_MESSAGE = "Geometry is missing a field defined in the Shapefile"; const ERR_GEOM_POINT_NOT_VALID = 'ERR_GEOM_POINT_NOT_VALID'; const ERR_GEOM_POINT_NOT_VALID_MESSAGE = "A Point can be either EMPTY or al least 2D"; @@ -291,7 +350,7 @@ abstract class Shapefile const ERR_INPUT_RECORD_NOT_FOUND_MESSAGE = "Record index not found (check the total number of records in the SHP file)"; const ERR_INPUT_FIELD_NOT_FOUND = 'ERR_INPUT_FIELD_NOT_FOUND'; - const ERR_INPUT_FIELD_NOT_FOUND_MESSAGE = "Field name not found"; + const ERR_INPUT_FIELD_NOT_FOUND_MESSAGE = "Field not found"; const ERR_INPUT_GEOMETRY_TYPE_NOT_VALID = 'ERR_INPUT_GEOMETRY_TYPE_NOT_VALID'; const ERR_INPUT_GEOMETRY_TYPE_NOT_VALID_MESSAGE = "Geometry type not valid. Must be of specified type"; @@ -300,13 +359,16 @@ abstract class Shapefile const ERR_INPUT_GEOMETRY_INDEX_NOT_VALID_MESSAGE = "Geometry index not valid (check the total number of geometries in the collection)"; const ERR_INPUT_ARRAY_NOT_VALID = 'ERR_INPUT_ARRAY_NOT_VALID'; - const ERR_INPUT_ARRAY_NOT_VALID_MESSAGE = "Array input not valid"; + const ERR_INPUT_ARRAY_NOT_VALID_MESSAGE = "Array not valid"; const ERR_INPUT_WKT_NOT_VALID = 'ERR_INPUT_WKT_NOT_VALID'; - const ERR_INPUT_WKT_NOT_VALID_MESSAGE = "WKT input not valid"; + const ERR_INPUT_WKT_NOT_VALID_MESSAGE = "WKT not valid"; const ERR_INPUT_GEOJSON_NOT_VALID = 'ERR_INPUT_GEOJSON_NOT_VALID'; - const ERR_INPUT_GEOJSON_NOT_VALID_MESSAGE = "GeoJSON input not valid"; + const ERR_INPUT_GEOJSON_NOT_VALID_MESSAGE = "GeoJSON not valid"; + + const ERR_INPUT_NUMERIC_VALUE_OVERFLOW = 'ERR_INPUT_NUMERIC_VALUE_OVERFLOW'; + const ERR_INPUT_NUMERIC_VALUE_OVERFLOW_MESSAGE = "Integer value overflows field size definition"; /** @@ -324,13 +386,11 @@ abstract class Shapefile */ private $computed_bounding_box = null; - /** * @var string|null PRJ well-known-text. */ private $prj = null; - /** * @var string|null DBF charset. */ @@ -347,285 +407,483 @@ abstract class Shapefile */ private $fields = []; + /** + * @var array Array of file pointer resource handles. + */ + private $files = []; + + /** + * @var array Options. + */ + private $options = []; + + + /** + * @var bool|null Flag to store whether the machine is big endian or not. + */ + private $flag_big_endian_machine = null; /** * @var bool Flag representing whether the Shapefile has been initialized with any Geometry. */ private $flag_init = false; - /** - * @var array Constructor options. + * @var bool Flag representing whether open files were passed as resources or filenames. */ - private $options = []; + private $flag_resources = false; - /////////////////////////////// PUBLIC /////////////////////////////// + /////////////////////////////// PROTECTED /////////////////////////////// /** - * Pair a Geometry with the Shapefile. - * It enforces the Geometry type, compute Shapefile bounding box and call Geometry setShapefile() method. - * After that the Shapefile will be considered as "initialized" and no changes will be allowd to its structure. - * - * @param Geometry $Geometry Geometry to add. + * Opens file pointer resource handles to specified files with binary read or write access. + * Returns an array or canonicalized absolute pathnames ONLY IF files are not passed as stream resources. + * (They are returned here because files are closed in destructors and working directory may be different!) + * + * @param string|array $files Path to SHP file / Array of paths / Array of resource handles of individual files. + * @param bool $write_access Access type: false = read; true = write; + * + * @return array */ - public function addGeometry(Geometry\Geometry $Geometry) + protected function openFiles($files, $write_access) { - // Geometry type - $this->checkShapeType(); - if ( - $Geometry->getSHPBasetype() !== $this->getBasetype() || - (!$Geometry->isEmpty() && ($Geometry->isZ() !== $this->isZ())) || - (!$Geometry->isEmpty() && ($Geometry->isM() !== $this->isM())) - ) { - throw new ShapefileException(Shapefile::ERR_SHP_GEOMETRY_TYPE_NOT_COMPATIBLE, $this->getShapeType(Shapefile::FORMAT_INT) . ' - ' . $this->getShapeType(Shapefile::FORMAT_STR)); + // Create $files array from single string (SHP filename) + if (is_string($files)) { + $basename = (substr($files, -4) == '.' . Shapefile::FILE_SHP) ? substr($files, 0, -4) : $files; + $files = [ + Shapefile::FILE_SHP => $basename . '.' . Shapefile::FILE_SHP, + Shapefile::FILE_SHX => $basename . '.' . Shapefile::FILE_SHX, + Shapefile::FILE_DBF => $basename . '.' . Shapefile::FILE_DBF, + Shapefile::FILE_DBT => $basename . '.' . Shapefile::FILE_DBT, + Shapefile::FILE_PRJ => $basename . '.' . Shapefile::FILE_PRJ, + Shapefile::FILE_CPG => $basename . '.' . Shapefile::FILE_CPG, + ]; } - // Bounding box - $bbox = $Geometry->getBoundingBox(); - if (!$this->computed_bounding_box && $bbox) { - $this->computed_bounding_box = $bbox; - } elseif ($bbox) { - $this->computed_bounding_box['xmin'] = $bbox['xmin'] < $this->computed_bounding_box['xmin'] ? $bbox['xmin'] : $this->computed_bounding_box['xmin']; - $this->computed_bounding_box['xmax'] = $bbox['xmax'] > $this->computed_bounding_box['xmax'] ? $bbox['xmax'] : $this->computed_bounding_box['xmax']; - $this->computed_bounding_box['ymin'] = $bbox['ymin'] < $this->computed_bounding_box['ymin'] ? $bbox['ymin'] : $this->computed_bounding_box['ymin']; - $this->computed_bounding_box['ymax'] = $bbox['ymax'] > $this->computed_bounding_box['ymax'] ? $bbox['ymax'] : $this->computed_bounding_box['ymax']; - if ($this->isZ()) { - $this->computed_bounding_box['zmin'] = $bbox['zmin'] < $this->computed_bounding_box['zmin'] ? $bbox['zmin'] : $this->computed_bounding_box['zmin']; - $this->computed_bounding_box['zmax'] = $bbox['zmax'] > $this->computed_bounding_box['zmax'] ? $bbox['zmax'] : $this->computed_bounding_box['zmax']; + // Make sure required files are specified + if (!is_array($files) || !isset($files[Shapefile::FILE_SHP])) { + throw new ShapefileException(Shapefile::ERR_FILE_MISSING, strtoupper(Shapefile::FILE_SHP)); + } + if (!is_array($files) || !isset($files[Shapefile::FILE_SHX])) { + throw new ShapefileException(Shapefile::ERR_FILE_MISSING, strtoupper(Shapefile::FILE_SHX)); + } + if (!is_array($files) || !isset($files[Shapefile::FILE_DBF])) { + throw new ShapefileException(Shapefile::ERR_FILE_MISSING, strtoupper(Shapefile::FILE_DBF)); + } + + $mode = $write_access ? 'wb' : 'rb'; + $ret = []; + if ($files === array_filter($files, 'is_resource')) { + // Resource handles + $this->flag_resources = true; + foreach ($files as $type => $file) { + if (get_resource_type($file) != 'stream' || stream_get_meta_data($file)['mode'] != $mode) { + throw new ShapefileException(Shapefile::ERR_FILE_INVALID_RESOURCE, strtoupper($type)); + } + $this->files[$type] = $file; } - if ($this->isM()) { - $this->computed_bounding_box['mmin'] = ($this->computed_bounding_box['mmin'] === false || $bbox['mmin'] < $this->computed_bounding_box['mmin']) ? $bbox['mmin'] : $this->computed_bounding_box['mmin']; - $this->computed_bounding_box['mmax'] = ($this->computed_bounding_box['mmax'] === false || $bbox['mmax'] > $this->computed_bounding_box['mmax']) ? $bbox['mmax'] : $this->computed_bounding_box['mmax']; + } else { + // Filenames + foreach ([ + Shapefile::FILE_SHP => true, + Shapefile::FILE_SHX => true, + Shapefile::FILE_DBF => true, + Shapefile::FILE_DBT => false, + Shapefile::FILE_PRJ => false, + Shapefile::FILE_CPG => false, + ] as $type => $required) { + if (isset($files[$type])) { + if ( + (!$write_access && is_string($files[$type]) && is_readable($files[$type]) && is_file($files[$type])) + || ($write_access && is_string($files[$type]) && is_writable(dirname($files[$type])) && (!file_exists($files[$type]) || $this->getOption(Shapefile::OPTION_OVERWRITE_EXISTING_FILES))) + ) { + $handle = fopen($files[$type], $mode); + if ($handle === false) { + throw new ShapefileException(Shapefile::ERR_FILE_OPEN, $file); + } + $this->files[$type] = $handle; + $ret[$type] = realpath(stream_get_meta_data($handle)['uri']); + } elseif ($required) { + throw new ShapefileException(Shapefile::ERR_FILE_EXISTS, $files[$type]); + } + } } } - // Init Geometry with fields and flag Shapefile as initialized - $Geometry->setShapefile($this); - $this->flag_init = true; + return $ret; } - /** - * Gets shape type either as integer or string. - * - * @param integer $format Optional desired output format. - * It can be on of the following: - * - Shapefile::FORMAT_INT [default] - * - Shapefile::FORMAT_STR - * - * @return integer|string + * Closes all open resource handles. */ - public function getShapeType($format = Shapefile::FORMAT_INT) + protected function closeFiles() { - if ($this->shape_type === null) { - return null; - } - if ($format == Shapefile::FORMAT_STR) { - return Shapefile::$shape_types[$this->shape_type]; - } else { - return $this->shape_type; + if (!$this->flag_resources) { + foreach ($this->files as $handle) { + fclose($handle); + } } } /** - * Sets shape type. - * It can be called just once for an instance of the class. - * - * @param integer $type Shape type. It can be on of the following: - * - Shapefile::SHAPE_TYPE_NULL - * - Shapefile::SHAPE_TYPE_POINT - * - Shapefile::SHAPE_TYPE_POLYLINE - * - Shapefile::SHAPE_TYPE_POLYGON - * - Shapefile::SHAPE_TYPE_MULTIPOINT - * - Shapefile::SHAPE_TYPE_POINTZ - * - Shapefile::SHAPE_TYPE_POLYLINEZ - * - Shapefile::SHAPE_TYPE_POLYGONZ - * - Shapefile::SHAPE_TYPE_MULTIPOINTZ - * - Shapefile::SHAPE_TYPE_POINTM - * - Shapefile::SHAPE_TYPE_POLYLINEM - * - Shapefile::SHAPE_TYPE_POLYGONM - * - Shapefile::SHAPE_TYPE_MULTIPOINTM + * Checks if file type has been opened. + * + * @param string $file_type File type. + * + * @return bool */ - public function setShapeType($type) + protected function isFileOpen($file_type) { - if ($this->shape_type !== null) { - throw new ShapefileException(Shapefile::ERR_SHP_TYPE_ALREADY_SET); - } - if (!isset(Shapefile::$shape_types[$type])) { - throw new ShapefileException(Shapefile::ERR_SHP_TYPE_NOT_SUPPORTED, $type); - } - $this->shape_type = $type; + return isset($this->files[$file_type]); } - /** - * Gets Shapefile bounding box. - * - * @return array + * Reads data from an open resource handle. + * + * @param string $file_type File type. + * @param integer $length Number of bytes to read. + * + * @return string|bool */ - public function getBoundingBox() + protected function fileRead($file_type, $length) { - return $this->custom_bounding_box ?: $this->computed_bounding_box; + return fread($this->files[$file_type], $length); } /** - * Sets a custom bounding box for the Shapefile. - * No formal check is carried out except the compliance of dimensions. + * Writes data into an open resource handle. + * + * @param string $file_type File type. + * @param string $data String value to write. * - * @param array $bounding_box Associative array with the xmin, xmax, ymin, ymax and optional zmin, zmax, mmin, mmax values. + * @return integer|bool */ - public function setCustomBoundingBox($bounding_box) + protected function fileWrite($file_type, $data) { - if ( - !isset($bounding_box['xmin'], $bounding_box['xmax'], $bounding_box['ymin'], $bounding_box['ymax']) || - ($this->isZ() && !isset($bounding_box['zmin'], $bounding_box['zmax'])) || - ($this->isM() && !isset($bounding_box['mmin'], $bounding_box['mmax'])) - ) { - throw new ShapefileException(Shapefile::ERR_SHP_MISMATCHED_BBOX); - } - $this->custom_bounding_box = $bounding_box; + return fwrite($this->files[$file_type], $data); } /** - * Resets custom bounding box for the Shapefile. - * It will cause getBoundingBox() method to return a normally computed bbox instead of a custom one. + * Gets file size of an open a resource handle. + * + * @param string $file_type File type (member of $this->files array). + * + * @return integer */ - public function resetCustomBoundingBox() + protected function getFileSize($file_type) { - $this->custom_bounding_box = null; + return fstat($this->files[$file_type])['size']; } - /** - * Gets PRJ well-known-text. + * Sets the pointer position of a resource handle to specified value. * - * @return string + * @param string $file_type File type (member of $this->files array). + * @param integer $position The position to set the pointer to. */ - public function getPRJ() + protected function setFilePointer($file_type, $position) { - return $this->prj; + fseek($this->files[$file_type], $position, SEEK_SET); } /** - * Sets PRJ well-known-text. + * Gets current pointer position of a resource handle. * - * @param string $prj PRJ well-known-text. - * Pass a falsy value (ie. false or "") to delete it. + * @param string $file_type File type (member of $this->files array). + * + * @return integer */ - public function setPRJ($prj) + protected function getFilePointer($file_type) { - $this->prj = $prj ?: null; + return ftell($this->files[$file_type]); } - /** - * Gets DBF charset. + * Resets the pointer position of a resource handle to its end. * - * @return string + * @param string $file_type File type (member of $this->files array). */ - public function getCharset() + protected function resetFilePointer($file_type) { - return $this->charset ?: Shapefile::DEFAULT_DBF_CHARSET; + fseek($this->files[$file_type], 0, SEEK_END); } /** - * Sets or resets DBF charset. + * Increase the pointer position of a resource handle of specified value. * - * @param mixed $charset Name of the charset. - * Pass a falsy value (ie. false or "") to reset it to default. + * @param string $file_type File type (member of $this->files array). + * @param integer $offset The offset to move the pointer for. */ - public function setCharset($charset) + protected function setFileOffset($file_type, $offset) { - $this->charset = $charset ?: Shapefile::DEFAULT_DBF_CHARSET; + fseek($this->files[$file_type], $offset, SEEK_CUR); } /** - * Adds a char field to the Shapefile definition. + * Checks if machine is big endian. * - * @param string $name Name of the field. Maximum 10 characters. - * Only letters, numbers and underscores are allowed. - * @param integer $size Lenght of the field, between 1 and 254 characters. - * @param bool $flag_sanitize_name Optional flag to automatically replace illegal characters - * in the name with underscores. Defaults to true. + * @return bool */ - public function addCharField($name, $size, $flag_sanitize_name = true) + protected function isBigEndianMachine() { - $this->addField($name, Shapefile::DBF_TYPE_CHAR, $size); + if ($this->flag_big_endian_machine === null) { + $this->flag_big_endian_machine = current(unpack('v', pack('S', 0xff))) !== 0xff; + } + return $this->flag_big_endian_machine; } - /** - * Adds a date field to the Shapefile definition. - * - * @param string $name Name of the field. Maximum 10 characters. - * Only letters, numbers and underscores are allowed. - * @param bool $flag_sanitize_name Optional flag to automatically replace illegal characters - * in the name with underscores. Defaults to true. - */ - public function addDateField($name, $flag_sanitize_name = true) - { - $this->addField($name, Shapefile::DBF_TYPE_DATE, 8); - } /** - * Adds a logical/boolean field to the Shapefile definition. + * Initializes options with default and user-provided values. * - * @param string $name Name of the field. Maximum 10 characters. - * Only letters, numbers and underscores are allowed. - * @param bool $flag_sanitize_name Optional flag to automatically replace illegal characters - * in the name with underscores. Defaults to true. + * @param array $options Array of options to initialize. + * @param array $custom User-provided options */ - public function addLogicalField($name, $flag_sanitize_name = true) + protected function initOptions($options, $custom) { - $this->addField($name, Shapefile::DBF_TYPE_LOGICAL, 1); - } - + // Make sure compulsory options used in this abstract class are defined + $options = array_unique(array_merge($options, [ + Shapefile::OPTION_DBF_FORCE_ALL_CAPS, + Shapefile::OPTION_SUPPRESS_M, + Shapefile::OPTION_SUPPRESS_Z, + ])); + + // Defaults + $defaults = []; + foreach ($options as $option) { + $defaults[$option] = constant('Shapefile\Shapefile::' . $option . '_DEFAULT'); + } + + // Filter custom options + $custom = array_intersect_key(array_change_key_case($custom, CASE_UPPER), $defaults); + + // Initialize option array + $this->options = $custom + $defaults; + + // Use only the first character of OPTION_DBF_NULL_PADDING_CHAR if it's set and is not false or empty + $k = Shapefile::OPTION_DBF_NULL_PADDING_CHAR; + if (array_key_exists($k, $this->options)) { + $this->options[$k] = ($this->options[$k] === false || $this->options[$k] === null || $this->options[$k] === '') ? null : substr($this->options[$k], 0, 1); + } + + // Parse OPTION_DBF_IGNORED_FIELDS + $k = Shapefile::OPTION_DBF_IGNORED_FIELDS; + if (array_key_exists($k, $this->options)) { + $this->options[$k] = is_array($this->options[$k]) ? array_map([$this, 'sanitizeDBFFieldName'], $this->options[$k]) : []; + } + } + /** - * Adds a memo field to the Shapefile definition. + * Gets option value. + * + * @param string $option Name of the option. + * + * @return string + */ + protected function getOption($option) + { + return $this->options[$option]; + } + + /** + * Sets option value. + * + * @param string $option Name of the option. + * @param mixed $value Value of the option. + */ + protected function setOption($option, $value) + { + $this->options[$option] = $value; + } + + + /** + * Checks if Shapefile is of type Z. * - * @param string $name Name of the field. Maximum 10 characters. - * Only letters, numbers and underscores are allowed. - * @param bool $flag_sanitize_name Optional flag to automatically replace illegal characters - * in the name with underscores. Defaults to true. + * @return bool */ - public function addMemoField($name, $flag_sanitize_name = true) + protected function isZ() { - $this->addField($name, Shapefile::DBF_TYPE_MEMO, 10); + $shape_type = $this->getShapeType(Shapefile::FORMAT_INT); + return $shape_type > 10 && $shape_type < 20; } /** - * Adds numeric to the Shapefile definition. + * Checks if Shapefile is of type M. * - * @param string $name Name of the field. Maximum 10 characters. - * Only letters, numbers and underscores are allowed. - * @param integer $size Lenght of the field, between 1 and 254 characters. - * @param integer $decimals Optional number of decimal digits. - * @param bool $flag_sanitize_name Optional flag to automatically replace illegal characters - * in the name with underscores. Defaults to true. + * @return bool */ - public function addNumericField($name, $size, $decimals = 0, $flag_sanitize_name = true) + protected function isM() { - $this->addField($name, Shapefile::DBF_TYPE_NUMERIC, $size, $decimals); + return $this->getShapeType(Shapefile::FORMAT_INT) > 10; } + /** - * Adds floating point to the Shapefile definition. + * Gets Shapefile base type, regardless of Z and M dimensions. * - * @param string $name Name of the field. Maximum 10 characters. - * Only letters, numbers and underscores are allowed. - * @param integer $size Lenght of the field, between 1 and 254 characters. - * @param integer $decimals Number of decimal digits. - * @param bool $flag_sanitize_name Optional flag to automatically replace illegal characters - * in the name with underscores. Defaults to true. + * @return integer + */ + protected function getBasetype() + { + return $this->getShapeType(Shapefile::FORMAT_INT) % 10; + } + + + /** + * Gets shape type either as integer or string. + * + * @param integer $format Optional desired output format. + * It can be on of the following: + * - Shapefile::FORMAT_INT [default] + * - Shapefile::FORMAT_STR + * + * @return integer|string + */ + protected function getShapeType($format = Shapefile::FORMAT_INT) + { + if ($this->shape_type === null) { + throw new ShapefileException(Shapefile::ERR_SHP_TYPE_NOT_SET); + } + if ($format == Shapefile::FORMAT_STR) { + return Shapefile::$shape_types[$this->shape_type]; + } else { + return $this->shape_type; + } + } + + /** + * Sets shape type. + * It can be called just once for an instance of the class. + * + * @param integer $type Shape type. It can be on of the following: + * - Shapefile::SHAPE_TYPE_NULL + * - Shapefile::SHAPE_TYPE_POINT + * - Shapefile::SHAPE_TYPE_POLYLINE + * - Shapefile::SHAPE_TYPE_POLYGON + * - Shapefile::SHAPE_TYPE_MULTIPOINT + * - Shapefile::SHAPE_TYPE_POINTZ + * - Shapefile::SHAPE_TYPE_POLYLINEZ + * - Shapefile::SHAPE_TYPE_POLYGONZ + * - Shapefile::SHAPE_TYPE_MULTIPOINTZ + * - Shapefile::SHAPE_TYPE_POINTM + * - Shapefile::SHAPE_TYPE_POLYLINEM + * - Shapefile::SHAPE_TYPE_POLYGONM + * - Shapefile::SHAPE_TYPE_MULTIPOINTM + */ + protected function setShapeType($type) + { + if ($this->shape_type !== null) { + throw new ShapefileException(Shapefile::ERR_SHP_TYPE_ALREADY_SET); + } + if (!isset(Shapefile::$shape_types[$type])) { + throw new ShapefileException(Shapefile::ERR_SHP_TYPE_NOT_SUPPORTED, $type); + } + $this->shape_type = $type; + } + + + /** + * Gets Shapefile bounding box. + * + * @return array + */ + protected function getBoundingBox() + { + return $this->custom_bounding_box ?: $this->computed_bounding_box; + } + + /** + * Sets a custom bounding box for the Shapefile. + * No check is carried out except a formal compliance of dimensions. + * + * @param array $bounding_box Associative array with the xmin, xmax, ymin, ymax and optional zmin, zmax, mmin, mmax values. + */ + protected function setCustomBoundingBox($bounding_box) + { + $bounding_box = array_intersect_key($bounding_box, array_flip(['xmin', 'xmax', 'ymin', 'ymax', 'zmin', 'zmax', 'mmin', 'mmax'])); + if ($this->getOption(Shapefile::OPTION_SUPPRESS_Z)) { + unset($bounding_box['zmin'], $bounding_box['zmax']); + } + if ($this->getOption(Shapefile::OPTION_SUPPRESS_M)) { + unset($bounding_box['mmin'], $bounding_box['mmax']); + } + + if ( + !isset($bounding_box['xmin'], $bounding_box['xmax'], $bounding_box['ymin'], $bounding_box['ymax']) + || ( + ($this->isZ() && !$this->getOption(Shapefile::OPTION_SUPPRESS_Z) && !isset($bounding_box['zmin'], $bounding_box['zmax'])) + || (!$this->isZ() && (isset($bounding_box['zmin']) || isset($bounding_box['zmax']))) + ) + || ( + ($this->isM() && !$this->getOption(Shapefile::OPTION_SUPPRESS_M) && !isset($bounding_box['mmin'], $bounding_box['mmax'])) + || (!$this->isM() && (isset($bounding_box['mmin']) || isset($bounding_box['mmax']))) + ) + ) { + throw new ShapefileException(Shapefile::ERR_SHP_MISMATCHED_BBOX); + } + + $this->custom_bounding_box = $bounding_box; + } + + /** + * Resets custom bounding box for the Shapefile. + * It will cause getBoundingBox() method to return a normally computed bbox instead of a custom one. + */ + protected function resetCustomBoundingBox() + { + $this->custom_bounding_box = null; + } + + + /** + * Gets PRJ well-known-text. + * + * @return string + */ + protected function getPRJ() + { + return $this->prj; + } + + /** + * Sets PRJ well-known-text. + * + * @param string $prj PRJ well-known-text. + * Pass a falsy value (ie. false or "") to delete it. + */ + protected function setPRJ($prj) + { + $this->prj = $prj ?: null; + } + + + /** + * Gets DBF charset. + * + * @return string + */ + protected function getCharset() + { + return $this->charset ?: Shapefile::DBF_DEFAULT_CHARSET; + } + + /** + * Sets or resets DBF charset. + * + * @param mixed $charset Name of the charset. + * Pass a falsy value (ie. false or "") to reset it to default. */ - public function addFloatField($name, $size, $decimals, $flag_sanitize_name = true) + protected function setCharset($charset) { - $this->addField($name, Shapefile::DBF_TYPE_FLOAT, $size, $decimals); + $this->charset = $charset ?: Shapefile::DBF_DEFAULT_CHARSET; } + /** - * Adds field to the shapefile definition. - * Returns the actual field name after eventual sanitization. + * Adds a field to the shapefile definition. + * Returns the effective field name after eventual sanitization. * * @param string $name Name of the field. Maximum 10 characters. * Only letters, numbers and underscores are allowed. @@ -643,9 +901,13 @@ public function addFloatField($name, $size, $decimals, $flag_sanitize_name = tru * * @return string */ - public function addField($name, $type, $size, $decimals = 0, $flag_sanitize_name = true) + protected function addField($name, $type, $size, $decimals, $flag_sanitize_name = true) { - $this->checkInit(); + // Check init + if ($this->flag_init) { + throw new ShapefileException(Shapefile::ERR_SHP_FILE_ALREADY_INITIALIZED); + } + // Check filed count if (count($this->fields) >= Shapefile::DBF_MAX_FIELD_COUNT) { throw new ShapefileException(Shapefile::ERR_DBF_MAX_FIELD_COUNT_REACHED, Shapefile::DBF_MAX_FIELD_COUNT); } @@ -678,13 +940,13 @@ public function addField($name, $type, $size, $decimals = 0, $flag_sanitize_name // Check size $size = intval($size); if ( - ($size < 1) || - ($type == Shapefile::DBF_TYPE_CHAR && $size > 254) || - ($type == Shapefile::DBF_TYPE_DATE && $size !== 8) || - ($type == Shapefile::DBF_TYPE_LOGICAL && $size !== 1) || - ($type == Shapefile::DBF_TYPE_MEMO && $size !== 10) || - ($type == Shapefile::DBF_TYPE_NUMERIC && $size > 254) || - ($type == Shapefile::DBF_TYPE_FLOAT && $size > 254) + ($size < 1) + || ($type == Shapefile::DBF_TYPE_CHAR && $size > 254) + || ($type == Shapefile::DBF_TYPE_DATE && $size !== 8) + || ($type == Shapefile::DBF_TYPE_LOGICAL && $size !== 1) + || ($type == Shapefile::DBF_TYPE_MEMO && $size !== 10) + || ($type == Shapefile::DBF_TYPE_NUMERIC && $size > 254) + || ($type == Shapefile::DBF_TYPE_FLOAT && $size > 254) ) { throw new ShapefileException(Shapefile::ERR_DBF_FIELD_SIZE_NOT_VALID, $size); } @@ -692,10 +954,10 @@ public function addField($name, $type, $size, $decimals = 0, $flag_sanitize_name // Minimal decimal formal check $decimals = intval($decimals); if ( - ($type != Shapefile::DBF_TYPE_NUMERIC && $type != Shapefile::DBF_TYPE_FLOAT && $decimals !== 0) || - ($type == Shapefile::DBF_TYPE_FLOAT && $decimals === 0) || - ($decimals < 0) || - ($decimals > 0 && $decimals > $size - 2) + ($type != Shapefile::DBF_TYPE_NUMERIC && $type != Shapefile::DBF_TYPE_FLOAT && $decimals !== 0) + || ($type == Shapefile::DBF_TYPE_FLOAT && $decimals === 0) + || ($decimals < 0) + || ($decimals > 0 && $size - 1 <= $decimals) ) { throw new ShapefileException(Shapefile::ERR_DBF_FIELD_DECIMALS_NOT_VALID, $type . ' - ' . $decimals); } @@ -710,7 +972,6 @@ public function addField($name, $type, $size, $decimals = 0, $flag_sanitize_name return $name; } - /** * Gets a complete field definition. * @@ -725,169 +986,25 @@ public function addField($name, $type, $size, $decimals = 0, $flag_sanitize_name * * @return array */ - public function getField($name) + protected function getField($name) { - $this->checkField($name); + if (!isset($this->fields[$name])) { + throw new ShapefileException(Shapefile::ERR_INPUT_FIELD_NOT_FOUND, $name); + } return $this->fields[$name]; } - /** - * Gets a field type. - * - * @param string $name Name of the field. - * - * @return string - */ - public function getFieldType($name) - { - return $this->getField($name)['type']; - } - - /** - * Gets a field size. - * - * @param string $name Name of the field. - * - * @return integer - */ - public function getFieldSize($name) - { - return $this->getField($name)['size']; - } - - /** - * Gets a field decimals. - * - * @param string $name Name of the field. - * - * @return integer - */ - public function getFieldDecimals($name) - { - return $this->getField($name)['decimals']; - } - /** * Gets all fields definition. * * @return array */ - public function getFields() + protected function getFields() { return $this->fields; } - /** - * Checks if field is defined and if not throws and exception. - * This is not intended for users, but this class and Geometry require it for internal mechanisms. - * - * @internal - * - * @param string $name Name of the field. - */ - public function checkField($name, $flag_sanitize_name = true) - { - if (!isset($this->fields[$name])) { - throw new ShapefileException(Shapefile::ERR_INPUT_FIELD_NOT_FOUND, $name); - } - } - - - - /////////////////////////////// PROTECTED /////////////////////////////// - /** - * Initializes options with default and user-provided values. - * - * @param array $options Array of options to initialize. - * @param array $custom User-provided options - */ - protected function initOptions($options, $custom) - { - // Defaults - $defaults = []; - foreach ($options as $option) { - $defaults[$option] = constant('Shapefile\Shapefile::' . $option . '_DEFAULT'); - } - - // Filter custom options - $custom = array_intersect_key(array_change_key_case($custom, CASE_UPPER), $defaults); - - // Initialize option array - $this->options = $custom + $defaults; - - // Use only the first character of OPTION_DBF_NULL_PADDING_CHAR if it's set and is not false or empty - $k = Shapefile::OPTION_DBF_NULL_PADDING_CHAR; - if (array_key_exists($k, $this->options)) { - $this->options[$k] = ($this->options[$k] === false || $this->options[$k] === null || $this->options[$k] === '') ? null : substr($this->options[$k], 0, 1); - } - - // Parse OPTION_DBF_IGNORED_FIELDS - $k = Shapefile::OPTION_DBF_IGNORED_FIELDS; - if (array_key_exists($k, $this->options)) { - $this->options[$k] = is_array($this->options[$k]) ? array_map([$this, 'sanitizeDBFFieldName'], $this->options[$k]) : []; - } - } - - /** - * Gets option value. - * - * @param string $option Name of the option. - * - * @return string - */ - protected function getOption($option) - { - return $this->options[$option]; - } - - /** - * Sets option value. - * - * @param string $option Name of the option. - * @param mixed $value Value of the option. - */ - protected function setOption($option, $value) - { - $this->options[$option] = $value; - } - - - /** - * Checks if Shapefile is of type Z. - * - * @return bool - */ - protected function isZ() - { - $this->checkShapeType(); - return !$this->getOption(Shapefile::OPTION_SUPPRESS_Z) && $this->shape_type > 10 && $this->shape_type < 20; - } - - /** - * Checks if Shapefile is of type M. - * - * @return bool - */ - protected function isM() - { - $this->checkShapeType(); - return !$this->getOption(Shapefile::OPTION_SUPPRESS_M) && $this->shape_type > 10; - } - - - /** - * Gets Shapefile base type, regardless of Z and M dimensions. - * - * @return integer - */ - protected function getBasetype() - { - $this->checkShapeType(); - return $this->shape_type % 10; - } - - /** * Returns a valid name for DBF fields. * @@ -911,27 +1028,86 @@ protected function sanitizeDBFFieldName($input) } - - /////////////////////////////// PRIVATE /////////////////////////////// /** - * Checks if the Shapefile has been initialized with any Geometry and if YES throws and exception. + * Checks whether a ring is clockwise or not. + * It uses Gauss's area formula to check for positive or negative orientation. + * + * An optional $exp parameter is used in order to deal with small polygons. + * + * @param array $points Array of points. Each element must have a "x" and "y" member. + * @param integer $exp Optional exponent to deal with small areas. + * + * @return bool */ - private function checkInit() + protected function isClockwise($points, $exp = 1) { - if ($this->flag_init) { - throw new ShapefileException(Shapefile::ERR_SHP_FILE_ALREADY_INITIALIZED); + $num_points = count($points); + if ($num_points < 2) { + return true; } + + $num_points--; + $tot = 0; + for ($i = 0; $i < $num_points; ++$i) { + $tot += ($exp * $points[$i]['x'] * $points[$i+1]['y']) - ($exp * $points[$i]['y'] * $points[$i+1]['x']); + } + $tot += ($exp * $points[$num_points]['x'] * $points[0]['y']) - ($exp * $points[$num_points]['y'] * $points[0]['x']); + + if ($tot == 0) { + if ($exp >= pow(10, 9)) { + throw new ShapefileException(Shapefile::ERR_GEOM_POLYGON_AREA_TOO_SMALL); + } + return $this->isClockwise($points, $exp * pow(10, 3)); + } + + return $tot < 0; } /** - * Checks if the shape type has been set and if NOT throws and exception. + * Pairs a Geometry with the Shapefile. + * It enforces the Geometry type and computes Shapefile bounding box. + * After that the Shapefile will be considered as "initialized" and no changes will be allowd to its structure. + * + * @param Geometry $Geometry Geometry to pair with. */ - private function checkShapeType() + protected function pairGeometry(Geometry\Geometry $Geometry) { - if ($this->shape_type === null) { - throw new ShapefileException(Shapefile::ERR_SHP_TYPE_NOT_SET); + // Geometry type + if ( + $this->getBasetype() !== $Geometry->getSHPBasetype() + || (!$Geometry->isEmpty() && $Geometry->isZ() !== $this->isZ() && !$this->getOption(Shapefile::OPTION_SUPPRESS_Z)) + || (!$Geometry->isEmpty() && $Geometry->isM() !== $this->isM() && !$this->getOption(Shapefile::OPTION_SUPPRESS_M)) + ) { + throw new ShapefileException(Shapefile::ERR_SHP_GEOMETRY_TYPE_NOT_COMPATIBLE, $this->getShapeType(Shapefile::FORMAT_INT) . ' - ' . $this->getShapeType(Shapefile::FORMAT_STR)); } + + // Bounding box + $bbox = $Geometry->getBoundingBox(); + if (!$this->computed_bounding_box && $bbox) { + if ($this->getOption(Shapefile::OPTION_SUPPRESS_Z)) { + unset($bbox['zmin'], $bbox['zmax']); + } + if ($this->getOption(Shapefile::OPTION_SUPPRESS_M)) { + unset($bbox['mmin'], $bbox['mmax']); + } + $this->computed_bounding_box = $bbox; + } elseif ($bbox) { + $this->computed_bounding_box['xmin'] = $bbox['xmin'] < $this->computed_bounding_box['xmin'] ? $bbox['xmin'] : $this->computed_bounding_box['xmin']; + $this->computed_bounding_box['xmax'] = $bbox['xmax'] > $this->computed_bounding_box['xmax'] ? $bbox['xmax'] : $this->computed_bounding_box['xmax']; + $this->computed_bounding_box['ymin'] = $bbox['ymin'] < $this->computed_bounding_box['ymin'] ? $bbox['ymin'] : $this->computed_bounding_box['ymin']; + $this->computed_bounding_box['ymax'] = $bbox['ymax'] > $this->computed_bounding_box['ymax'] ? $bbox['ymax'] : $this->computed_bounding_box['ymax']; + if ($this->isZ() && !$this->getOption(Shapefile::OPTION_SUPPRESS_Z)) { + $this->computed_bounding_box['zmin'] = $bbox['zmin'] < $this->computed_bounding_box['zmin'] ? $bbox['zmin'] : $this->computed_bounding_box['zmin']; + $this->computed_bounding_box['zmax'] = $bbox['zmax'] > $this->computed_bounding_box['zmax'] ? $bbox['zmax'] : $this->computed_bounding_box['zmax']; + } + if ($this->isM() && !$this->getOption(Shapefile::OPTION_SUPPRESS_M)) { + $this->computed_bounding_box['mmin'] = ($this->computed_bounding_box['mmin'] === false || $bbox['mmin'] < $this->computed_bounding_box['mmin']) ? $bbox['mmin'] : $this->computed_bounding_box['mmin']; + $this->computed_bounding_box['mmax'] = ($this->computed_bounding_box['mmax'] === false || $bbox['mmax'] > $this->computed_bounding_box['mmax']) ? $bbox['mmax'] : $this->computed_bounding_box['mmax']; + } + } + // Mark Shapefile as initialized + $this->flag_init = true; } } diff --git a/src/Shapefile/ShapefileException.php b/src/Shapefile/ShapefileException.php index 633c05e..373869d 100644 --- a/src/Shapefile/ShapefileException.php +++ b/src/Shapefile/ShapefileException.php @@ -21,6 +21,11 @@ class ShapefileException extends \Exception */ private $error_type; + /** + * @var string Additional information about the error. + */ + private $details; + /** * Constructor @@ -30,13 +35,10 @@ class ShapefileException extends \Exception */ public function __construct($error_type, $details = '') { - $this->error_type = $error_type; + $this->error_type = $error_type; + $this->details = $details; $message = constant('Shapefile\Shapefile::' . $error_type . '_MESSAGE'); - if ($details !== '') { - $message .= ': "' . $details . '"'; - } - parent::__construct($message, 0, null); } @@ -49,4 +51,15 @@ public function getErrorType() { return $this->error_type; } + + /** + * Gets error details. + * + * @return string + */ + public function getDetails() + { + return $this->details; + } + } diff --git a/src/Shapefile/ShapefileReader.php b/src/Shapefile/ShapefileReader.php index 2ff50d8..624118e 100644 --- a/src/Shapefile/ShapefileReader.php +++ b/src/Shapefile/ShapefileReader.php @@ -21,19 +21,14 @@ class ShapefileReader extends Shapefile implements \Iterator { /** - * @var array Array of files. Every files is represented as an associative array: - * "xxx" => [ - * "handle" => resource - * "size" => integer - * ] + * @var array DBF field names map: fields are numerically indexed into DBF files. */ - private $files = []; - + private $dbf_fields = []; /** - * @var array DBF field names map: fields are numerically indexed into DBF files. + * @var integer DBF file size in bytes. */ - private $dbf_fields = []; + private $dbf_file_size; /** * @var integer DBF file header size in bytes. @@ -45,12 +40,10 @@ class ShapefileReader extends Shapefile implements \Iterator */ private $dbf_record_size; - /** - * @var bool Flag used by readDoubleL() method. + * @var integer DBT file size in bytes. */ - private $big_endian_machine; - + private $dbt_file_size; /** * @var integer Pointer to current SHP and DBF files record. @@ -68,85 +61,47 @@ class ShapefileReader extends Shapefile implements \Iterator /** * Constructor. * - * @param string|array $files Path to SHP file or array of paths or handles of individual files. + * @param string|array $files Path to SHP file / Array of paths / Array of handles of individual files. * @param array $options Optional associative array of options. */ public function __construct($files, $options = []) { - // Files - if (is_string($files)) { - $basename = (substr($files, -4) == '.' . Shapefile::FILE_SHP) ? substr($files, 0, -4) : $files; - $files = [ - Shapefile::FILE_SHP => $basename . '.' . Shapefile::FILE_SHP, - Shapefile::FILE_SHX => $basename . '.' . Shapefile::FILE_SHX, - Shapefile::FILE_DBF => $basename . '.' . Shapefile::FILE_DBF, - Shapefile::FILE_DBT => $basename . '.' . Shapefile::FILE_DBT, - Shapefile::FILE_PRJ => $basename . '.' . Shapefile::FILE_PRJ, - Shapefile::FILE_CPG => $basename . '.' . Shapefile::FILE_CPG, - Shapefile::FILE_CST => $basename . '.' . Shapefile::FILE_CST, - ]; - } else { - if (!isset($files[Shapefile::FILE_SHP], $files[Shapefile::FILE_SHX], $files[Shapefile::FILE_DBF])) { - throw new ShapefileException(Shapefile::ERR_FILE_MISSING, strtoupper(implode(', ', [Shapefile::FILE_SHP, Shapefile::FILE_SHX, Shapefile::FILE_DBF]))); - } - } - if (array_filter($files, 'is_resource') === $files) { - foreach ($files as $type => $file) { - $this->files[$type] = [ - 'handle' => $file, - 'size' => fstat($file)['size'], - ]; - } - } else { - foreach ([ - ['type' => Shapefile::FILE_SHP, 'required' => true], - ['type' => Shapefile::FILE_SHX, 'required' => true], - ['type' => Shapefile::FILE_DBF, 'required' => true], - ['type' => Shapefile::FILE_DBT, 'required' => false], - ['type' => Shapefile::FILE_PRJ, 'required' => false], - ['type' => Shapefile::FILE_CPG, 'required' => false], - ['type' => Shapefile::FILE_CST, 'required' => false], - ] as $f) { - if (isset($files[$f['type']])) { - if (is_string($files[$f['type']]) && is_readable($files[$f['type']]) && is_file($files[$f['type']])) { - $this->files[$f['type']] = $this->openFile($files[$f['type']]); - } elseif ($f['required']) { - throw new ShapefileException(Shapefile::ERR_FILE_EXISTS, $files[$f['type']]); - } - } - } - } - // Options $this->initOptions([ - Shapefile::OPTION_INVERT_POLYGONS_ORIENTATION, - Shapefile::OPTION_SUPPRESS_Z, - Shapefile::OPTION_SUPPRESS_M, + Shapefile::OPTION_DBF_CONVERT_TO_UTF8, Shapefile::OPTION_DBF_FORCE_ALL_CAPS, + Shapefile::OPTION_DBF_IGNORED_FIELDS, Shapefile::OPTION_DBF_NULL_PADDING_CHAR, + Shapefile::OPTION_DBF_NULLIFY_INVALID_DATES, + Shapefile::OPTION_DBF_RETURN_DATES_AS_OBJECTS, Shapefile::OPTION_ENFORCE_POLYGON_CLOSED_RINGS, Shapefile::OPTION_FORCE_MULTIPART_GEOMETRIES, - Shapefile::OPTION_IGNORE_SHAPEFILE_BBOX, Shapefile::OPTION_IGNORE_GEOMETRIES_BBOXES, - Shapefile::OPTION_DBF_IGNORED_FIELDS, - Shapefile::OPTION_DBF_NULLIFY_INVALID_DATES, - Shapefile::OPTION_DBF_CONVERT_TO_UTF8, + Shapefile::OPTION_IGNORE_SHAPEFILE_BBOX, + Shapefile::OPTION_INVERT_POLYGONS_ORIENTATION, + Shapefile::OPTION_SUPPRESS_M, + Shapefile::OPTION_SUPPRESS_Z, ], $options); - // Misc - $this->big_endian_machine = current(unpack('v', pack('S', 0xff))) !== 0xff; - $this->tot_records = ($this->files[Shapefile::FILE_SHX]['size'] - 100) / 8; + // Open files + $this->openFiles($files, false); + + // Gets number of records from SHX file size. + $this->tot_records = ($this->getFileSize(Shapefile::FILE_SHX) - Shapefile::SHX_HEADER_SIZE) / Shapefile::SHX_RECORD_SIZE; + + // DBF file size + $this->dbf_file_size = $this->getFileSize(Shapefile::FILE_DBF); + // DBT file size + $this->dbt_file_size = $this->isFileOpen(Shapefile::FILE_DBT) ? $this->getFileSize(Shapefile::FILE_DBT) : null; // PRJ - if (isset($this->files[Shapefile::FILE_PRJ])) { - $this->setPRJ($this->readString(Shapefile::FILE_PRJ, $this->files[Shapefile::FILE_PRJ]['size'])); + if ($this->isFileOpen(Shapefile::FILE_PRJ)) { + $this->setPRJ($this->readString(Shapefile::FILE_PRJ, $this->getFileSize(Shapefile::FILE_PRJ))); } - // CPG and CST (DBF charset): CPG file takes precedence over CST one - if (isset($this->files[Shapefile::FILE_CPG])) { - $this->setCharset($this->readString(Shapefile::FILE_CPG, $this->files[Shapefile::FILE_CPG]['size'])); - } elseif (isset($this->files[Shapefile::FILE_CST])) { - $this->setCharset($this->readString(Shapefile::FILE_CST, $this->files[Shapefile::FILE_CST]['size'])); + // CPG + if ($this->isFileOpen(Shapefile::FILE_CPG)) { + $this->setCharset($this->readString(Shapefile::FILE_CPG, $this->getFileSize(Shapefile::FILE_CPG))); } // Read headers @@ -160,13 +115,11 @@ public function __construct($files, $options = []) /** * Destructor. * - * Closes all file pointer resource handles. + * Closes all files. */ public function __destruct() { - foreach ($this->files as $file) { - $this->closeFile($file['handle']); - } + $this->closeFiles(); } @@ -200,6 +153,78 @@ public function valid() } + public function getShapeType($format = Shapefile::FORMAT_INT) + { + return parent::getShapeType($format); + } + + public function getBoundingBox() + { + return parent::getBoundingBox(); + } + + public function getPRJ() + { + return parent::getPRJ(); + } + + public function getCharset() + { + return parent::getCharset(); + } + + public function setCharset($charset) + { + parent::setCharset($charset); + } + + public function getField($name) + { + return parent::getField($name); + } + + public function getFields() + { + return parent::getFields(); + } + + /** + * Gets a field type. + * + * @param string $name Name of the field. + * + * @return string + */ + public function getFieldType($name) + { + return $this->getField($name)['type']; + } + + /** + * Gets a field size. + * + * @param string $name Name of the field. + * + * @return integer + */ + public function getFieldSize($name) + { + return $this->getField($name)['size']; + } + + /** + * Gets a field decimals. + * + * @param string $name Name of the field. + * + * @return integer + */ + public function getFieldDecimals($name) + { + return $this->getField($name)['decimals']; + } + + /** * Gets total number of records in SHP and DBF files. * @@ -214,7 +239,7 @@ public function getTotRecords() * Gets current record index. * * Note that records count starts from 1 in Shapefiles. - * When the last record is reached, the special value ShapeFile::EOF will be returned. + * When the last record is reached, the special value Shapefile::EOF will be returned. * * @return integer */ @@ -236,7 +261,6 @@ public function setCurrentRecord($index) $this->current_record = $index; } - /** * Gets current record and moves the cursor to the next one. * @@ -255,74 +279,20 @@ public function fetchRecord() /////////////////////////////// PRIVATE /////////////////////////////// /** - * Opens a file in binary read mode returning a resource handle and its size. - * - * @param string $file Path to the file to open. + * Reads data from a file and unpacks it according to the given format. * - * @return array Associative array with "handle" (file pointer resource) and "size" (file size in bytes) - */ - private function openFile($file) - { - $handle = fopen($file, 'rb'); - if (!$handle) { - throw new ShapefileException(Shapefile::ERR_FILE_OPEN, $file); - } - return [ - 'handle' => $handle, - 'size' => fstat($handle)['size'], - ]; - } - - /** - * Closes a previously opened resource handle. - * - * @param resource $handle The resource handle of the file. - */ - private function closeFile($handle) - { - if ($handle) { - fclose($handle); - } - } - - /** - * Sets the pointer position of a resource handle to specified value. - * - * @param string $file_type File type (member of $this->files array). - * @param integer $position The position to set the pointer to. - */ - private function setFilePointer($file_type, $position) - { - fseek($this->files[$file_type]['handle'], $position, SEEK_SET); - } - - /** - * Increase the pointer position of a resource handle of specified value. - * - * @param string $file_type File type (member of $this->files array). - * @param integer $offset The offset to move the pointer for. - */ - private function setFileOffset($file_type, $offset) - { - fseek($this->files[$file_type]['handle'], $offset, SEEK_CUR); - } - - - /** - * Reads data from a resource handle and unpacks it according to the given format. - * - * @param string $file_type File type (member of $this->files array). + * @param string $file_type File type. * @param string $format Format code. See php pack() documentation. * @param integer $length Number of bytes to read. * @param bool $invert_endianness Set this optional flag to true when reading floating point numbers on a big endian machine. * - * @return mixed + * @return string */ private function readData($file_type, $format, $length, $invert_endianness = false) { - $data = fread($this->files[$file_type]['handle'], $length); + $data = $this->fileRead($file_type, $length); if ($data === false) { - return null; + throw new ShapefileException(Shapefile::ERR_FILE_READING); } if ($invert_endianness) { $data = strrev($data); @@ -330,10 +300,22 @@ private function readData($file_type, $format, $length, $invert_endianness = fal return current(unpack($format, $data)); } + /** + * Reads an unsigned char from a resource handle. + * + * @param string $file_type File type. + * + * @return integer + */ + private function readChar($file_type) + { + return $this->readData($file_type, 'C', 1); + } + /** * Reads an unsigned short, 16 bit, little endian byte order, from a resource handle. * - * @param string $file_type File type (member of $this->files array). + * @param string $file_type File type. * * @return integer */ @@ -345,7 +327,7 @@ private function readInt16L($file_type) /** * Reads an unsigned long, 32 bit, big endian byte order, from a resource handle. * - * @param string $file_type File type (member of $this->files array). + * @param string $file_type File type. * * @return integer */ @@ -357,7 +339,7 @@ private function readInt32B($file_type) /** * Reads an unsigned long, 32 bit, little endian byte order, from a resource handle. * - * @param string $file_type File type (member of $this->files array). + * @param string $file_type File type. * * @return integer */ @@ -369,19 +351,19 @@ private function readInt32L($file_type) /** * Reads a double, 64 bit, little endian byte order, from a resource handle. * - * @param string $file_type File type (member of $this->files array). + * @param string $file_type File type. * * @return double */ private function readDoubleL($file_type) { - return $this->readData($file_type, 'd', 8, $this->big_endian_machine); + return $this->readData($file_type, 'd', 8, $this->isBigEndianMachine()); } /** - * Reads a string of given length from a resource handle and converts it to UTF-8. + * Reads a string of given length from a resource handle and optionally converts it to UTF-8. * - * @param string $file_type File type (member of $this->files array). + * @param string $file_type File type. * @param integer $length Length of the string to read. * @param bool $flag_utf8_encode Optional flag to convert output to UTF-8 if OPTION_DBF_CONVERT_TO_UTF8 is enabled. * @@ -399,18 +381,6 @@ private function readString($file_type, $length, $flag_utf8_encode = false) return trim($ret); } - /** - * Reads an unsigned char from a resource handle. - * - * @param string $file_type File type (member of $this->files array). - * - * @return string - */ - private function readChar($file_type) - { - return $this->readData($file_type, 'C', 1); - } - /** * Checks whether a record index value is valid or not. @@ -467,14 +437,14 @@ private function readDBFHeader() // Fields $this->dbf_fields = []; $this->setFilePointer(Shapefile::FILE_DBF, 32); - while (ftell($this->files['dbf']['handle']) < $this->dbf_header_size - 1) { + while ($this->getFilePointer(Shapefile::FILE_DBF) < $this->dbf_header_size - 1) { $name = $this->sanitizeDBFFieldName($this->readString(Shapefile::FILE_DBF, 11)); $type = $this->readString(Shapefile::FILE_DBF, 1); $this->setFileOffset(Shapefile::FILE_DBF, 4); $size = $this->readChar(Shapefile::FILE_DBF); $decimals = $this->readChar(Shapefile::FILE_DBF); $ignored = in_array($name, $this->getOption(Shapefile::OPTION_DBF_IGNORED_FIELDS)); - if ($type === Shapefile::DBF_TYPE_MEMO && !$flag_ignore && !isset($files[Shapefile::FILE_DBT])) { + if ($type === Shapefile::DBF_TYPE_MEMO && !$flag_ignore && !$this->isFileOpen(Shapefile::FILE_DBT)) { throw new ShapefileException(Shapefile::ERR_FILE_MISSING, strtoupper(Shapefile::FILE_DBT)); } $this->dbf_fields[] = [ @@ -503,18 +473,21 @@ private function readCurrentRecord() return false; } - // Read SHP offset from SHX - $this->setFilePointer(Shapefile::FILE_SHX, 100 + (($this->current_record - 1) * 8)); + // === SHX === + $this->setFilePointer(Shapefile::FILE_SHX, Shapefile::SHX_HEADER_SIZE + (($this->current_record - 1) * Shapefile::SHX_RECORD_SIZE)); + // Offset (in 16bit words) $shp_offset = $this->readInt32B(Shapefile::FILE_SHX) * 2; - $this->setFilePointer(Shapefile::FILE_SHP, $shp_offset); - // Read SHP record header + + // === SHP === + $this->setFilePointer(Shapefile::FILE_SHP, $shp_offset); + // Skip record header $this->setFileOffset(Shapefile::FILE_SHP, 8); + // Shape type $shape_type = $this->readInt32L(Shapefile::FILE_SHP); if ($shape_type != Shapefile::SHAPE_TYPE_NULL && $shape_type != $this->getShapeType()) { throw new ShapefileException(Shapefile::ERR_SHP_WRONG_RECORD_TYPE, $shape_type); } - // Read Geometry $methods = [ Shapefile::SHAPE_TYPE_NULL => 'readNull', @@ -532,25 +505,40 @@ private function readCurrentRecord() Shapefile::SHAPE_TYPE_MULTIPOINTM => 'readMultiPointM', ]; $Geometry = $this->{$methods[$shape_type]}(); - $this->addGeometry($Geometry); - // Read DBF data + + // === DBF === $this->setFilePointer(Shapefile::FILE_DBF, $this->dbf_header_size + (($this->current_record - 1) * $this->dbf_record_size)); // Check if DBF is not corrupted (some "naive" users try to edit the DBF separately...) // Some GIS do not include the last Shapefile::DBF_EOF_MARKER (0x1a) byte in the DBF file, hence the "+ 1" in the following line - if (ftell($this->files['dbf']['handle']) >= ($this->files['dbf']['size'] - $this->dbf_record_size + 1)) { + if ($this->getFilePointer(Shapefile::FILE_DBF) >= ($this->dbf_file_size - $this->dbf_record_size + 1)) { throw new ShapefileException(Shapefile::ERR_DBF_EOF_REACHED); } - $Geometry->setFlagDeleted($this->readChar(Shapefile::FILE_DBF) !== Shapefile::DBF_BLANK); + $Geometry->setFlagDeleted($this->readChar(Shapefile::FILE_DBF) === Shapefile::DBF_DELETED_MARKER); foreach ($this->dbf_fields as $i => $f) { if ($f['ignored']) { $this->setFileOffset(Shapefile::FILE_DBF, $f['size']); } else { - $value = $this->decodeFieldValue($f['name'], $this->readString(Shapefile::FILE_DBF, $f['size'], true)); + $type = $this->getField($f['name'])['type']; + $value = $this->decodeFieldValue($f['name'], $type, $this->readString(Shapefile::FILE_DBF, $f['size'], true)); + // Memo (DBT) + if ($type === Shapefile::DBF_TYPE_MEMO && $value) { + $this->setFilePointer(Shapefile::FILE_DBT, intval($value) * Shapefile::DBT_BLOCK_SIZE); + $value = ''; + do { + if ($this->getFilePointer(Shapefile::FILE_DBT) >= $this->dbt_file_size) { + throw new ShapefileException(Shapefile::ERR_DBT_EOF_REACHED); + } + $value .= $this->readString(Shapefile::FILE_DBT, Shapefile::DBT_BLOCK_SIZE, true); + // Some software only sets ONE field terminator instead of TWO, hence the weird loop condition check: + } while (ord(substr($value, -1)) != Shapefile::DBT_FIELD_TERMINATOR && ord(substr($value, -2, 1)) != Shapefile::DBT_FIELD_TERMINATOR); + $value = substr($value, 0, -2); + } $Geometry->setData($f['name'], $value); } } + $this->pairGeometry($Geometry); return $Geometry; } @@ -559,21 +547,25 @@ private function readCurrentRecord() * Decodes a raw value read from a DBF field. * * @param string $field Name of the field. + * @param string $type Type of the field. * @param string $value Raw value to decode. * * @return mixed */ - private function decodeFieldValue($field, $value) + private function decodeFieldValue($field, $type, $value) { - if ($this->getOption(Shapefile::OPTION_DBF_NULL_PADDING_CHAR) !== null && $value == str_repeat($this->getOption(Shapefile::OPTION_DBF_NULL_PADDING_CHAR), $this->getFieldSize($field))) { + if ($this->getOption(Shapefile::OPTION_DBF_NULL_PADDING_CHAR) !== null && $value == str_repeat($this->getOption(Shapefile::OPTION_DBF_NULL_PADDING_CHAR), $this->getField($field)['size'])) { $value = null; } else { - switch ($this->getFieldType($field)) { + switch ($type) { case Shapefile::DBF_TYPE_DATE: $DateTime = \DateTime::createFromFormat('Ymd', $value); $errors = \DateTime::getLastErrors(); if ($errors['warning_count'] || $errors['error_count']) { $value = $this->getOption(Shapefile::OPTION_DBF_NULLIFY_INVALID_DATES) ? null : $value; + } elseif ($this->getOption(Shapefile::OPTION_DBF_RETURN_DATES_AS_OBJECTS)) { + $DateTime->setTime(0, 0, 0); + $value = $DateTime; } else { $value = $DateTime->format('Y-m-d'); } @@ -581,18 +573,6 @@ private function decodeFieldValue($field, $value) case Shapefile::DBF_TYPE_LOGICAL: $value = ($value === '?') ? null : in_array($value, ['Y', 'y', 'T', 't']); - break; - - case Shapefile::DBF_TYPE_MEMO: - $this->setFilePointer(Shapefile::FILE_DBT, intval($value) * Shapefile::DBT_BLOCK_SIZE); - $value = ''; - do { - if (ftell($this->files['dbt']['handle']) >= $this->files['dbt']['size']) { - throw new ShapefileException(Shapefile::ERR_DBT_EOF_REACHED); - } - $value .= $this->readString(Shapefile::FILE_DBT, Shapefile::DBT_BLOCK_SIZE, true); - } while (ord(substr($value, -1)) != Shapefile::DBT_FIELD_TERMINATOR && ord(substr($value, -2, 1)) != Shapefile::DBT_FIELD_TERMINATOR); - $value = substr($value, 0, -2); break; } } @@ -644,7 +624,7 @@ private function readM() */ private function parseM($value) { - return ($value < -pow(10, 38)) ? false : $value; + return ($value <= Shapefile::SHP_NO_DATA_THRESHOLD) ? false : $value; } @@ -1072,39 +1052,4 @@ private function createPolygon($data) return $Geometry; } - /** - * Checks whether a ring is clockwise or not. - * It uses Gauss's area formula to check for positive or negative orientation. - * - * An optional $exp parameter is used in order to deal with small polygons. - * - * @param array $points Array of points. Each element must have a "x" and "y" member. - * @param integer $exp Optional exponent to deal with small areas. - * - * @return bool - */ - private function isClockwise($points, $exp = 1) - { - $num_points = count($points); - if ($num_points < 2) { - return true; - } - - $num_points--; - $tot = 0; - for ($i = 0; $i < $num_points; ++$i) { - $tot += ($exp * $points[$i]['x'] * $points[$i+1]['y']) - ($exp * $points[$i]['y'] * $points[$i+1]['x']); - } - $tot += ($exp * $points[$num_points]['x'] * $points[0]['y']) - ($exp * $points[$num_points]['y'] * $points[0]['x']); - - if ($tot == 0) { - if ($exp >= pow(10, 9)) { - throw new ShapefileException(Shapefile::ERR_GEOM_POLYGON_AREA_TOO_SMALL); - } - return $this->isClockwise($points, $exp * pow(10, 3)); - } - - return $tot < 0; - } - } diff --git a/src/Shapefile/ShapefileWriter.php b/src/Shapefile/ShapefileWriter.php new file mode 100644 index 0000000..621ed46 --- /dev/null +++ b/src/Shapefile/ShapefileWriter.php @@ -0,0 +1,1063 @@ +initOptions([ + Shapefile::OPTION_CPG_ENABLE_FOR_DEFAULT_CHARSET, + Shapefile::OPTION_DBF_FORCE_ALL_CAPS, + Shapefile::OPTION_DBF_NULL_PADDING_CHAR, + Shapefile::OPTION_DBF_NULLIFY_INVALID_DATES, + Shapefile::OPTION_DELETE_EMPTY_FILES, + Shapefile::OPTION_ENFORCE_GEOMETRY_DATA_STRUCTURE, + Shapefile::OPTION_OVERWRITE_EXISTING_FILES, + Shapefile::OPTION_SUPPRESS_M, + Shapefile::OPTION_SUPPRESS_Z, + ], $options); + + // Open files + $this->filenames = $this->openFiles($files, true); + } + + /** + * Destructor. + * + * Finalizes open files. + * If files were NOT passed as stream resources, empty useless files will be removed. + */ + public function __destruct() + { + // Write SHP, SHX, DBF and DBT headers + $this->writeSHPOrSHXHeader(Shapefile::FILE_SHP); + $this->writeSHPOrSHXHeader(Shapefile::FILE_SHX); + $this->writeDBFHeader(); + $this->writeDBTHeader(); + + // Write PRJ + if ($this->isFileOpen(Shapefile::FILE_PRJ) && $this->getPRJ() !== null) { + $this->writeString(Shapefile::FILE_PRJ, $this->getPRJ()); + } + + // Write CPG + if ($this->isFileOpen(Shapefile::FILE_CPG) && ($this->getCharset() !== Shapefile::DBF_DEFAULT_CHARSET || $this->getOption(Shapefile::OPTION_CPG_ENABLE_FOR_DEFAULT_CHARSET))) { + $this->writeString(Shapefile::FILE_CPG, $this->getCharset()); + } + + // Close files and delete empty ones + $this->closeFiles(); + if ($this->getOption(Shapefile::OPTION_DELETE_EMPTY_FILES)) { + foreach ($this->filenames as $filename) { + if (filesize($filename) === 0) { + unlink($filename); + } + } + } + } + + + public function setShapeType($type) + { + parent::setShapeType($type); + } + + public function setCustomBoundingBox($bounding_box) + { + parent::setCustomBoundingBox($bounding_box); + } + + public function resetCustomBoundingBox() + { + parent::resetCustomBoundingBox(); + } + + public function setPRJ($prj) + { + parent::setPRJ($prj); + } + + public function setCharset($charset) + { + parent::setCharset($charset); + } + + public function addField($name, $type, $size, $decimals, $flag_sanitize_name = true) + { + return parent::addField($name, $type, $size, $decimals, $flag_sanitize_name); + } + + /** + * Adds a char field to the Shapefile definition. + * + * @param string $name Name of the field. Maximum 10 characters. + * Only letters, numbers and underscores are allowed. + * @param integer $size Lenght of the field, between 1 and 254 characters. Defaults to 254. + * @param bool $flag_sanitize_name Optional flag to automatically replace illegal characters + * in the name with underscores. Defaults to true. + * + * @return string + */ + public function addCharField($name, $size = 254, $flag_sanitize_name = true) + { + return $this->addField($name, Shapefile::DBF_TYPE_CHAR, $size, 0, $flag_sanitize_name); + } + + /** + * Adds a date field to the Shapefile definition. + * + * @param string $name Name of the field. Maximum 10 characters. + * Only letters, numbers and underscores are allowed. + * @param bool $flag_sanitize_name Optional flag to automatically replace illegal characters + * in the name with underscores. Defaults to true. + * + * @return string + */ + public function addDateField($name, $flag_sanitize_name = true) + { + return $this->addField($name, Shapefile::DBF_TYPE_DATE, 8, 0, $flag_sanitize_name); + } + + /** + * Adds a logical/boolean field to the Shapefile definition. + * + * @param string $name Name of the field. Maximum 10 characters. + * Only letters, numbers and underscores are allowed. + * @param bool $flag_sanitize_name Optional flag to automatically replace illegal characters + * in the name with underscores. Defaults to true. + * + * @return string + */ + public function addLogicalField($name, $flag_sanitize_name = true) + { + return $this->addField($name, Shapefile::DBF_TYPE_LOGICAL, 1, 0, $flag_sanitize_name); + } + + /** + * Adds a memo field to the Shapefile definition. + * + * @param string $name Name of the field. Maximum 10 characters. + * Only letters, numbers and underscores are allowed. + * @param bool $flag_sanitize_name Optional flag to automatically replace illegal characters + * in the name with underscores. Defaults to true. + * + * @return string + */ + public function addMemoField($name, $flag_sanitize_name = true) + { + return $this->addField($name, Shapefile::DBF_TYPE_MEMO, 10, 0, $flag_sanitize_name); + } + + /** + * Adds numeric to the Shapefile definition. + * + * @param string $name Name of the field. Maximum 10 characters. + * Only letters, numbers and underscores are allowed. + * @param integer $size Lenght of the field, between 1 and 254 characters. Defaults to 10. + * @param integer $decimals Optional number of decimal digits. Defaults to 0. + * @param bool $flag_sanitize_name Optional flag to automatically replace illegal characters + * in the name with underscores. Defaults to true. + * + * @return string + */ + public function addNumericField($name, $size = 10, $decimals = 0, $flag_sanitize_name = true) + { + return $this->addField($name, Shapefile::DBF_TYPE_NUMERIC, $size, $decimals, $flag_sanitize_name); + } + + /** + * Adds floating point to the Shapefile definition. + * + * @param string $name Name of the field. Maximum 10 characters. + * Only letters, numbers and underscores are allowed. + * @param integer $size Lenght of the field, between 1 and 254 characters. Defaults to 20. + * @param integer $decimals Number of decimal digits. Defaults to 10. + * @param bool $flag_sanitize_name Optional flag to automatically replace illegal characters + * in the name with underscores. Defaults to true. + * + * @return string + */ + public function addFloatField($name, $size = 20, $decimals = 10, $flag_sanitize_name = true) + { + return $this->addField($name, Shapefile::DBF_TYPE_FLOAT, $size, $decimals, $flag_sanitize_name); + } + + + /* + * Writes a record to the Shapefile. + * + * @param Geometry $Geometry Geometry to write. + */ + public function writeRecord(Geometry\Geometry $Geometry) + { + // Init headers + if (!$this->flag_init_headers) { + $this->writeNulPadding(Shapefile::FILE_SHP, Shapefile::SHP_HEADER_SIZE); + $this->writeNulPadding(Shapefile::FILE_SHX, Shapefile::SHX_HEADER_SIZE); + $this->writeNulPadding(Shapefile::FILE_DBF, $this->getDBFHeaderSize()); + if (in_array(Shapefile::DBF_TYPE_MEMO, $this->arrayColumn($this->getFields(), 'type'))) { + if (!$this->isFileOpen(Shapefile::FILE_DBT)) { + throw new ShapefileException(Shapefile::ERR_FILE_MISSING, strtoupper(Shapefile::FILE_DBT)); + } + $this->writeNulPadding(Shapefile::FILE_DBT, Shapefile::DBT_BLOCK_SIZE); + ++$this->dbt_next_available_block; + } + $this->flag_init_headers = true; + } + + // Pair with Geometry and increase records count + $this->pairGeometry($Geometry); + ++$this->tot_records; + + // Write data + $this->writeSHPAndSHXData($Geometry); + $this->writeDBFData($Geometry); + } + + + + /////////////////////////////// PRIVATE /////////////////////////////// + /** + * Packs data according to the given format and writes it to a file. + * + * @param string $file_type File type. + * @param string $format Format code. See php pack() documentation. + * @param string $data String value to write. + * @param bool $invert_endianness Set this optional flag to true when reading floating point numbers on a big endian machine. + * + * @return mixed + */ + private function writeData($file_type, $format, $data, $invert_endianness = false) + { + $data = pack($format, $data); + if ($invert_endianness) { + $data = strrev($data); + } + if ($this->fileWrite($file_type, $data) === false) { + throw new ShapefileException(Shapefile::ERR_FILE_WRITING); + } + } + + /** + * Writes an unsigned char from a resource handle. + * + * @param string $file_type File type. + * @param integer $data Value to write. + */ + private function writeChar($file_type, $data) + { + $this->writeData($file_type, 'C', $data); + } + + /** + * Writes an unsigned short, 16 bit, little endian byte order, to a resource handle. + * + * @param string $file_type File type. + * @param integer $data Value to write. + */ + private function writeInt16L($file_type, $data) + { + $this->writeData($file_type, 'v', $data); + } + + /** + * Writes an unsigned long, 32 bit, big endian byte order, to a resource handle. + * + * @param string $file_type File type. + * @param integer $data Value to write. + */ + private function writeInt32B($file_type, $data) + { + $this->writeData($file_type, 'N', $data); + } + + /** + * Writes an unsigned long, 32 bit, little endian byte order, to a resource handle. + * + * @param string $file_type File type. + * @param integer $data Value to write. + */ + private function writeInt32L($file_type, $data) + { + $this->writeData($file_type, 'V', $data); + } + + /** + * Writes a double, 64 bit, little endian byte order, to a resource handle. + * + * @param string $file_type File type. + * @param float $data Value to write. + */ + private function writeDoubleL($file_type, $data) + { + $this->writeData($file_type, 'd', $data, $this->isBigEndianMachine()); + } + + /** + * Writes a string to a resource handle. + * + * @param string $file_type File type. + * @param string $data Value to write. + */ + private function writeString($file_type, $data) + { + $this->writeData($file_type, 'A*', $data); + } + + /** + * Writes a NUL-padding of given length to a resource handle. + * + * @param string $file_type File type. + * @param string $lenght Length of the padding to write. + */ + private function writeNulPadding($file_type, $lenght) + { + $this->writeData($file_type, 'a*', str_repeat("\0", $lenght)); + } + + + /** + * Writes some XY coordinates to the Shapefile. + * + * @param array $coordinates Array with "x" and "y" coordinates. + */ + private function writeXY($coordinates) + { + $this->writeDoubleL(Shapefile::FILE_SHP, $coordinates['x']); + $this->writeDoubleL(Shapefile::FILE_SHP, $coordinates['y']); + } + + /** + * Writes a Z coordinate to the Shapefile. + * + * @param array $coordinates Array with "z" coordinate. + */ + private function writeZ($coordinates) + { + $this->writeDoubleL(Shapefile::FILE_SHP, $this->getOption(Shapefile::OPTION_SUPPRESS_Z) ? 0 : $coordinates['z']); + } + + /** + * Writes an M coordinate to the Shapefile. + * + * @param array $coordinates Array with "m" coordinate. + */ + private function writeM($coordinates) + { + $this->writeDoubleL(Shapefile::FILE_SHP, $this->getOption(Shapefile::OPTION_SUPPRESS_M) ? 0 : $this->parseM($coordinates['m'])); + } + + /** + * Parses an M coordinate according to the ESRI specs: + * «Any floating point number smaller than –10^38 is considered by a shapefile reader to represent a "no data" value» + * + * @return float|bool + */ + private function parseM($value) + { + return ($value === false) ? Shapefile::SHP_NO_DATA_VALUE : $value; + } + + + /** + * Writes an XY bounding box into a file. + * + * @param string $file_type File type. + * @param array $bounding_box Associative array with xmin, xmax, ymin, ymax values. + */ + private function writeXYBoundingBox($file_type, $bounding_box) + { + $this->writeDoubleL($file_type, $bounding_box['xmin']); + $this->writeDoubleL($file_type, $bounding_box['ymin']); + $this->writeDoubleL($file_type, $bounding_box['xmax']); + $this->writeDoubleL($file_type, $bounding_box['ymax']); + } + + /** + * Writes a Z range into a file. + * + * @param string $file_type File type. + * @param array $bounding_box Associative array with zmin and zmax values. + */ + private function writeZRange($file_type, $bounding_box) + { + $this->writeDoubleL($file_type, $this->getOption(Shapefile::OPTION_SUPPRESS_Z) ? 0 : $bounding_box['zmin']); + $this->writeDoubleL($file_type, $this->getOption(Shapefile::OPTION_SUPPRESS_Z) ? 0 : $bounding_box['zmax']); + } + + /** + * Writes an M range into a file. + * + * @param string $file_type File type. + * @param array $bounding_box Associative array with mmin and mmax values. + */ + private function writeMRange($file_type, $bounding_box) + { + $this->writeDoubleL($file_type, $this->getOption(Shapefile::OPTION_SUPPRESS_M) ? 0 : $this->parseM($bounding_box['mmin'])); + $this->writeDoubleL($file_type, $this->getOption(Shapefile::OPTION_SUPPRESS_M) ? 0 : $this->parseM($bounding_box['mmax'])); + } + + + /** + * Writes a Null shape to the Shapefile. + */ + private function writeNull() + { + // Shape type + $this->writeInt32L(Shapefile::FILE_SHP, Shapefile::SHAPE_TYPE_NULL); + } + + + /** + * Writes a Point shape to the Shapefile. + * + * @param array $coordinates Array with "x" and "y" coordinates. + * @param string $shape_type Optional shape type to write in the record. + */ + private function writePoint($coordinates, $shape_type = Shapefile::SHAPE_TYPE_POINT) + { + // Shape type + $this->writeInt32L(Shapefile::FILE_SHP, $shape_type); + // XY Coordinates + $this->writeXY($coordinates); + } + + /** + * Writes a PointM shape to the Shapefile. + * + * @param array $coordinates Array with "m" coordinate. + */ + private function writePointM($coordinates) + { + // XY Point + $this->writePoint($coordinates, Shapefile::SHAPE_TYPE_POINTM); + // M Coordinate + $this->writeM($coordinates); + } + + /** + * Writes a PointZ shape to the Shapefile. + * + * @param array $coordinates Array with "z" coordinate. + */ + private function writePointZ($coordinates) + { + // XY Point + $this->writePoint($coordinates, Shapefile::SHAPE_TYPE_POINTZ); + // Z Coordinate + $this->writeZ($coordinates); + // M Coordinate + $this->writeM($coordinates); + } + + + /** + * Writes a MultiPoint shape to the Shapefile. + * + * @param array $array Array with "numpoints" and "points" elements. + + * @param string $shape_type Optional shape type to write in the record. + * @param array $bounding_box Associative array with xmin, xmax, ymin, ymax values. + */ + private function writeMultiPoint($array, $bounding_box, $shape_type = Shapefile::SHAPE_TYPE_MULTIPOINT) + { + // Shape type + $this->writeInt32L(Shapefile::FILE_SHP, $shape_type); + // XY Bounding Box + $this->writeXYBoundingBox(Shapefile::FILE_SHP, $bounding_box); + // NumPoints + $this->writeInt32L(Shapefile::FILE_SHP, $array['numpoints']); + // Points + foreach ($array['points'] as $coordinates) { + $this->writeXY($coordinates); + } + } + + /** + * Writes a MultiPointM shape to the Shapefile. + * + * @param array $array Array with "numpoints" and "points" elements. + * @param array $bounding_box Associative array with xmin, xmax, ymin, ymax, mmin, mmax values. + */ + private function writeMultiPointM($array, $bounding_box) + { + // XY MultiPoint + $this->writeMultiPoint($array, $bounding_box, Shapefile::SHAPE_TYPE_MULTIPOINTM); + // M Range + $this->writeMRange(Shapefile::FILE_SHP, $bounding_box); + // M Array + foreach ($array['points'] as $coordinates) { + $this->writeM($coordinates); + } + } + + /** + * Writes a MultiPointZ shape to the Shapefile. + * + * @param array $array Array with "numpoints" and "points" elements. + * @param array $bounding_box Associative array with xmin, xmax, ymin, ymax, zmin, zmax, mmin, mmax values. + */ + private function writeMultiPointZ($array, $bounding_box) + { + // XY MultiPoint + $this->writeMultiPoint($array, $bounding_box, Shapefile::SHAPE_TYPE_MULTIPOINTZ); + // Z Range + $this->writeZRange(Shapefile::FILE_SHP, $bounding_box); + // Z Array + foreach ($array['points'] as $coordinates) { + $this->writeZ($coordinates); + } + // M Range + $this->writeMRange(Shapefile::FILE_SHP, $bounding_box); + // M Array + foreach ($array['points'] as $coordinates) { + $this->writeM($coordinates); + } + } + + + /** + * Writes a PolyLine shape to the Shapefile. + * + * @param array $array Array with "numparts" and "parts" elements. + * @param array $bounding_box Associative array with xmin, xmax, ymin, ymax values. + * @param string $shape_type Optional shape type to write in the record. + */ + private function writePolyLine($array, $bounding_box, $shape_type = Shapefile::SHAPE_TYPE_POLYLINE) + { + // Shape type + $this->writeInt32L(Shapefile::FILE_SHP, $shape_type); + // XY Bounding Box + $this->writeXYBoundingBox(Shapefile::FILE_SHP, $bounding_box); + // NumParts + $this->writeInt32L(Shapefile::FILE_SHP, $array['numparts']); + // NumPoints + $this->writeInt32L(Shapefile::FILE_SHP, array_sum($this->arrayColumn($array['parts'], 'numpoints'))); + // Parts + $part_first_index = 0; + foreach ($array['parts'] as $part) { + $this->writeInt32L(Shapefile::FILE_SHP, $part_first_index); + $part_first_index += $part['numpoints']; + } + // Points + foreach ($array['parts'] as $part) { + foreach ($part['points'] as $coordinates) { + $this->writeXY($coordinates); + } + } + } + + /** + * Writes a PolyLineM shape to the Shapefile. + * + * @param array $array Array with "numparts" and "parts" elements. + * @param array $bounding_box Associative array with xmin, xmax, ymin, ymax, mmin, mmax values. + * @param string $shape_type Optional shape type to write in the record. + */ + private function writePolyLineM($array, $bounding_box, $shape_type = Shapefile::SHAPE_TYPE_POLYLINEM) + { + // XY PolyLine + $this->writePolyLine($array, $bounding_box, $shape_type); + // M Range + $this->writeMRange(Shapefile::FILE_SHP, $bounding_box); + // M Array + foreach ($array['parts'] as $part) { + foreach ($part['points'] as $coordinates) { + $this->writeM($coordinates); + } + } + } + + /** + * Writes a PolyLineZ shape to the Shapefile. + * + * @param array $array Array with "numparts" and "parts" elements. + * @param array $bounding_box Associative array with xmin, xmax, ymin, ymax, zmin, zmax, mmin, mmax values. + * @param string $shape_type Optional shape type to write in the record. + */ + private function writePolyLineZ($array, $bounding_box, $shape_type = Shapefile::SHAPE_TYPE_POLYLINEZ) + { + // XY PolyLine + $this->writePolyLine($array, $bounding_box, $shape_type); + // Z Range + $this->writeZRange(Shapefile::FILE_SHP, $bounding_box); + // Z Array + foreach ($array['parts'] as $part) { + foreach ($part['points'] as $coordinates) { + $this->writeZ($coordinates); + } + } + // M Range + $this->writeMRange(Shapefile::FILE_SHP, $bounding_box); + // M Array + foreach ($array['parts'] as $part) { + foreach ($part['points'] as $coordinates) { + $this->writeM($coordinates); + } + } + } + + + /** + * Writes a Polygon shape to the Shapefile. + * + * @param array $array Array with "numparts" and "parts" elements. + * @param array $bounding_box Associative array with xmin, xmax, ymin, ymax values. + */ + private function writePolygon($array, $bounding_box) + { + $this->writePolyLine($this->parsePolygon($array), $bounding_box, Shapefile::SHAPE_TYPE_POLYGON); + } + + /** + * Writes a PolygonM shape to the Shapefile. + * + * @param array $array Array with "numparts" and "parts" elements. + * @param array $bounding_box Associative array with xmin, xmax, ymin, ymax, mmin, mmax values. + */ + private function writePolygonM($array, $bounding_box) + { + $this->writePolyLineM($this->parsePolygon($array), $bounding_box, Shapefile::SHAPE_TYPE_POLYGONM); + } + + /** + * Writes a PolygonZ shape to the Shapefile. + * + * @param array $array Array with "numparts" and "parts" elements. + * @param array $bounding_box Associative array with xmin, xmax, ymin, ymax, zmin, zmax, mmin, mmax values. + */ + private function writePolygonZ($array, $bounding_box) + { + $this->writePolyLineZ($this->parsePolygon($array), $bounding_box, Shapefile::SHAPE_TYPE_POLYGONZ); + } + + /** + * Parses a Polygon array (with multiple "rings" for each "part") into a plain structure of sequential parts + * enforcing outer and innner rings orientation. + * + * @param array $array Array "parts" elements. + * + * @return array + */ + private function parsePolygon($array) + { + $parts = []; + foreach ($array['parts'] as $part) { + foreach ($part['rings'] as $i => $ring) { + // Enforce clockwise outer rings and counterclockwise inner rings + $is_clockwise = $this->isClockwise($ring['points']); + if (($i == 0 && !$is_clockwise) || ($i > 0 && $is_clockwise)) { + $ring['points'] = array_reverse($ring['points']); + } + $parts[] = $ring; + } + } + + return [ + 'numparts' => count($parts), + 'parts' => $parts, + ]; + } + + + /** + * Writes SHP or SHX file header. + * + * @param string $file_type File type. + */ + private function writeSHPOrSHXHeader($file_type) + { + $this->setFilePointer($file_type, 0); + + // File Code + $this->writeInt32B($file_type, Shapefile::SHP_FILE_CODE); + + // Unused bytes + $this->setFileOffset($file_type, 20); + + // File Length + $this->writeInt32B($file_type, $this->getFileSize($file_type) / 2); + + // Version + $this->writeInt32L($file_type, Shapefile::SHP_VERSION); + + // Shape Type + $this->writeInt32L($file_type, $this->getShapeType(Shapefile::FORMAT_INT)); + + //Bounding Box + $bounding_box = $this->getBoundingBox(); + $this->writeXYBoundingBox($file_type, $bounding_box); + $this->writeZRange($file_type, $this->isZ() ? $bounding_box : ['zmin' => 0, 'zmax' => 0]); + $this->writeMRange($file_type, $this->isM() ? $bounding_box : ['mmin' => 0, 'mmax' => 0]); + + $this->resetFilePointer($file_type); + } + + /** + * Writes DBF file header. + */ + private function writeDBFHeader() + { + $this->setFilePointer(Shapefile::FILE_DBF, 0); + + // Version number + $this->writeChar(Shapefile::FILE_DBF, $this->dbt_next_available_block > 0 ? Shapefile::DBF_VERSION_WITH_DBT : Shapefile::DBF_VERSION); + + // Date of last update + $this->writeChar(Shapefile::FILE_DBF, intval(date('Y')) - 1900); + $this->writeChar(Shapefile::FILE_DBF, intval(date('m'))); + $this->writeChar(Shapefile::FILE_DBF, intval(date('d'))); + + // Number of records + $this->writeInt32L(Shapefile::FILE_DBF, $this->tot_records); + + // Header size + $this->writeInt16L(Shapefile::FILE_DBF, $this->getDBFHeaderSize()); + + // Record size + $this->writeInt16L(Shapefile::FILE_DBF, $this->getDBFRecordSize()); + + // Reserved bytes + $this->setFileOffset(Shapefile::FILE_DBF, 20); + + // Field descriptor array + foreach ($this->getFields() as $name => $field) { + // Name + $this->writeString(Shapefile::FILE_DBF, str_pad($name, 10, "\0", STR_PAD_RIGHT)); + $this->setFileOffset(Shapefile::FILE_DBF, 1); + // Type + $this->writeString(Shapefile::FILE_DBF, $field['type']); + $this->setFileOffset(Shapefile::FILE_DBF, 4); + // Size + $this->writeChar(Shapefile::FILE_DBF, $field['size']); + // Decimals + $this->writeChar(Shapefile::FILE_DBF, $field['decimals']); + $this->setFileOffset(Shapefile::FILE_DBF, 14); + } + + // Field terminator + $this->writeChar(Shapefile::FILE_DBF, Shapefile::DBF_FIELD_TERMINATOR); + + // EOF Marker + $this->resetFilePointer(Shapefile::FILE_DBF); + $this->writeChar(Shapefile::FILE_DBF, Shapefile::DBF_EOF_MARKER); + } + + /** + * Writes DBT file header. + */ + private function writeDBTHeader() + { + if ($this->dbt_next_available_block === 0) { + return; + } + + $this->setFilePointer(Shapefile::FILE_DBT, 0); + + // Next available block + $this->writeInt32L(Shapefile::FILE_DBT, $this->dbt_next_available_block); + + // Reserved bytes + $this->setFileOffset(Shapefile::FILE_DBT, 12); + + // Version number + $this->writeChar(Shapefile::FILE_DBT, Shapefile::DBF_VERSION); + + $this->resetFilePointer(Shapefile::FILE_DBT); + } + + /** + * Computes DBF header size. + * 32bytes + (number of fields x 32) + 1 (field terminator character) + */ + private function getDBFHeaderSize() + { + return 33 + (32 * count($this->getFields())); + } + + /** + * Computes DBF record size. + * Sum of all fields sizes + 1 (record deleted flag). + */ + private function getDBFRecordSize() + { + return array_sum($this->arrayColumn($this->getFields(), 'size')) + 1; + } + + + /* + * Writes Geometry data to SHP and SHX files. + * + * @param Geometry $Geometry Geometry to write. + */ + private function writeSHPAndSHXData(Geometry\Geometry $Geometry) + { + // === SHP === + $array = []; + $write_method = 'writeNull'; + if (!$Geometry->isEmpty()) { + $class = get_class($Geometry); + $array = $Geometry->getArray(); + if ($class == 'Shapefile\Geometry\Linestring' || $class == 'Shapefile\Geometry\Polygon') { + $array = [ + 'numparts' => 1, + 'parts' => [$array], + ]; + } + $methods = [ + 'Shapefile\Geometry\Point' => 'writePoint', + 'Shapefile\Geometry\MultiPoint' => 'writeMultiPoint', + 'Shapefile\Geometry\Linestring' => 'writePolyLine', + 'Shapefile\Geometry\MultiLinestring' => 'writePolyLine', + 'Shapefile\Geometry\Polygon' => 'writePolygon', + 'Shapefile\Geometry\MultiPolygon' => 'writePolygon', + ]; + $write_method = $methods[$class]; + if ($Geometry->isZ()) { + $write_method .= 'Z'; + } elseif ($Geometry->isM()) { + $write_method .= 'M'; + } + } + // Save current offset and leave space for record header + $old_shp_offset = $this->getFilePointer(Shapefile::FILE_SHP); + $this->setFileOffset(Shapefile::FILE_SHP, 8); + // Write Geometry + $this->{$write_method}($array, $Geometry->getBoundingBox()); + // Update record header + $shp_content_length = (($this->getFilePointer(Shapefile::FILE_SHP) - $old_shp_offset) / 2) - 4; + $this->setFilePointer(Shapefile::FILE_SHP, $old_shp_offset); + $this->writeInt32B(Shapefile::FILE_SHP, $this->tot_records); + $this->writeInt32B(Shapefile::FILE_SHP, $shp_content_length); + $this->resetFilePointer(Shapefile::FILE_SHP); + + // === SHX === + // Offset (in 16bit words) + $this->writeInt32B(Shapefile::FILE_SHX, $old_shp_offset / 2); + // Content Length (in 16bit words) + $this->writeInt32B(Shapefile::FILE_SHX, $shp_content_length); + } + + /* + * Writes Geometry data to DBF file. + * + * @param Geometry $Geometry Geometry to write. + */ + private function writeDBFData(Geometry\Geometry $Geometry) + { + // === DBF === + // Deleted flag + $this->writeChar(Shapefile::FILE_DBF, $Geometry->isDeleted() ? Shapefile::DBF_DELETED_MARKER : Shapefile::DBF_BLANK); + // Data + $data = $Geometry->getDataArray(); + foreach ($this->getFields() as $name => $field) { + if (!isset($data[$name])) { + if ($this->getOption(Shapefile::OPTION_ENFORCE_GEOMETRY_DATA_STRUCTURE)) { + throw new ShapefileException(Shapefile::ERR_GEOM_MISSING_FIELD, $name); + } + $data[$name] = null; + } + $value = $this->encodeFieldValue($field['type'], $field['size'], $field['decimals'], $data[$name]); + // Memo (DBT) + if ($field['type'] === Shapefile::DBF_TYPE_MEMO && $value !== null) { + $value = $this->writeDBTData($value, $field['size']); + } + // Null + if ($value === null) { + $value = str_repeat(($this->getOption(Shapefile::OPTION_DBF_NULL_PADDING_CHAR) !== null ? $this->getOption(Shapefile::OPTION_DBF_NULL_PADDING_CHAR) : chr(Shapefile::DBF_BLANK)), $field['size']); + } + // Write value to file + $this->writeString(Shapefile::FILE_DBF, $value); + } + } + + /* + * Writes data to DBT file and returns first block number. + * + * @param string $data Data to write + * @param integer $field_size Size of the DBF field. + * + * @return string + */ + private function writeDBTData($data, $field_size) + { + // Ignore empty values + if ($data === '') { + return str_repeat(chr(Shapefile::DBF_BLANK), $field_size); + } + + // Block number to return + $ret = str_pad($this->dbt_next_available_block, $field_size, chr(Shapefile::DBF_BLANK), STR_PAD_LEFT); + + // Corner case: there's not enough space at the end of the last block for 2 field terminators. Add a space and switch to the next block! + if (strlen($data) % Shapefile::DBT_BLOCK_SIZE == Shapefile::DBT_BLOCK_SIZE - 1) { + $data .= chr(Shapefile::DBF_BLANK); + } + // Add TWO field terminators + $data .= str_repeat(chr(Shapefile::DBT_FIELD_TERMINATOR), 2); + // Write data + foreach (str_split($data, Shapefile::DBT_BLOCK_SIZE) as $block) { + $this->writeString(Shapefile::FILE_DBT, str_pad($block, Shapefile::DBT_BLOCK_SIZE, "\0", STR_PAD_RIGHT)); + ++$this->dbt_next_available_block; + } + + return $ret; + } + + /** + * Encodes a value to be written into a DBF field as a raw string. + * + * @param string $type Type of the field. + * @param integer $size Lenght of the field. + * @param integer $decimals Number of decimal digits for numeric type. + * @param string $value Value to encode. + * + * @return string|null + */ + private function encodeFieldValue($type, $size, $decimals, $value) + { + switch ($type) { + case Shapefile::DBF_TYPE_CHAR: + if ($value !== null) { + $value = $this->truncateOrPadString($value, $size); + } + break; + + case Shapefile::DBF_TYPE_DATE: + if (is_a($value, 'DateTime')) { + $value = $value->format('Ymd'); + } elseif ($value !== null) { + // Try YYYY-MM-DD format + $DateTime = \DateTime::createFromFormat('Y-m-d', $value); + $errors = \DateTime::getLastErrors(); + if ($errors['warning_count'] || $errors['error_count']) { + // Try YYYYMMDD format + $DateTime = \DateTime::createFromFormat('Ymd', $value); + $errors = \DateTime::getLastErrors(); + } + if ($errors['warning_count'] || $errors['error_count']) { + $value = $this->getOption(Shapefile::OPTION_DBF_NULLIFY_INVALID_DATES) ? null : $this->truncateOrPadString(preg_replace('/[^0-9]/', '', $value), $size); + } else { + $value = $DateTime->format('Ymd'); + } + } + break; + + case Shapefile::DBF_TYPE_LOGICAL: + if ($value === null) { + $value = '?'; + } elseif ($value === true || in_array(substr(trim($value), 0, 1), ['Y', 'y', 'T', 't'])) { + $value = 'T'; + } else { + $value = 'F'; + } + break; + + case Shapefile::DBF_TYPE_MEMO: + if ($value !== null) { + $value = (string) $value; + } + break; + + case Shapefile::DBF_TYPE_NUMERIC: + case Shapefile::DBF_TYPE_FLOAT: + if ($value !== null) { + $value = trim($value); + $flag_negative = substr($value, 0, 1) === '-'; + $intpart = $this->sanitizeNumber(strpos($value, '.') === false ? $value : strstr($value, '.', true)); + $decpart = $this->sanitizeNumber(substr(strstr($value, '.', false), 1)); + $decpart = strlen($decpart) > $decimals ? substr($decpart, 0, $decimals) : str_pad($decpart, $decimals, '0', STR_PAD_RIGHT); + $value = ($flag_negative ? '-' : '') . $intpart . ($decimals > 0 ? '.' : '') . $decpart; + if (strlen($value) > $size) { + throw new ShapefileException(Shapefile::ERR_INPUT_NUMERIC_VALUE_OVERFLOW, "value:$intpart - size:($size.$decimals)"); + } + $value = str_pad($value, $size, chr(Shapefile::DBF_BLANK), STR_PAD_LEFT); + } + break; + } + + return $value; + } + + /** + * Truncates long input strings and right-pads short ones to maximum/minimun lenght. + * + * @param string $value Value to pad. + * @param integer $size Lenght of the field. + * + * @return string + */ + private function truncateOrPadString($value, $size) + { + return str_pad(substr($value, 0, $size), $size, chr(Shapefile::DBF_BLANK), STR_PAD_RIGHT); + } + + /** + * Removes illegal characters from a numeric string. + * + * @param string $value Value to sanitize. + * + * @return string + */ + private function sanitizeNumber($value) + { + return preg_replace('/[^0-9]/', '', $value); + } + + + /** + * Substitute for PHP 5.5 array_column() function. + * + * @param array $array Multidimensional array. + * @param string $key Key of the column to return. + * + * @return array + */ + private function arrayColumn($array, $key) + { + return array_map(function($element) use ($key){ + return $element[$key]; + }, $array); + } + +}