├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer.json ├── docs ├── CHANGELOG.md ├── SEMVER.md └── TODO.md └── src ├── Cleaner.php ├── ExcludeList.php ├── Internal ├── CfdiXPath.php ├── SchemaLocation.php ├── XmlAttributeMethodsTrait.php ├── XmlConstants.php ├── XmlElementMethodsTrait.php └── XmlNamespaceMethodsTrait.php ├── XmlDocumentCleanerInterface.php ├── XmlDocumentCleaners.php ├── XmlDocumentCleaners ├── CollapseComplemento.php ├── MoveNamespaceDeclarationToRoot.php ├── MoveSchemaLocationsToRoot.php ├── RemoveAddenda.php ├── RemoveIncompleteSchemaLocations.php ├── RemoveNonSatNamespacesNodes.php ├── RemoveNonSatSchemaLocations.php ├── RemoveUnusedNamespaces.php ├── RenameElementAddPrefix.php └── SetKnownSchemaLocations.php ├── XmlStringCleanerInterface.php ├── XmlStringCleaners.php └── XmlStringCleaners ├── AppendXmlDeclaration.php ├── RemoveDuplicatedCfdi3Namespace.php ├── RemoveNonXmlStrings.php ├── SplitXmlDeclarationFromDocument.php └── XmlNsSchemaLocation.php /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Código de Conducta Convenido para Contribuyentes 2 | 3 | ## Nuestro compromiso 4 | 5 | Nosotros, como miembros, contribuyentes y administradores nos comprometemos a hacer de la participación en nuestra comunidad una experiencia libre de acoso para todo el mundo, independientemente de la edad, dimensión corporal, minusvalía visible o invisible, etnicidad, características sexuales, identidad y expresión de género, nivel de experiencia, educación, nivel socioeconómico, nacionalidad, apariencia personal, raza, religión, o identidad u orientación sexual. 6 | 7 | Nos comprometemos a actuar e interactuar de maneras que contribuyan a una comunidad abierta, acogedora, diversa, inclusiva y sana. 8 | 9 | ## Nuestros estándares 10 | 11 | Ejemplos de comportamiento que contribuyen a crear un ambiente positivo para nuestra comunidad: 12 | 13 | * Demostrar empatía y amabilidad ante otras personas 14 | * Respeto a diferentes opiniones, puntos de vista y experiencias 15 | * Dar y aceptar adecuadamente retroalimentación constructiva 16 | * Aceptar la responsabilidad y disculparse ante quienes se vean afectados por nuestros errores, aprendiendo de la experiencia 17 | * Centrarse en lo que sea mejor no solo para nosotros como individuos, sino para la comunidad en general 18 | 19 | Ejemplos de comportamiento inaceptable: 20 | 21 | * El uso de lenguaje o imágenes sexualizadas, y aproximaciones o atenciones sexuales de cualquier tipo 22 | * Comentarios despectivos (_trolling_), insultantes o derogatorios, y ataques personales o políticos 23 | * El acoso en público o privado 24 | * Publicar información privada de otras personas, tales como direcciones físicas o de correo electrónico, sin su permiso explícito 25 | * Otras conductas que puedan ser razonablemente consideradas como inapropiadas en un entorno profesional 26 | 27 | ## Aplicación de las responsabilidades 28 | 29 | Los administradores de la comunidad son responsables de aclarar y hacer cumplir nuestros estándares de comportamiento aceptable y tomarán acciones apropiadas y correctivas de forma justa en respuesta a cualquier comportamiento que consideren inapropiado, amenazante, ofensivo o dañino. 30 | 31 | Los administradores de la comunidad tendrán el derecho y la responsabilidad de eliminar, editar o rechazar comentarios, _commits_, código, ediciones de páginas de wiki, _issues_ y otras contribuciones que no se alineen con este Código de Conducta, y comunicarán las razones para sus decisiones de moderación cuando sea apropiado. 32 | 33 | ## Alcance 34 | 35 | Este código de conducta aplica tanto a espacios del proyecto como a espacios públicos donde un individuo esté en representación del proyecto o comunidad. Ejemplos de esto incluyen el uso de la cuenta oficial de correo electrónico, publicaciones a través de las redes sociales oficiales, o presentaciones con personas designadas en eventos en línea o no. 36 | 37 | ## Aplicación 38 | 39 | Instancias de comportamiento abusivo, acosador o inaceptable de otro modo podrán ser reportadas a los administradores de la comunidad responsables del cumplimiento a través de [coc@phpcfdi.com](mailto:coc@phpcfdi.com). Todas las quejas serán evaluadas e investigadas de una manera puntual y justa. 40 | 41 | Todos los administradores de la comunidad están obligados a respetar la privacidad y la seguridad de quienes reporten incidentes. 42 | 43 | ## Guías de Aplicación 44 | 45 | Los administradores de la comunidad seguirán estas Guías de Impacto en la Comunidad para determinar las consecuencias de cualquier acción que juzguen como un incumplimiento de este Código de Conducta: 46 | 47 | ### 1. Corrección 48 | 49 | **Impacto en la Comunidad**: El uso de lenguaje inapropiado u otro comportamiento considerado no profesional o no acogedor en la comunidad. 50 | 51 | **Consecuencia**: Un aviso escrito y privado por parte de los administradores de la comunidad, proporcionando claridad alrededor de la naturaleza de este incumplimiento y una explicación de por qué el comportamiento es inaceptable. Una disculpa pública podría ser solicitada. 52 | 53 | ### 2. Aviso 54 | 55 | **Impacto en la Comunidad**: Un incumplimiento causado por un único incidente o por una cadena de acciones. 56 | 57 | **Consecuencia**: Un aviso con consecuencias por comportamiento prolongado. No se interactúa con las personas involucradas, incluyendo interacción no solicitada con quienes se encuentran aplicando el Código de Conducta, por un periodo especificado de tiempo. Esto incluye evitar las interacciones en espacios de la comunidad, así como a través de canales externos como las redes sociales. Incumplir estos términos puede conducir a una expulsión temporal o permanente. 58 | 59 | ### 3. Expulsión temporal 60 | 61 | **Impacto en la Comunidad**: Una serie de incumplimientos de los estándares de la comunidad, incluyendo comportamiento inapropiado continuo. 62 | 63 | **Consecuencia**: Una expulsión temporal de cualquier forma de interacción o comunicación pública con la comunidad durante un intervalo de tiempo especificado. No se permite interactuar de manera pública o privada con las personas involucradas, incluyendo interacciones no solicitadas con quienes se encuentran aplicando el Código de Conducta, durante este periodo. Incumplir estos términos puede conducir a una expulsión permanente. 64 | 65 | ### 4. Expulsión permanente 66 | 67 | **Impacto en la Comunidad**: Demostrar un patrón sistemático de incumplimientos de los estándares de la comunidad, incluyendo conductas inapropiadas prolongadas en el tiempo, acoso de individuos, o agresiones o menosprecio a grupos de individuos. 68 | 69 | **Consecuencia**: Una expulsión permanente de cualquier tipo de interacción pública con la comunidad del proyecto. 70 | 71 | ## Atribución 72 | 73 | Este Código de Conducta es una adaptación del [Contributor Covenant](https://www.contributor-covenant.org), versión 2.0, disponible en . 74 | 75 | Las Guías de Impacto en la Comunidad están inspiradas en la [escalera de aplicación del código de conducta de Mozilla](https://github.com/mozilla/diversity). 76 | 77 | Para respuestas a las preguntas frecuentes de este código de conducta, consulta las FAQ en . Hay traducciones disponibles en . 78 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribuciones 2 | 3 | Las contribuciones son bienvenidas. Aceptamos *Pull Requests* en [el repositorio GitHub][project]. 4 | 5 | Este proyecto se apega al siguiente [Código de Conducta][coc]. 6 | Al participar en este proyecto y en su comunidad, deberás seguir este código. 7 | 8 | ## Miembros del equipo 9 | 10 | * [phpCfdi][] - Organización que mantiene el proyecto. 11 | * [Contribuidores][contributors]. 12 | 13 | ## Canales de comunicación 14 | 15 | Puedes encontrar ayuda y comentar asuntos relacionados con este proyecto en estos lugares: 16 | 17 | * Comunidad Discord: 18 | * GitHub Issues: 19 | 20 | ## Reportar Bugs 21 | 22 | Publica los *Bugs* en la sección [GitHub Issues][issues] del proyecto. 23 | 24 | Sigue las recomendaciones generales de [phpCfdi][] para reportar problemas 25 | . 26 | 27 | Cuando se reporte un *Bug*, por favor incluye la mayor información posible para reproducir el problema, preferentemente 28 | con ejemplos de código o cualquier otra información técnica que nos pueda ayudar a identificar el caso. 29 | 30 | **Recuerda no incluir contraseñas, información personal o confidencial.** 31 | 32 | ## Corrección de Bugs 33 | 34 | Apreciamos mucho los *Pull Request* para corregir Bugs. 35 | 36 | Si encuentras un reporte de Bug y te gustaría solucionarlo siéntete libre de hacerlo. 37 | Sigue las directrices de "Agregar nuevas funcionalidades" a continuación. 38 | 39 | ## Agregar nuevas funcionalidades 40 | 41 | Si tienes una idea para una nueva funcionalidad revisa primero que existan discusiones o *Pull Requests* 42 | en donde ya se esté trabajando en la funcionalidad. 43 | 44 | Antes de trabajar en la nueva característica, utiliza los "Canales de comunicación" mencionados 45 | anteriormente para platicar acerca de tu idea. Si dialogas tus ideas con la comunidad y los 46 | mantenedores del proyecto, podrás ahorrar mucho esfuerzo de desarrollo y prevenir que tu 47 | *Pull Request* sea rechazado. No nos gusta rechazar contribuciones, pero algunas características 48 | o la forma de desarrollarlas puede que no estén alineadas con el proyecto. 49 | 50 | Considera las siguientes directrices: 51 | 52 | * Usa una rama única que se desprenda de la rama principal. 53 | No mezcles dos diferentes funcionalidades en una misma rama o *Pull Request*. 54 | * Describe claramente y en detalle los cambios que hiciste. 55 | * **Escribe pruebas** para la funcionalidad que deseas agregar. 56 | * **Asegúrate que las pruebas pasan** antes de enviar tu contribución. 57 | Usamos integración contínua donde se hace esta verificación, pero es mucho mejor si lo pruebas localmente. 58 | * Intenta enviar una historia coherente, entenderemos cómo cambia el código si los *commits* tienen significado. 59 | * La documentación es parte del proyecto. 60 | Realiza los cambios en los archivos de ayuda para que reflejen los cambios en el código. 61 | 62 | ## Proceso de construcción 63 | 64 | ```shell 65 | # Actualiza tus dependencias 66 | composer update 67 | phive update 68 | 69 | # Verificación de estilo de código 70 | composer dev:check-style 71 | 72 | # Corrección de estilo de código 73 | composer dev:fix-style 74 | 75 | # Ejecución de pruebas 76 | composer dev:test 77 | 78 | # Ejecución todo en uno: corregir estilo, verificar estilo y correr pruebas 79 | composer dev:build 80 | ``` 81 | 82 | ## Ejecutar GitHub Actions localmente 83 | 84 | Puedes utilizar la herramienta [`act`](https://github.com/nektos/act) para ejecutar las GitHub Actions localmente. 85 | Según [`actions/setup-php-action`](https://github.com/marketplace/actions/setup-php-action#local-testing-setup) 86 | puedes ejecutar el siguiente comando para revisar los flujos de trabajo localmente: 87 | 88 | ```shell 89 | act -P ubuntu-latest=shivammathur/node:latest -W .github/workflows/build.yml 90 | ``` 91 | 92 | [phpCfdi]: https://github.com/phpcfdi/ 93 | [project]: https://github.com/phpcfdi/cfdi-cleaner 94 | [contributors]: https://github.com/phpcfdi/cfdi-cleaner/graphs/contributors 95 | [coc]: https://github.com/phpcfdi/cfdi-cleaner/blob/main/CODE_OF_CONDUCT.md 96 | [issues]: https://github.com/phpcfdi/cfdi-cleaner/issues 97 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021 - 2024 PhpCfdi https://www.phpcfdi.com/ 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # phpcfdi/cfdi-cleaner 2 | 3 | [![Source Code][badge-source]][source] 4 | [![Packagist PHP Version Support][badge-php-version]][php-version] 5 | [![Discord][badge-discord]][discord] 6 | [![Latest Version][badge-release]][release] 7 | [![Software License][badge-license]][license] 8 | [![Build Status][badge-build]][build] 9 | [![Reliability][badge-reliability]][reliability] 10 | [![Maintainability][badge-maintainability]][maintainability] 11 | [![Code Coverage][badge-coverage]][coverage] 12 | [![Violations][badge-violations]][violations] 13 | [![Total Downloads][badge-downloads]][downloads] 14 | 15 | > Herramienta para limpiar Comprobantes Fiscales Digitales por Internet mexicanos. 16 | 17 | :us: The documentation of this project is in spanish as this is the natural language for the intended audience. 18 | 19 | ## Acerca de phpcfdi/cfdi-cleaner 20 | 21 | Los archivos XML de Comprobantes Fiscales Digitales por Internet (CFDI) suelen contener errores. 22 | Esta librería se encarga de reparar los errores (reparables) conocidos/comunes para poder trabajar con ellos. 23 | 24 | Todas las operaciones que realiza esta librería con sobre partes del CFDI que no influyen en la generación 25 | de la cadena de origen ni del sello. 26 | 27 | ## Instalación 28 | 29 | Usa [composer](https://getcomposer.org/) 30 | 31 | ```shell 32 | composer require phpcfdi/cfdi-cleaner 33 | ``` 34 | 35 | ## Referencia de uso 36 | 37 | La clase de trabajo es `\PhpCfdi\CfdiCleaner\Cleaner` y ofrece los siguientes métodos de limpieza: 38 | 39 | ### `staticClean(string $xml): string` 40 | 41 | Realiza la limpieza de texto y del documento xml a partir de una cadena de caracteres 42 | y entrega la representación limpia también en texto. 43 | 44 | Este método es estático, por lo que no se necesita crear una instancia del objeto `Cleaner`. 45 | 46 | ```php 47 | cleanStringToString($xml); 66 | ``` 67 | 68 | ### `cleanStringToDocument(string $xml): DOMDocument` 69 | 70 | Realiza la limpieza de texto y del documento xml a partir de una cadena de caracteres 71 | y entrega el documento XML limpio. 72 | 73 | Este método es útil si se necesita utilizar inmediatamente el objeto documento XML. 74 | 75 | ```php 76 | cleanStringToDocument($xml); 82 | echo $document->saveXML(); 83 | ``` 84 | 85 | ## Acciones de limpieza 86 | 87 | Hay dos tipos de limpiezas que se pueden hacer, una sobre el texto XML antes de que se intente cargar como objetos DOM, 88 | y la otra una vez que se pudo cargar el contenido como objetos DOM. 89 | 90 | ### Limpiezas sobre el texto XML 91 | 92 | Estos limpiadores deben ejecutarse antes de intentar leer el contenido XML y están hechos para prevenir que el 93 | objeto documento XML no se pueda crear. 94 | 95 | #### `RemoveNonXmlStrings` 96 | 97 | Elimina todo contenido antes del primer caracter `<` y posterior al último `>`. 98 | 99 | #### `SplitXmlDeclarationFromDocument` 100 | 101 | Separa por un `LF` (`"\n"`) la declaración XML `` del cuerpo XML. 102 | 103 | #### `AppendXmlDeclaration` 104 | 105 | Agrega `` al inicio del archivo si no existe, es muy útil porque 106 | las herramientas de detección de `MIME` no reconocen un archivo XML si no trae la cabecera. 107 | 108 | #### `XmlNsSchemaLocation` 109 | 110 | Elimina un error frecuentemente encontrado en los CFDI emitidos por el SAT donde dice `xmlns:schemaLocation` 111 | en lugar de `xsi:schemaLocation`. En caso de que existan ambos, el único que se mantiene es `xsi:schemaLocation`. 112 | 113 | ### Limpiezas sobre el documento XML (`DOMDocument`) 114 | 115 | Estas limpiezas se realizan sobre el documento XML. 116 | 117 | #### `RemoveAddenda` 118 | 119 | Remueve cualquier nodo de tipo `Addenda` en el espacio de nombres `http://www.sat.gob.mx/cfd/3`. 120 | 121 | #### `RemoveIncompleteSchemaLocations` 122 | 123 | Actúa sobre cada uno de los `xsi:schemaLocations`. 124 | 125 | Lee el contenido e intenta interpretar el espacio de nombres y la ubicación del esquema de validación. 126 | Para considerar que es un esquema de validación verifica que termine con `.xsd` (insensible a mayúsculas o minusculas). 127 | Si encuentra un espacio de nombres sin esquema lo omite. 128 | Si encuentra un esquema sin espacio de nombres lo omite. 129 | 130 | #### `RemoveNonSatNamespacesNodes` 131 | 132 | Verifica todas las definiciones de espacios de nombres y si no pertenece a un espacio de nombres con la URI 133 | `http://www.sat.gob.mx/**` entonces elimina los nodos y atributos relacionados. 134 | 135 | #### `RemoveNonSatSchemaLocations` 136 | 137 | Actúa sobre cada uno de los `xsi:schemaLocations`. 138 | 139 | Verifica las definiciones de espacios de nombres y elimina todos los pares donde el espacio de nombres que no 140 | correspondan a la URI `http://www.sat.gob.mx/**`. 141 | 142 | #### `RemoveUnusedNamespaces` 143 | 144 | Remueve todas las declaraciones de espacios de nombres (junto con su prefijo) que no estén en uso. 145 | 146 | #### `RenameElementAddPrefix` 147 | 148 | Agrega el prefijo al nodo que no lo tiene por estar utilizando la definición simple `xmlns`. 149 | Además, elimina los namespace superfluos y las definiciones `xmlns` redundantes. 150 | 151 | Ejemplo de CFDI sucio: 152 | 153 | ```xml 154 | 155 | 156 | 157 | 158 | ``` 159 | 160 | Ejemplo de CFDI limpio: 161 | 162 | ```xml 163 | 164 | 165 | 166 | 167 | ``` 168 | 169 | #### `MoveNamespaceDeclarationToRoot` 170 | 171 | Mueve todas las declaraciones de espacios de nombres al nodo raíz. 172 | 173 | Por lo regular el SAT pide en la documentación técnica que los espacios de nombres se definan en el nodo raíz, 174 | sin embargo, es frecuente que se definan en el nodo que los implementa. 175 | 176 | Hay casos extremos de CFDI que siguen las reglas de XML, pero que no siguen las reglas de CFDI y generan prefijos 177 | que se superponen. En este caso, se moverán solamente los espacios de nombres que no se superponen, por ejemplo: 178 | 179 | ```xml 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | ``` 188 | 189 | Genera el siguiente resultado: 190 | 191 | ```xml 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | ``` 200 | 201 | Ante un caso como el anterior, no se están siguiendo las reglas establecidas en el Anexo 20 y en el complemento. 202 | Es mejor que siempre considere ese caso como un CFDI inválido, aun cuando se haya firmado, y solicite la 203 | sustitución por un CFDI que sí contenga los prefijos de los espacios de nombres correctos. 204 | 205 | #### `MoveSchemaLocationsToRoot` 206 | 207 | Mueve todas las declaraciones de ubicaciones de archivos de esquema al nodo principal. 208 | 209 | Por lo regular el SAT pide en la documentación técnica que las ubicaciones de archivos de esquema se definan en 210 | el nodo principal, sin embargo, es frecuente que se definan en el nodo que los implementa. 211 | 212 | #### `SetKnownSchemaLocations` 213 | 214 | Verifica que las ubicaciones de los esquemas de espacios de nombres conocidos sean exactamente las direcciones conocidas, 215 | en caso de no serlo las modifican. 216 | 217 | Anteriormente, el SAT permitía que las ubicaciones de los esquemas de espacios de nombres estuvieran escritos sin 218 | sensibilidad a mayúsculas o minúsculas, incluso tenía varias ubicaciones para obtener estos archivos. Sin embargo, 219 | recientemente ha eliminado la tolerancia a estas ubicaciones y solo permite las definiciones oficiales. 220 | 221 | Este limpiador tiene la información de espacio de nombres, versión a la que aplica y ubicación conocida con base en 222 | el proyecto [phpcfdi/sat-ns-registry](https://github.com/phpcfdi/sat-ns-registry). 223 | 224 | En caso de que no se encuentre la ruta conocida para un espacio de nombres entonces no aplicará ninguna corrección 225 | y dejará el valor tal como estaba. 226 | 227 | #### `CollapseComplemento` 228 | 229 | Este limpiador se crea para solventar la inconsistencia en la documentación del SAT. 230 | 231 | Por un lado, en el Anexo 20 de CFDI 3.3, el SAT exige que exista uno y solamente un nodo `cfdi:Complemento`. 232 | Sin embargo, en el archivo de validación XSD permite que existan más de uno. 233 | 234 | Con esta limpieza, se deja un solo `cfdi:Complemento` con todos los complementos en él. 235 | 236 | ### Exclusión de limpiadores 237 | 238 | Para no tener que modificar la creación del objeto limpiador y permitir la exclusión de limpiadores específicos, 239 | y de esta forma ser compatibles con nuevas actualizaciones de la librería, se puede crear el limpiador estándar 240 | y luego aplicar exclusiones. 241 | 242 | El siguiente ejemplo muestra cómo excluir los limpiadores que afectan a una *Addenda*. 243 | 244 | ```php 245 | exclude($exclude); 265 | 266 | $contents = $cleaner->cleanStringToString($contents); 267 | ``` 268 | 269 | ## Soporte 270 | 271 | Puedes obtener soporte abriendo un ticket en Github. 272 | 273 | Adicionalmente, esta librería pertenece a la comunidad [PhpCfdi](https://www.phpcfdi.com/), así que puedes usar los 274 | mismos canales de comunicación para obtener ayuda de algún miembro de la comunidad. 275 | 276 | ## Compatibilidad 277 | 278 | Esta librería se mantendrá compatible con al menos la versión con 279 | [soporte activo de PHP](https://www.php.net/supported-versions.php) más reciente. 280 | 281 | También utilizamos [Versionado Semántico 2.0.0](docs/SEMVER.md) por lo que puedes usar esta librería 282 | sin temor a romper tu aplicación. 283 | 284 | ## Contribuciones 285 | 286 | Las contribuciones con bienvenidas. Por favor lee [CONTRIBUTING][] para más detalles 287 | y recuerda revisar el archivo de tareas pendientes [TODO][] y el archivo [CHANGELOG][]. 288 | 289 | ## Copyright and License 290 | 291 | The `phpcfdi/cfdi-cleaner` library is copyright © [PhpCfdi](https://www.phpcfdi.com/) 292 | and licensed for use under the MIT License (MIT). Please see [LICENSE][] for more information. 293 | 294 | [contributing]: https://github.com/phpcfdi/cfdi-cleaner/blob/main/CONTRIBUTING.md 295 | [changelog]: https://github.com/phpcfdi/cfdi-cleaner/blob/main/docs/CHANGELOG.md 296 | [todo]: https://github.com/phpcfdi/cfdi-cleaner/blob/main/docs/TODO.md 297 | 298 | [source]: https://github.com/phpcfdi/cfdi-cleaner 299 | [php-version]: https://packagist.org/packages/phpcfdi/cfdi-cleaner 300 | [discord]: https://discord.gg/aFGYXvX 301 | [release]: https://github.com/phpcfdi/cfdi-cleaner/releases 302 | [license]: https://github.com/phpcfdi/cfdi-cleaner/blob/main/LICENSE 303 | [build]: https://github.com/phpcfdi/cfdi-cleaner/actions/workflows/build.yml?query=branch:main 304 | [reliability]:https://sonarcloud.io/component_measures?id=phpcfdi_cfdi-cleaner&metric=Reliability 305 | [maintainability]: https://sonarcloud.io/component_measures?id=phpcfdi_cfdi-cleaner&metric=Maintainability 306 | [coverage]: https://sonarcloud.io/component_measures?id=phpcfdi_cfdi-cleaner&metric=Coverage 307 | [violations]: https://sonarcloud.io/project/issues?id=phpcfdi_cfdi-cleaner&resolved=false 308 | [downloads]: https://packagist.org/packages/phpcfdi/cfdi-cleaner 309 | 310 | [badge-source]: https://img.shields.io/badge/source-phpcfdi/cfdi--cleaner-blue?logo=github 311 | [badge-discord]: https://img.shields.io/discord/459860554090283019?logo=discord 312 | [badge-php-version]: https://img.shields.io/packagist/php-v/phpcfdi/cfdi-cleaner?logo=php 313 | [badge-release]: https://img.shields.io/github/release/phpcfdi/cfdi-cleaner?logo=git 314 | [badge-license]: https://img.shields.io/github/license/phpcfdi/cfdi-cleaner?logo=open-source-initiative 315 | [badge-build]: https://img.shields.io/github/actions/workflow/status/phpcfdi/cfdi-cleaner/build.yml?branch=main&logo=github-actions 316 | [badge-reliability]: https://sonarcloud.io/api/project_badges/measure?project=phpcfdi_cfdi-cleaner&metric=reliability_rating 317 | [badge-maintainability]: https://sonarcloud.io/api/project_badges/measure?project=phpcfdi_cfdi-cleaner&metric=sqale_rating 318 | [badge-coverage]: https://img.shields.io/sonar/coverage/phpcfdi_cfdi-cleaner/main?logo=sonarcloud&server=https%3A%2F%2Fsonarcloud.io 319 | [badge-violations]: https://img.shields.io/sonar/violations/phpcfdi_cfdi-cleaner/main?format=long&logo=sonarcloud&server=https%3A%2F%2Fsonarcloud.io 320 | [badge-downloads]: https://img.shields.io/packagist/dt/phpcfdi/cfdi-cleaner?logo=packagist 321 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "phpcfdi/cfdi-cleaner", 3 | "description": "Clean up Mexican CFDI", 4 | "license": "MIT", 5 | "keywords": [ 6 | "cfdi", 7 | "sat", 8 | "mexico" 9 | ], 10 | "authors": [ 11 | { 12 | "name": "Carlos C Soto", 13 | "email": "eclipxe13@gmail.com" 14 | } 15 | ], 16 | "homepage": "https://github.com/phpcfdi/cfdi-cleaner", 17 | "require": { 18 | "php": ">=7.3", 19 | "ext-dom": "*", 20 | "ext-libxml": "*", 21 | "symfony/polyfill-php80": "^1.22" 22 | }, 23 | "require-dev": { 24 | "ext-json": "*", 25 | "phpunit/phpunit": "^9.3" 26 | }, 27 | "autoload": { 28 | "psr-4": { 29 | "PhpCfdi\\CfdiCleaner\\": "src/" 30 | } 31 | }, 32 | "autoload-dev": { 33 | "psr-4": { 34 | "PhpCfdi\\CfdiCleaner\\Tests\\": "tests/" 35 | } 36 | }, 37 | "scripts": { 38 | "dev:build": [ 39 | "@dev:fix-style", 40 | "@dev:test" 41 | ], 42 | "dev:check-style": [ 43 | "@php tools/composer-normalize normalize --dry-run", 44 | "@php tools/php-cs-fixer fix --dry-run --verbose", 45 | "@php tools/phpcs --colors -sp" 46 | ], 47 | "dev:coverage": [ 48 | "@php -dzend_extension=xdebug.so -dxdebug.mode=coverage vendor/bin/phpunit --coverage-html build/coverage/html/" 49 | ], 50 | "dev:fix-style": [ 51 | "@php tools/composer-normalize normalize", 52 | "@php tools/php-cs-fixer fix --verbose", 53 | "@php tools/phpcbf --colors -sp" 54 | ], 55 | "dev:test": [ 56 | "@dev:check-style", 57 | "@php vendor/bin/phpunit --testdox --verbose --stop-on-failure", 58 | "@php tools/phpstan analyse --verbose" 59 | ] 60 | }, 61 | "scripts-descriptions": { 62 | "dev:build": "DEV: run dev:fix-style and dev:tests, run before pull request", 63 | "dev:check-style": "DEV: search for code style errors using composer-normalize, php-cs-fixer and phpcs", 64 | "dev:coverage": "DEV: run phpunit with xdebug and storage coverage in build/coverage/html/", 65 | "dev:fix-style": "DEV: fix code style errors using composer-normalize, php-cs-fixer and phpcbf", 66 | "dev:test": "DEV: run dev:check-style, phpunit and phpstan" 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /docs/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## SemVer 2.0 4 | 5 | Utilizamos [Versionado Semántico 2.0.0](SEMVER.md). 6 | 7 | ## Cambios en la rama principal sin liberación de nueva versión 8 | 9 | Los cambios no liberados se integran a la rama principal, pero no requieren de la liberación de una nueva versión. 10 | 11 | ## Versión 1.3.4 12 | 13 | Se hacen las siguientes correcciones: 14 | 15 | - Se corrige la ubicación del XSD del complemento "Enajenaciones de acciones" para Retenciones e información de pagos. 16 | - Se corrige la el espacio de nombres del complemento "Pagos a extranjeros" para Retenciones e información de pagos. 17 | - El limpiador de *Addenda* incluye también los CFDI de Retenciones e información de pagos. 18 | 19 | Se hacen los siguientes cambios al entorno de desarrollo: 20 | 21 | - En el flujo de trabajo `build` en el trabajo `tests` se usa la variable `php-version` en singular. 22 | - En el flujo de trabajo `coverage` en el trabajo `test-coverage` se usa mejora el título. 23 | - Se actualizan las herramientas de desarrollo. 24 | 25 | ## Versión 1.3.3 26 | 27 | - Se agrega *Complemento de Carta Porte 3.1* a la lista de espacio de nombres conocidos. 28 | 29 | Se hacen los siguientes cambios al entorno de desarrollo: 30 | 31 | - Se agrega a las herramientas de desarrollo `composer-normalize`: 32 | - Se agrega a los scripts de desarrollo de `composer` en `dev:check-style` y `dev:fix-style`. 33 | - Se agrega al flujo de trabajo de integración contínua. 34 | - Se normaliza el archivo `composer.json`. 35 | - Se aplicó en los flujos de trabajo: 36 | - Se actualizan las acciones de GitHub a la versión 4. 37 | - Se permite la ejecución de los flujos de trabajo manualmente. 38 | - Se excluye `test/_files` de la detección de lenguajes de GitHub. 39 | - Se actualizan las herramientas de desarrollo. 40 | 41 | ## Versión 1.3.2 42 | 43 | - Se agrega *Comercio Exterior 2.0* a la lista de espacio de nombres conocidos. 44 | - Se actualiza el año de licencia. 45 | - Se corrige la liga al proyecto en el archivo `CONTRIBUTING.md`. 46 | - Se corrige el correo de comunicación en `CODE_OF_CONDUCT.md`. 47 | - Se aplicó en los flujos de trabajo: 48 | - Se incluye PHP 8.3 a la matriz de pruebas. 49 | - Ejecutar todo en PHP 8.3. 50 | - Se actualizan las herramientas de desarrollo. 51 | 52 | ## Versión 1.3.1 53 | 54 | - Se agrega *Carta Porte 3.0* a la lista de espacio de nombres conocidos. 55 | 56 | ### Mantenimiento 2023-10-22 57 | 58 | - Se corrige la configuración de *PHP-CS-Fixer*. 59 | - Se corrigen las exclusiones de archivos para *SonarCloud*. 60 | - Se actualizan las herramientas de desarrollo. 61 | 62 | ### Mantenimiento 2023-02-07 63 | 64 | - Se refactoriza una prueba porque en PHPUnit 9.6.3 se deprecó el método `expectDeprecation()`. 65 | - Se actualiza el año de la licencia. Feliz 2023. 66 | - Se actualizan las herramientas de desarrollo. 67 | 68 | ## Versión 1.3.0 69 | 70 | Se agrega la opción de excluir limpiadores específicos por nombre de clase. 71 | En futuras versiones se implementará una mejor manera de manejar estas exclusiones. 72 | La implementación actual no genera cambios que rompan la compatibilidad y requieran una versión mayor. 73 | 74 | ### Cambios de mantenimiento 75 | 76 | - Se aplicó en los flujos de trabajo: 77 | - Incluir PHP 8.2 a la matriz de pruebas. 78 | - Ejecutar todo en PHP 8.2 excepto el trabajo `php-cs-fixer`. 79 | - Sustituir la instrucción `::set-output` con el uso del archivo `$GITHUB_OUTPUT`. 80 | - Se removió la restricción de versión fija de PHPStan. 81 | - Se corrigió la insignia `badge-build`. 82 | - Se actualizaron los archivos de estilo de código a las reglas utilizadas en los últimos proyectos. 83 | 84 | ## Versión 1.2.4 85 | 86 | Se corrigen los limpiadores `RemoveAddenda` y `CollapseComplemento` porque no estaban actuando sobre CFDI 4.0. 87 | Gracias `@luffynando`. 88 | 89 | El problema de fondo es que la clase `Cfdi3XPath` solo actuaba sobre el XML namespace `http://www.sat.gob.mx/cfd/3` 90 | y nunca sobre `http://www.sat.gob.mx/cfd/4`. En la corrección se renombra la clase interna `Cfdi3XPath` a `CfdiXPath` 91 | y esta clase actúa sobre el XML namespace del nodo principal siempre que sea `http://www.sat.gob.mx/cfd/3` 92 | y `http://www.sat.gob.mx/cfd/4`. 93 | 94 | Se refactoriza internamente la clase `CfdiXPath` y ahora incluye un método `querySchemaLocations`. 95 | 96 | Se actualizan las librerías de desarrollo y el estilo de código. Siendo lo más importante la actualización de 97 | PHPStan 1.7.15 que lleva a múltiples definiciones de tipos. 98 | 99 | Se actualizan los flujos de trabajo de GitHub para usar PHP 8.1 y las acciones de GitHub en versión 3. 100 | 101 | ## Versión 1.2.3 102 | 103 | La limpieza de CFDI grandes tardaba mucho tiempo en el limpiador `RemoveUnusedNamespaces`. 104 | Se optimizó para que el resultado de la llamada al método privado `isPrefixedNamespaceOnUse` (*puro*) 105 | fuera almacenado en *caché* y así evitar hacer consultas XPath innecesarias. 106 | Después de la optimización, la ejecución de limpieza en un CFDI con más de 2500 conceptos pasó de 107 | 180 segundos a menos de 0.5 segundos. 108 | 109 | ## Versión 1.2.2 110 | 111 | Se modifica el limpiador `XmlNsSchemaLocation` para que la limpieza se realice a nivel elemento XML. 112 | Si no existe un atributo `xsi:schemaLocation` entonces el atributo `xmlns:schemaLocation` es renombrado. 113 | Si ya existe un atributo `xsi:schemaLocation` entonces el atributo `xmlns:schemaLocation` es eliminado. 114 | Esta modificación cierra el *issue* #13. 115 | 116 | ## Versión 1.2.1 117 | 118 | Se agrega la definición del espacio de nombres de *Ingresos de Hidrocarburos 1.0* a `SetKnownSchemaLocations`. 119 | Con esta actualización se corrige el proceso de integración continua. 120 | 121 | Se corrige el estilo de código: 122 | 123 | - Se modifican los textos HEREDOC usados como argumentos de funciones. 124 | - Se actualiza `php-cs-fixer` de `3.6.0` a `3.8.0`. 125 | 126 | ## Versión 1.2.0 127 | 128 | ### Definición de XML namespace duplicado pero sin uso 129 | 130 | Se han encontrado casos donde hay CFDI que incluyen un namespace que está en uso pero con un prefijo sin uso. 131 | 132 | En el siguiente ejemplo, el espacio de nombres `http://www.sat.gob.mx/TimbreFiscalDigital` está declarado con el 133 | prefijo `nsx` y `tfd`, donde el primer prefijo no está en uso y el segundo sí. 134 | 135 | ```xml 136 | 139 | 140 | 141 | ``` 142 | 143 | Se ha modificado el limpiador `RemoveUnusedNamespaces` para que cuando detecta si un espacio de nombres está 144 | en uso detecte también el prefijo. Con este cambio, el resultado de la limpieza sería: 145 | 146 | ```xml 147 | 149 | 150 | 151 | ``` 152 | 153 | ### Definición de XML namespace duplicado y sin prefijo 154 | 155 | Se han encontrado casos donde hay CFDI *sucios*, pero válidos, donde la definición de los nodos 156 | no cuenta con un prefijo. En estos casos el limpiador está produciendo un CFDI inválido después de limpiar. 157 | 158 | Para corregir este problema: 159 | 160 | - Se elimina de la lista de limpiadores de texto por defecto a `RemoveDuplicatedCfdi3Namespace`. 161 | - Se quita la funcionalidad de `RemoveDuplicatedCfdi3Namespace` y se emite un `E_USER_DEPRECATED`. 162 | - Se crea un nuevo limpiador `RenameElementAddPrefix` que agrega el prefijo al nodo que no lo tiene por estar 163 | utilizando la definición simple `xmlns`. Además, elimina los namespace superfluos y las 164 | definiciones `xmlns` redundantes. 165 | 166 | Ejemplo de CFDI sucio: 167 | 168 | ```xml 169 | 170 | 171 | 172 | 173 | ``` 174 | 175 | Ejemplo de CFDI limpio: 176 | 177 | ```xml 178 | 179 | 180 | 181 | 182 | ``` 183 | 184 | ### El limpiador `RemoveDuplicatedCfdi3Namespace` ha sido deprecado 185 | 186 | El limpiador `RemoveDuplicatedCfdi3Namespace` ha sido deprecado porque existen casos con un XML válido, 187 | pero sucio, y el limpiador convierte el CFDI en inválido. La funcionalidad será absorvida por otro limpiador. 188 | 189 | CFDI con XML correcto, pero sucio: 190 | 191 | ```xml 192 | 193 | 194 | 195 | ``` 196 | 197 | Resultado del limpiador, donde `Emisor` ahora no pertenece al espacio de nombres `http://www.sat.gob.mx/cfd/3`. 198 | El XML es correcto, pero como CFDI ya no lo es: 199 | 200 | ```xml 201 | 202 | 203 | 204 | ``` 205 | 206 | ### Mejoras al manejo interno de definiciones de espacios de nombres XML 207 | 208 | Se modificó el *trait* `XmlNamespaceMethodsTrait` para que detectara si un elemento de espacios de nombres 209 | `DOMNameSpaceNode` está eliminado revisando si la propiedad `namespaceURI` es `NULL`. 210 | Antes se validaba contra la propiedad `nodeValue`, pero esta propiedad puede ser vacía, por ejemplo en `xmlns=""`. 211 | 212 | Al momento de verificar si un espacio de nombres es reservado, ya no se excluye cuando el espacio de nombres es vacío. 213 | 214 | ### Eliminación de definición de espacio de nombres sin prefijo 215 | 216 | Se modificó el *trait* `XmlNamespaceMethodsTrait` para que pueda eliminar un espacio de nombres sin prefijo, 217 | por ejemplo `xmlns="http://tempuri.org/root"` o `xmlns=""`. 218 | 219 | ## Versión 1.1.5 220 | 221 | ### Espacios de nombres conocidos 222 | 223 | Se actualiza la lista de espacios de nombres conocidos para: 224 | 225 | - CFDI 4.0. 226 | - CFDI de retenciones e información de pagos 2.0. 227 | - Complemento de pagos 2.0. 228 | - Complemento de carta porte 1.0. 229 | - Complemento de carta porte 2.0. 230 | 231 | Además, se agrega una prueba que usa para verificar que la lista 232 | se mantiene actualizada. 233 | 234 | ### Integración continua 235 | 236 | - Se agrega PHP 8.1 a la matriz de pruebas. 237 | - Se configura [SonarCloud](https://sonarcloud.io/project/overview?id=phpcfdi_cfdi-cleaner). 238 | - Se remueve Scrutinizer CI. Gracias por todo. 239 | - Se actualizan los *badges* del proyecto. 240 | 241 | ## Versión 1.1.4 242 | 243 | ### Error al tratar espacios de nombres duplicados 244 | 245 | Se encontraron casos en los que el CFDI firmado por un PAC tiene errores de espacios de nombres XML, 246 | específicamente al duplicar un prefijo en uso en uno de los hijos. Si bien esto es correcto en XML, 247 | no es correcto en un CFDI. 248 | 249 | En este caso el limpiador `MoveNamespaceDeclarationToRoot` estaba generando una salida de XML no válida, 250 | cambiando el prefijo, por ejemplo de `` 251 | a ``. 252 | 253 | Se corrigió `MoveNamespaceDeclarationToRoot` para que utilice la misma estrategia alternativa de 254 | espacios de nombres con prefijos sobrepuestos y entregue una salida correcta. 255 | 256 | ### Mantenimiento 257 | 258 | - Se actualiza el año de licencia. ¡Feliz 2022!. 259 | - Se corrigió el nombre de archivo de configuración de PHPStan y ahora usa el nombre correcto en `.gitattributes`, 260 | de esta forma es correctamente excluido del paquete de distribución. 261 | - Se cambia el flujo de integración continua de pasos en el trabajo a trabajos separados. 262 | - Se corrige el nombre del grupo de mantenedores de código de PhpCfdi. 263 | - Se cambia de `develop/install-development-tools` a `phive` para instalar las herramientas de desarrollo. 264 | 265 | ## Versión 1.1.3 266 | 267 | ### Error al tratar espacios de nombres predefinidos 268 | 269 | Se encontraron casos en los que el CFDI firmado por un PAC tiene severos errores de espacios de nombres XML, 270 | específicamente al redefinir un prefijo en uso por otro espacio de nombres. Si bien esto es correcto en XML, 271 | no es correcto en un CFDI. 272 | 273 | En este caso el limpiador `MoveNamespaceDeclarationToRoot` estaba generando una salida de XML no válida. 274 | 275 | Se corrigió `MoveNamespaceDeclarationToRoot` para que utilice una estrategia alternativa en el caso de encontrar 276 | espacios de nombres con prefijos sobrepuestos y entregue una salida correcta. 277 | 278 | ### `tests/clean.php` 279 | 280 | Se agregó el archivo `tests/clean.php` para limpiar un archivo CFDI y entregar la respuesta en la salida estándar. 281 | 282 | ## Versión 1.1.2 283 | 284 | Se encontró un error interno en el que, después de eliminar espacios de nombres no usados, se caía en un error 285 | al momento de volver a iterar sobre los nodos de espacios de nombre. Lo que terminaba en una excepción. 286 | 287 | Es importante actualizar si se está observando un error parecido a este: 288 | 289 | ``` 290 | TypeError: Argument 1 passed to PhpCfdi\CfdiCleaner\XmlDocumentCleaners\MoveNamespaceDeclarationToRoot::isNamespaceReserved() 291 | must be of the type string, null given, called in .../vendor/phpcfdi/cfdi-cleaner/src/Internal/XmlNamespaceMethodsTrait.php on line 28 292 | ``` 293 | 294 | ## Versión 1.1.1 295 | 296 | En algunos casos, el limpiador de cadena de caracteres `RemoveNonXmlStrings` regresaba una cadena de caracteres vacía, 297 | no pude determinar la causa exacta, pero fallaba con `preg_last_error_msg() == "JIT stack limit exhausted"`. 298 | 299 | Este limpiador se encarga de eliminar cualquier caracter previo al primer `<` y posterior al último `>`. 300 | Por lo que se ha cambiado a trabajo de cadenas de caracteres en lugar de expresiones regulares. 301 | 302 | ## Versión 1.1.0 303 | 304 | Se agrega el limpiador de texto XML `SplitXmlDeclarationFromDocument` que separa la declaración XML del resto del 305 | documento XML utilizando uno y solo un caracter `LF`. Por ejemplo: 306 | 307 | ```diff 308 | --- 309 | +++ 310 | +++ 311 | ``` 312 | 313 | Además, se incluyen los siguientes cambios previamente no liberados: 314 | 315 | **2021-06-28**: Se reconfiguró PHPUnit para que fallara con un test incompleto o un *test suite* vacío, 316 | pasara con un test riesgoso y no fuera *verbose*. 317 | 318 | **2021-06-28**: Se corrigió el título del código de conducta. 319 | 320 | **2021-06-28**: Se corrigió el nombre de la prueba `AddXmlDeclarationTest` a `AppendXmlDeclarationTest`. 321 | 322 | **2021-05-18**: Se reconfiguró el proyecto para el uso de `php-cs-fixer: ^3.0`. 323 | 324 | **2021-05-18**: Se corrigieron las extensiones usadas por la acción `build.yml/setup-php`. 325 | 326 | **2021-05-18**: Se actualiza la configuración de PHPUnit con la ubicación del caché. 327 | 328 | **2021-04-28**: Las pruebas no funcionaban correctamente con `LibXML < 2.9.10`. 329 | Presumiblemente por la canonicalización y recarga realizada por PHPUnit `sebastian/comparator`. 330 | Esto provocaba que los test no pasaran en sistemas con estas versiones, por ejemplo, Scrutinizer. 331 | La solución más simple fue cambiar los espacios de nombres `urn:foo` a `http://tempuri.org/foo`. 332 | 333 | ## Versión 1.0.0 334 | 335 | - Versión inicial. 336 | -------------------------------------------------------------------------------- /docs/SEMVER.md: -------------------------------------------------------------------------------- 1 | # SEMVER 2 | 3 | Respetamos el estándar [Versionado Semántico 2.0.0](https://semver.org/lang/es/). 4 | 5 | En resumen, [SemVer](https://semver.org/) es un sistema de versiones de tres componentes `X.Y.Z` 6 | que nombraremos así: ` Breaking . Feature . Fix `, donde: 7 | 8 | - `Breaking`: Rompe la compatibilidad de código con versiones anteriores. 9 | - `Feature`: Agrega una nueva característica que es compatible con lo anterior. 10 | - `Fix`: Incluye algún cambio (generalmente correcciones) que no agregan nueva funcionalidad. 11 | 12 | ## Composer 13 | 14 | La herramienta [Composer](https://getcomposer.org/) es un gestor de dependencias en proyectos para PHP. 15 | Este gestor usa las [reglas](https://getcomposer.org/doc/articles/versions.md) 16 | de versionado semántico para instalar y actualizar paquetes. 17 | 18 | Te recomendamos instalar dependencias de librerías (no frameworks) con *Caret Version Range*. 19 | Por ejemplo: `"vendor/package": "^2.5"`. 20 | 21 | Esto significa que: 22 | 23 | - no debe actualizar a versiones `3.x.x` 24 | - no debe utilizar ninguna versión menor a `2.5.0` 25 | 26 | ## Versiones 0.x.y no rompe compatibilidad 27 | 28 | Las versiones que inician con cero, por ejemplo `0.y.z`, no se ajustan a las reglas de versionado. 29 | Se considera que estas versiones son previas a la madurez del proyecto y por lo tanto 30 | introducen cambios sin previo aviso. 31 | 32 | ## `@internal` no rompe compatibilidad 33 | 34 | Si la librería contiene elementos marcados como `@internal` significa que no deben ser utilizados 35 | por tu código. Son partes de código internos de la librería. 36 | Por lo tanto, no se consideran breaking changes. 37 | 38 | Cuando un elemento es `@internal`, dicho elemento: 39 | 40 | - no debe ser una entrada (parámetro) 41 | - no debe ser una salida (retorno) 42 | - no debe exponer funcionalidades en los objetos públicos (rasgos) 43 | -------------------------------------------------------------------------------- /docs/TODO.md: -------------------------------------------------------------------------------- 1 | # phpcfdi/cfdi-cleaner To Do List 2 | 3 | ## Deprecaciones 2.0.0 4 | 5 | - Eliminar `PhpCfdi\CfdiCleaner\XmlStringCleaners\RemoveDuplicatedCfdi3Namespace`. 6 | 7 | ## Actualizaciones 8 | 9 | *Listar aquí las tareas pendientes*. 10 | -------------------------------------------------------------------------------- /src/Cleaner.php: -------------------------------------------------------------------------------- 1 | stringCleaners = $stringCleaners ?? XmlStringCleaners::createDefault(); 20 | $this->xmlCleaners = $xmlCleaners ?? XmlDocumentCleaners::createDefault(); 21 | } 22 | 23 | public function exclude(ExcludeList $excludeList): void 24 | { 25 | $this->stringCleaners = $this->stringCleaners->withOutCleaners($excludeList); 26 | $this->xmlCleaners = $this->xmlCleaners->withOutCleaners($excludeList); 27 | } 28 | 29 | public static function staticClean(string $xml): string 30 | { 31 | return (new self())->cleanStringToString($xml); 32 | } 33 | 34 | public function cleanString(string $xml): string 35 | { 36 | return $this->stringCleaners->clean($xml); 37 | } 38 | 39 | public function cleanDocument(DOMDocument $document): void 40 | { 41 | $this->xmlCleaners->clean($document); 42 | } 43 | 44 | public function cleanStringToDocument(string $xml): DOMDocument 45 | { 46 | $xml = $this->cleanString($xml); 47 | $document = $this->createDocument($xml); 48 | $this->cleanDocument($document); 49 | return $document; 50 | } 51 | 52 | public function cleanStringToString(string $xml): string 53 | { 54 | return $this->cleanStringToDocument($xml)->saveXML() ?: ''; 55 | } 56 | 57 | protected function createDocument(string $xml): DOMDocument 58 | { 59 | $document = new DOMDocument(); 60 | $document->preserveWhiteSpace = false; 61 | $document->formatOutput = true; 62 | $document->loadXML($xml, LIBXML_NSCLEAN | LIBXML_PARSEHUGE); 63 | return $document; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/ExcludeList.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | final class ExcludeList implements IteratorAggregate 17 | { 18 | /** @var list */ 19 | private $classNames; 20 | 21 | /** @param class-string ...$classNames */ 22 | public function __construct(string ...$classNames) 23 | { 24 | $this->classNames = array_values($classNames); 25 | } 26 | 27 | public function isEmpty(): bool 28 | { 29 | return [] === $this->classNames; 30 | } 31 | 32 | public function match(object $object): bool 33 | { 34 | foreach ($this->classNames as $className) { 35 | if ($object instanceof $className) { 36 | return true; 37 | } 38 | } 39 | 40 | return false; 41 | } 42 | 43 | /** 44 | * @template TObject of object 45 | * @param TObject ...$objects 46 | * @return array 47 | */ 48 | public function filterObjects(object ...$objects): array 49 | { 50 | return array_filter( 51 | $objects, 52 | function (object $object): bool { 53 | return ! $this->match($object); 54 | } 55 | ); 56 | } 57 | 58 | /** @return Traversable */ 59 | public function getIterator(): Traversable 60 | { 61 | return new ArrayIterator($this->classNames); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Internal/CfdiXPath.php: -------------------------------------------------------------------------------- 1 | xpath = $xpath; 29 | } 30 | 31 | public static function createFromDocument(DOMDocument $document): self 32 | { 33 | $xpath = new DOMXPath($document); 34 | $rootNamespace = $document->documentElement->namespaceURI ?? ''; 35 | if (! in_array($rootNamespace, self::ALLOWED_NAMESPACES)) { 36 | $rootNamespace = ''; 37 | } 38 | $xpath->registerNamespace('cfdi', $rootNamespace); 39 | $xpath->registerNamespace('xsi', XmlConstants::NAMESPACE_XSI); 40 | return new self($xpath); 41 | } 42 | 43 | /** 44 | * @param string $xpathQuery 45 | * @return DOMNodeList 46 | */ 47 | public function queryElements(string $xpathQuery): DOMNodeList 48 | { 49 | /** @var DOMNodeList $list PHPStan does not detect empty DOMNodeList subtype */ 50 | $list = $this->xpath->query($xpathQuery, null, false) ?: new DOMNodeList(); 51 | return $list; 52 | } 53 | 54 | /** 55 | * Get all XMLSchema instance attributes schemaLocation 56 | * 57 | * @return DOMNodeList 58 | */ 59 | public function querySchemaLocations(): DOMNodeList 60 | { 61 | return $this->queryAttributes('//@xsi:schemaLocation'); 62 | } 63 | 64 | /** 65 | * @param string $xpathQuery 66 | * @return DOMNodeList 67 | */ 68 | public function queryAttributes(string $xpathQuery): DOMNodeList 69 | { 70 | /** @var DOMNodeList $list PHPStan does not detect empty DOMNodeList subtype */ 71 | $list = $this->xpath->query($xpathQuery, null, false) ?: new DOMNodeList(); 72 | return $list; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Internal/SchemaLocation.php: -------------------------------------------------------------------------------- 1 | */ 15 | private $pairs; 16 | 17 | /** 18 | * SchemaLocation constructor. 19 | * 20 | * @param array $pairs On each entry: key is namespace, value is location 21 | */ 22 | public function __construct(array $pairs) 23 | { 24 | $this->pairs = $pairs; 25 | } 26 | 27 | public static function createFromValue(string $value): self 28 | { 29 | return self::createFromComponents(self::valueToComponents($value)); 30 | } 31 | 32 | /** 33 | * @param string $schemaLocationValue 34 | * @return string[] 35 | */ 36 | public static function valueToComponents(string $schemaLocationValue): array 37 | { 38 | return array_values(array_filter(explode(' ', preg_replace('/\s/', ' ', $schemaLocationValue) ?? ''))); 39 | } 40 | 41 | /** 42 | * @param string[] $components 43 | * @return self 44 | */ 45 | public static function createFromComponents(array $components): self 46 | { 47 | $pairs = []; 48 | $count = count($components); 49 | for ($i = 0; $i < $count; $i = $i + 2) { 50 | $pairs[$components[$i]] = $components[$i + 1] ?? ''; 51 | } 52 | return new self($pairs); 53 | } 54 | 55 | /** @return array */ 56 | public function getPairs(): array 57 | { 58 | return $this->pairs; 59 | } 60 | 61 | public function setPair(string $namespace, string $location): void 62 | { 63 | $this->pairs[$namespace] = $location; 64 | } 65 | 66 | public function filterUsingNamespace(callable $filterFunction): void 67 | { 68 | $this->pairs = array_filter($this->pairs, $filterFunction, ARRAY_FILTER_USE_KEY); 69 | } 70 | 71 | public function asValue(): string 72 | { 73 | return implode(' ', array_map( 74 | function (string $namespace, string $location): string { 75 | return $namespace . ' ' . $location; 76 | }, 77 | array_keys($this->pairs), 78 | $this->pairs, 79 | )); 80 | } 81 | 82 | public function import(self $source): void 83 | { 84 | $this->pairs = array_merge($this->pairs, $source->pairs); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Internal/XmlAttributeMethodsTrait.php: -------------------------------------------------------------------------------- 1 | ownerElement; 17 | if (null !== $ownerElement) { 18 | $ownerElement->removeAttribute($attribute->nodeName); 19 | } 20 | } 21 | 22 | private function attributeSetValueOrRemoveIfEmpty(DOMAttr $attribute, string $value): void 23 | { 24 | if ('' === $value) { 25 | $this->attributeRemove($attribute); 26 | return; 27 | } 28 | 29 | $attribute->value = $value; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Internal/XmlConstants.php: -------------------------------------------------------------------------------- 1 | parentNode; 17 | if (null !== $parent) { 18 | $parent->removeChild($element); 19 | } 20 | } 21 | 22 | private function elementMove(DOMElement $element, DOMElement $parent): void 23 | { 24 | $this->elementRemove($element); 25 | $parent->appendChild($element); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Internal/XmlNamespaceMethodsTrait.php: -------------------------------------------------------------------------------- 1 | 22 | * @phpstan-impure 23 | * @internal The actual returned class is Generator (undocumented) 24 | */ 25 | private function iterateNonReservedNamespaces(DOMDocument $document): Generator 26 | { 27 | $xpath = new DOMXPath($document); 28 | $namespaceNodes = $xpath->query('//namespace::*') ?: new DOMNodeList(); 29 | foreach ($namespaceNodes as $namespaceNode) { 30 | // discard removed namespaces they could be returned by XPath 31 | if (null === $namespaceNode->namespaceURI) { 32 | continue; 33 | } 34 | 35 | // discard reserved (internal xml) namespaces 36 | if ($this->isNamespaceReserved($namespaceNode->namespaceURI)) { 37 | continue; 38 | } 39 | 40 | yield $namespaceNode; 41 | } 42 | } 43 | 44 | /** 45 | * @param DOMNode&object $namespaceNode 46 | */ 47 | private function removeNamespaceNodeAttribute($namespaceNode): void 48 | { 49 | $ownerElement = $namespaceNode->parentNode; 50 | if ($ownerElement instanceof DOMElement) { 51 | $localName = ('xmlns' === $namespaceNode->localName) ? '' : (string) $namespaceNode->localName; 52 | if ($ownerElement->hasAttributeNS(XmlConstants::NAMESPACE_XMLNS, $localName)) { 53 | $ownerElement->removeAttributeNS((string) $namespaceNode->nodeValue, $localName); 54 | } 55 | } 56 | } 57 | 58 | private function isNamespaceReserved(string $namespace): bool 59 | { 60 | $reservedNameSpaces = [ 61 | XmlConstants::NAMESPACE_XML, // xml 62 | XmlConstants::NAMESPACE_XMLNS, // xml namespace allocation 63 | XmlConstants::NAMESPACE_XSI, // xml schema instance 64 | ]; 65 | return in_array($namespace, $reservedNameSpaces, true); 66 | } 67 | 68 | private function isNamespaceRelatedToSat(string $namespace): bool 69 | { 70 | return str_starts_with($namespace, 'http://www.sat.gob.mx/'); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/XmlDocumentCleanerInterface.php: -------------------------------------------------------------------------------- 1 | cleaners = $cleaners; 17 | } 18 | 19 | public static function createDefault(): self 20 | { 21 | return new self( 22 | new XmlDocumentCleaners\RemoveAddenda(), 23 | new XmlDocumentCleaners\RemoveIncompleteSchemaLocations(), 24 | new XmlDocumentCleaners\RemoveNonSatNamespacesNodes(), 25 | new XmlDocumentCleaners\RemoveNonSatSchemaLocations(), 26 | new XmlDocumentCleaners\RemoveUnusedNamespaces(), 27 | new XmlDocumentCleaners\RenameElementAddPrefix(), 28 | new XmlDocumentCleaners\MoveNamespaceDeclarationToRoot(), 29 | new XmlDocumentCleaners\MoveSchemaLocationsToRoot(), 30 | new XmlDocumentCleaners\SetKnownSchemaLocations(), 31 | new XmlDocumentCleaners\CollapseComplemento(), 32 | ); 33 | } 34 | 35 | public function clean(DOMDocument $document): void 36 | { 37 | foreach ($this->cleaners as $cleaner) { 38 | $cleaner->clean($document); 39 | } 40 | } 41 | 42 | public function withOutCleaners(ExcludeList $excludeList): self 43 | { 44 | $cleaners = $excludeList->filterObjects(...$this->cleaners); 45 | return new self(...$cleaners); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/XmlDocumentCleaners/CollapseComplemento.php: -------------------------------------------------------------------------------- 1 | queryElements('/cfdi:Comprobante/cfdi:Complemento'); 22 | if ($complementos->length < 2) { 23 | return; 24 | } 25 | 26 | $receiver = null; 27 | foreach ($complementos as $complemento) { 28 | // first complemento 29 | if (null === $receiver) { 30 | $receiver = $complemento; 31 | continue; 32 | } 33 | 34 | // non-first complemento 35 | while ($complemento->childNodes->length > 0) { 36 | /** @var DOMElement $child */ 37 | $child = $complemento->childNodes->item(0); 38 | $this->elementMove($child, $receiver); 39 | } 40 | $this->elementRemove($complemento); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/XmlDocumentCleaners/MoveNamespaceDeclarationToRoot.php: -------------------------------------------------------------------------------- 1 | documentElement; 20 | if (null === $rootElement) { 21 | return; 22 | } 23 | 24 | if ($this->documentHasOverlappedNamespaces($document)) { 25 | $this->moveNamespacesToRootOverlapped($document, $rootElement); 26 | } else { 27 | $this->moveNamespacesToRoot($document, $rootElement); 28 | } 29 | } 30 | 31 | private function documentHasOverlappedNamespaces(DOMDocument $document): bool 32 | { 33 | $prefixes = []; 34 | /** $namespaceNode is a DOMNameSpaceNode, parentNode always exists */ 35 | foreach ($this->iterateNonReservedNamespaces($document) as $namespaceNode) { 36 | /** @var DOMElement $ownerElement */ 37 | $ownerElement = $namespaceNode->parentNode; 38 | /** 39 | * $namespaceNode->nodeName => xmlns:cfdi 40 | * $namespaceNode->nodeValue => http://www.sat.gob.mx/cfd/3 41 | * $namespaceNode->parentNode => DOMElement where namespace definition is 42 | */ 43 | $currentDefinition = [ 44 | 'namespace' => $namespaceNode->nodeValue, 45 | 'owner' => $ownerElement, 46 | ]; 47 | if (! isset($prefixes[$namespaceNode->nodeName])) { 48 | $prefixes[$namespaceNode->nodeName] = $currentDefinition; 49 | continue; 50 | } 51 | if ( 52 | $ownerElement->hasAttribute($namespaceNode->nodeName) 53 | && $prefixes[$namespaceNode->nodeName] !== $currentDefinition 54 | ) { 55 | return true; 56 | } 57 | } 58 | return false; 59 | } 60 | 61 | private function moveNamespacesToRootOverlapped(DOMDocument $document, DOMElement $rootElement): void 62 | { 63 | $namespaces = []; 64 | foreach ($this->iterateNonReservedNamespaces($document) as $namespaceNode) { 65 | if ($rootElement === $namespaceNode->parentNode) { 66 | continue; // already on root 67 | } 68 | $nsPrefix = (string) $namespaceNode->nodeName; 69 | $nsLocation = (string) $namespaceNode->nodeValue; 70 | $namespaces[$nsPrefix] = $namespaces[$nsPrefix] ?? $nsLocation; 71 | // do not iterate on overlapped 72 | if ($namespaces[$nsPrefix] !== $nsLocation) { 73 | continue; 74 | } 75 | // soft-write the xml namespace declaration if it does not exist yet 76 | if (! $rootElement->hasAttribute($nsPrefix)) { 77 | $rootElement->setAttribute($nsPrefix, $nsLocation); 78 | } 79 | } 80 | // ditry hack to remove child namespace declaration 81 | $document->loadXML($document->saveXML() ?: '', LIBXML_NSCLEAN | LIBXML_PARSEHUGE); 82 | } 83 | 84 | private function moveNamespacesToRoot(DOMDocument $document, DOMElement $rootElement): void 85 | { 86 | foreach ($this->iterateNonReservedNamespaces($document) as $namespaceNode) { 87 | if ($rootElement === $namespaceNode->parentNode) { 88 | continue; 89 | } 90 | 91 | $nsNodeName = $namespaceNode->nodeName; 92 | $nsNodeValue = $namespaceNode->nodeValue; 93 | if ($nsNodeValue && ! $rootElement->hasAttribute($nsNodeName)) { 94 | $rootElement->setAttributeNS(XmlConstants::NAMESPACE_XMLNS, $nsNodeName, $nsNodeValue); 95 | } 96 | 97 | $this->removeNamespaceNodeAttribute($namespaceNode); 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/XmlDocumentCleaners/MoveSchemaLocationsToRoot.php: -------------------------------------------------------------------------------- 1 | documentElement; 23 | if (null === $root) { 24 | return; 25 | } 26 | 27 | if (! $root->hasAttributeNS(XmlConstants::NAMESPACE_XSI, 'schemaLocation')) { 28 | $root->setAttributeNS(XmlConstants::NAMESPACE_XSI, 'xsi:schemaLocation', ''); 29 | } 30 | $rootAttribute = $root->getAttributeNodeNS(XmlConstants::NAMESPACE_XSI, 'schemaLocation'); 31 | $schemaLocation = SchemaLocation::createFromValue((string) $rootAttribute->nodeValue); 32 | 33 | $xpath = CfdiXPath::createFromDocument($document); 34 | $schemaLocationAttributes = $xpath->querySchemaLocations(); 35 | foreach ($schemaLocationAttributes as $schemaLocationAttribute) { 36 | if ($rootAttribute === $schemaLocationAttribute) { 37 | continue; 38 | } 39 | 40 | $currentSchemaLocation = SchemaLocation::createFromValue((string) $schemaLocationAttribute->nodeValue); 41 | $schemaLocation->import($currentSchemaLocation); 42 | $this->attributeRemove($schemaLocationAttribute); 43 | } 44 | 45 | $rootAttribute->nodeValue = $schemaLocation->asValue(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/XmlDocumentCleaners/RemoveAddenda.php: -------------------------------------------------------------------------------- 1 | removeAddendas($document, 'http://www.sat.gob.mx/cfd/3'); 18 | $this->removeAddendas($document, 'http://www.sat.gob.mx/cfd/4'); 19 | $this->removeAddendas($document, 'http://www.sat.gob.mx/esquemas/retencionpago/2'); 20 | $this->removeAddendas($document, 'http://www.sat.gob.mx/esquemas/retencionpago/1'); 21 | } 22 | 23 | private function removeAddendas(DOMDocument $document, string $namespace): void 24 | { 25 | $addendas = $document->getElementsByTagNameNS($namespace, 'Addenda'); 26 | foreach ($addendas as $addenda) { 27 | $this->elementRemove($addenda); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/XmlDocumentCleaners/RemoveIncompleteSchemaLocations.php: -------------------------------------------------------------------------------- 1 | querySchemaLocations(); 21 | foreach ($schemaLocations as $schemaLocation) { 22 | $value = $this->cleanSchemaLocationValue($schemaLocation->value); 23 | $this->attributeSetValueOrRemoveIfEmpty($schemaLocation, $value); 24 | } 25 | } 26 | 27 | /** 28 | * @param string $schemaLocationValue 29 | * @return string 30 | * @internal 31 | */ 32 | public function cleanSchemaLocationValue(string $schemaLocationValue): string 33 | { 34 | $pairs = $this->schemaLocationValueNamespaceXsdPairToArray($schemaLocationValue); 35 | $schemaLocation = new SchemaLocation($pairs); 36 | return $schemaLocation->asValue(); 37 | } 38 | 39 | /** 40 | * Parses schema location value skipping namespaces without xsd locations (identified by .xsd extension) 41 | * 42 | * @param string $schemaLocationValue 43 | * @return array 44 | * @internal 45 | */ 46 | public function schemaLocationValueNamespaceXsdPairToArray(string $schemaLocationValue): array 47 | { 48 | $components = SchemaLocation::valueToComponents($schemaLocationValue); 49 | $pairs = []; 50 | $length = count($components); 51 | for ($c = 0; $c < $length; $c = $c + 1) { 52 | $namespace = $components[$c]; 53 | if ($this->uriEndsWithXsd($namespace)) { // namespace is a location 54 | continue; 55 | } 56 | 57 | $location = $components[$c + 1] ?? ''; 58 | if (! $this->uriEndsWithXsd($location)) { // location is a namespace 59 | continue; 60 | } 61 | 62 | // namespace match with location that ends with xsd 63 | $pairs[$namespace] = $location; 64 | $c = $c + 1; // skip ns declaration 65 | } 66 | 67 | return $pairs; 68 | } 69 | 70 | public function uriEndsWithXsd(string $uri): bool 71 | { 72 | return str_ends_with(strtolower($uri), '.xsd'); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/XmlDocumentCleaners/RemoveNonSatNamespacesNodes.php: -------------------------------------------------------------------------------- 1 | obtainNamespacesFromDocument($document); 27 | foreach ($namespaces as $namespace) { 28 | if (! $this->isNamespaceRelatedToSat($namespace)) { 29 | $this->removeElementsWithNamespace($xpath, $namespace); 30 | $this->removeAttributesWithNamespace($xpath, $namespace); 31 | } 32 | } 33 | } 34 | 35 | /** @return string[] */ 36 | private function obtainNamespacesFromDocument(DOMDocument $document): array 37 | { 38 | $namespaces = []; 39 | foreach ($this->iterateNonReservedNamespaces($document) as $namespaceNode) { 40 | $namespaces[] = (string) $namespaceNode->nodeValue; 41 | } 42 | return array_unique($namespaces); 43 | } 44 | 45 | private function removeElementsWithNamespace(DOMXPath $xpath, string $namespace): void 46 | { 47 | /** @var DOMNodeList $elements */ 48 | $elements = $xpath->query(sprintf('//*[namespace-uri()="%1$s"]', $namespace)); 49 | foreach ($elements as $element) { 50 | $this->elementRemove($element); 51 | } 52 | } 53 | 54 | private function removeAttributesWithNamespace(DOMXPath $xpath, string $namespace): void 55 | { 56 | /** @var DOMNodeList $attributes */ 57 | $attributes = $xpath->query(sprintf('//@*[namespace-uri()="%1$s"]', $namespace)); 58 | foreach ($attributes as $attribute) { 59 | $this->attributeRemove($attribute); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/XmlDocumentCleaners/RemoveNonSatSchemaLocations.php: -------------------------------------------------------------------------------- 1 | querySchemaLocations(); 23 | foreach ($schemaLocations as $schemaLocation) { 24 | $value = $this->cleanSchemaLocationsValue($schemaLocation->value); 25 | $this->attributeSetValueOrRemoveIfEmpty($schemaLocation, $value); 26 | } 27 | } 28 | 29 | public function cleanSchemaLocationsValue(string $schemaLocationValue): string 30 | { 31 | $schemaLocation = SchemaLocation::createFromValue($schemaLocationValue); 32 | $schemaLocation->filterUsingNamespace( 33 | function (string $namespace): bool { 34 | return $this->isNamespaceRelatedToSat($namespace); 35 | }, 36 | ); 37 | return $schemaLocation->asValue(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/XmlDocumentCleaners/RemoveUnusedNamespaces.php: -------------------------------------------------------------------------------- 1 | */ 21 | private $prefixedNamespaceOnUseCache; 22 | 23 | private function setUp(DOMXPath $xpath): void 24 | { 25 | $this->xpath = $xpath; 26 | $this->prefixedNamespaceOnUseCache = []; 27 | } 28 | 29 | public function clean(DOMDocument $document): void 30 | { 31 | $this->setUp(new DOMXPath($document)); 32 | 33 | foreach ($this->iterateNonReservedNamespaces($document) as $namespaceNode) { 34 | $this->checkNamespaceNode($namespaceNode); 35 | } 36 | } 37 | 38 | /** 39 | * @param DOMNode&object $namespaceNode 40 | */ 41 | private function checkNamespaceNode($namespaceNode): void 42 | { 43 | $namespace = $namespaceNode->nodeValue; 44 | if (null === $namespace) { 45 | return; 46 | } 47 | $prefix = ('' !== strval($namespaceNode->prefix)) ? $namespaceNode->prefix . ':' : ''; 48 | 49 | if (! $this->isPrefixedNamespaceOnUseCached($namespace, $prefix)) { 50 | $this->removeNamespaceNodeAttribute($namespaceNode); 51 | } 52 | } 53 | 54 | /** 55 | * Function `isPrefixedNamespaceOnUse` is costly, use cache to avoid repetetive calls. 56 | * @see isPrefixedNamespaceOnUse 57 | */ 58 | private function isPrefixedNamespaceOnUseCached(string $namespace, string $prefix): bool 59 | { 60 | $key = sprintf('namespace=%s;prefix=%s', $namespace, $prefix); 61 | if (! array_key_exists($key, $this->prefixedNamespaceOnUseCache)) { 62 | $this->prefixedNamespaceOnUseCache[$key] = $this->isPrefixedNamespaceOnUse($namespace, $prefix); 63 | } 64 | return $this->prefixedNamespaceOnUseCache[$key]; 65 | } 66 | 67 | private function isPrefixedNamespaceOnUse(string $namespace, string $prefix): bool 68 | { 69 | if ($this->hasElementsOnNamespace($namespace, $prefix)) { 70 | return true; 71 | } 72 | if ($this->hasAttributesOnNamespace($namespace, $prefix)) { 73 | return true; 74 | } 75 | return false; 76 | } 77 | 78 | private function hasElementsOnNamespace(string $namespace, string $prefix): bool 79 | { 80 | $elements = $this->xpath->query( 81 | sprintf('(//*[namespace-uri()="%1$s" and name()=concat("%2$s", local-name())])[1]', $namespace, $prefix), 82 | ); 83 | return false !== $elements && $elements->length > 0; 84 | } 85 | 86 | private function hasAttributesOnNamespace(string $namespace, string $prefix): bool 87 | { 88 | $elements = $this->xpath->query( 89 | sprintf('(//@*[namespace-uri()="%1$s" and name()=concat("%2$s", local-name())])[1]', $namespace, $prefix), 90 | ); 91 | return false !== $elements && $elements->length > 0; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/XmlDocumentCleaners/RenameElementAddPrefix.php: -------------------------------------------------------------------------------- 1 | documentElement; 19 | if (null === $rootElement) { 20 | return; 21 | } 22 | 23 | $this->cleanElement($rootElement); 24 | 25 | // remove unused xmlns declarations 26 | foreach ($this->iterateNonReservedNamespaces($document) as $namespaceNode) { 27 | if ('xmlns' === $namespaceNode->nodeName) { 28 | /** @var DOMElement $parentNode */ 29 | $parentNode = $namespaceNode->parentNode; 30 | if ('' !== $this->queryPrefix($parentNode)) { 31 | $this->removeNamespaceNodeAttribute($namespaceNode); 32 | } 33 | } 34 | } 35 | 36 | // Remove redundant namespace declarations 37 | // We are using saveXML and loadXML because normalizeDocument method doesn't seem to reset the namespaces; 38 | $document->loadXML($document->saveXML() ?: '', LIBXML_NSCLEAN | LIBXML_PARSEHUGE); 39 | } 40 | 41 | private function cleanElement(DOMElement $element): void 42 | { 43 | $this->cleanElementPrefix($element); 44 | 45 | foreach ($element->childNodes as $child) { 46 | if ($child instanceof DOMElement) { 47 | $this->cleanElement($child); 48 | } 49 | } 50 | } 51 | 52 | private function cleanElementPrefix(DOMElement $element): void 53 | { 54 | $elementPrefix = (string) $element->prefix; 55 | if ('' !== $elementPrefix) { 56 | return; 57 | } 58 | 59 | $targetPrefix = $this->queryPrefix($element); 60 | if ('' !== $targetPrefix && $elementPrefix !== $targetPrefix) { 61 | $element->prefix = $targetPrefix; 62 | } 63 | } 64 | 65 | private function queryPrefix(DOMElement $element): string 66 | { 67 | $namespace = (string) $element->namespaceURI; 68 | if ('' === $namespace) { 69 | return ''; 70 | } 71 | 72 | /** @var DOMDocument $document */ 73 | $document = $element->ownerDocument; 74 | 75 | foreach ($this->iterateNonReservedNamespaces($document) as $namespaceNode) { 76 | if ($element !== $namespaceNode->parentNode) { 77 | continue; 78 | } 79 | 80 | $prefix = (string) $namespaceNode->prefix; 81 | if ('' !== $prefix && $namespaceNode->nodeValue === $namespace) { 82 | return $prefix; 83 | } 84 | } 85 | 86 | return ''; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/XmlDocumentCleaners/SetKnownSchemaLocations.php: -------------------------------------------------------------------------------- 1 | 26 | */ 27 | private const KNOWN_NAMESPACES = [ 28 | 'http://www.sat.gob.mx/cfd/4#4.0' 29 | => 'http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd', 30 | 'http://www.sat.gob.mx/cfd/3#3.3' 31 | => 'http://www.sat.gob.mx/sitio_internet/cfd/3/cfdv33.xsd', 32 | 'http://www.sat.gob.mx/cfd/3#3.2' 33 | => 'http://www.sat.gob.mx/sitio_internet/cfd/3/cfdv32.xsd', 34 | 'http://www.sat.gob.mx/cfd/3#3.0' 35 | => 'http://www.sat.gob.mx/sitio_internet/cfd/3/cfdv3.xsd', 36 | 'http://www.sat.gob.mx/cfd/2#2.2' 37 | => 'http://www.sat.gob.mx/sitio_internet/cfd/2/cfdv22.xsd', 38 | 'http://www.sat.gob.mx/cfd/2#2.0' 39 | => 'http://www.sat.gob.mx/sitio_internet/cfd/2/cfdv2.xsd', 40 | 'http://www.sat.gob.mx/esquemas/retencionpago/2#2.0' 41 | => 'http://www.sat.gob.mx/esquemas/retencionpago/2/retencionpagov2.xsd', 42 | 'http://www.sat.gob.mx/esquemas/retencionpago/1#1.0' 43 | => 'http://www.sat.gob.mx/esquemas/retencionpago/1/retencionpagov1.xsd', 44 | 'http://www.sat.gob.mx/TimbreFiscalDigital#1.0' 45 | => 'http://www.sat.gob.mx/sitio_internet/cfd/TimbreFiscalDigital/TimbreFiscalDigital.xsd', 46 | 'http://www.sat.gob.mx/TimbreFiscalDigital#1.1' 47 | => 'http://www.sat.gob.mx/sitio_internet/cfd/TimbreFiscalDigital/TimbreFiscalDigitalv11.xsd', 48 | 'http://www.sat.gob.mx/ecb#1.0' 49 | => 'http://www.sat.gob.mx/sitio_internet/cfd/ecb/ecb.xsd', 50 | 'http://www.sat.gob.mx/ecc#1.0' 51 | => 'http://www.sat.gob.mx/sitio_internet/cfd/ecc/ecc.xsd', 52 | 'http://www.sat.gob.mx/EstadoDeCuentaCombustible#1.1' 53 | => 'http://www.sat.gob.mx/sitio_internet/cfd/EstadoDeCuentaCombustible/ecc11.xsd', 54 | 'http://www.sat.gob.mx/EstadoDeCuentaCombustible12#1.2' 55 | => 'http://www.sat.gob.mx/sitio_internet/cfd/EstadoDeCuentaCombustible/ecc12.xsd', 56 | 'http://www.sat.gob.mx/donat#1.0' 57 | => 'http://www.sat.gob.mx/sitio_internet/cfd/donat/donat.xsd', 58 | 'http://www.sat.gob.mx/donat#1.1' 59 | => 'http://www.sat.gob.mx/sitio_internet/cfd/donat/donat11.xsd', 60 | 'http://www.sat.gob.mx/divisas#1.0' 61 | => 'http://www.sat.gob.mx/sitio_internet/cfd/divisas/divisas.xsd', 62 | 'http://www.sat.gob.mx/implocal#1.0' 63 | => 'http://www.sat.gob.mx/sitio_internet/cfd/implocal/implocal.xsd', 64 | 'http://www.sat.gob.mx/leyendasFiscales#1.0' 65 | => 'http://www.sat.gob.mx/sitio_internet/cfd/leyendasFiscales/leyendasFisc.xsd', 66 | 'http://www.sat.gob.mx/pfic#1.0' 67 | => 'http://www.sat.gob.mx/sitio_internet/cfd/pfic/pfic.xsd', 68 | 'http://www.sat.gob.mx/TuristaPasajeroExtranjero#1.0' 69 | => 'http://www.sat.gob.mx/sitio_internet/cfd/TuristaPasajeroExtranjero/TuristaPasajeroExtranjero.xsd', 70 | 'http://www.sat.gob.mx/spei#' 71 | => 'http://www.sat.gob.mx/sitio_internet/cfd/spei/spei.xsd', 72 | 'http://www.sat.gob.mx/detallista#' 73 | => 'http://www.sat.gob.mx/sitio_internet/cfd/detallista/detallista.xsd', 74 | 'http://www.sat.gob.mx/ registrofiscal#1.0' 75 | => 'http://www.sat.gob.mx/sitio_internet/cfd/cfdiregistrofiscal/cfdiregistrofiscal.xsd', 76 | 'http://www.sat.gob.mx/nomina#1.1' 77 | => 'http://www.sat.gob.mx/sitio_internet/cfd/nomina/nomina11.xsd', 78 | 'http://www.sat.gob.mx/nomina12#1.2' 79 | => 'http://www.sat.gob.mx/sitio_internet/cfd/nomina/nomina12.xsd', 80 | 'http://www.sat.gob.mx/pagoenespecie#1.0' 81 | => 'http://www.sat.gob.mx/sitio_internet/cfd/pagoenespecie/pagoenespecie.xsd', 82 | 'http://www.sat.gob.mx/valesdedespensa#1.0' 83 | => 'http://www.sat.gob.mx/sitio_internet/cfd/valesdedespensa/valesdedespensa.xsd', 84 | 'http://www.sat.gob.mx/ConsumoDeCombustibles11#1.1' 85 | => 'http://www.sat.gob.mx/sitio_internet/cfd/consumodecombustibles/consumodeCombustibles11.xsd', 86 | 'http://www.sat.gob.mx/consumodecombustibles#1.0' 87 | => 'http://www.sat.gob.mx/sitio_internet/cfd/consumodecombustibles/consumodecombustibles.xsd', 88 | 'http://www.sat.gob.mx/aerolineas#1.0' 89 | => 'http://www.sat.gob.mx/sitio_internet/cfd/aerolineas/aerolineas.xsd', 90 | 'http://www.sat.gob.mx/notariospublicos#1.0' 91 | => 'http://www.sat.gob.mx/sitio_internet/cfd/notariospublicos/notariospublicos.xsd', 92 | 'http://www.sat.gob.mx/vehiculousado#1.0' 93 | => 'http://www.sat.gob.mx/sitio_internet/cfd/vehiculousado/vehiculousado.xsd', 94 | 'http://www.sat.gob.mx/servicioparcialconstruccion#1.0' 95 | => 'http://www.sat.gob.mx/sitio_internet/cfd/servicioparcialconstruccion/servicioparcialconstruccion.xsd', 96 | 'http://www.sat.gob.mx/renovacionysustitucionvehiculos#1.0' 97 | => 'http://www.sat.gob.mx/sitio_internet/' 98 | . 'cfd/renovacionysustitucionvehiculos/renovacionysustitucionvehiculos.xsd', 99 | 'http://www.sat.gob.mx/certificadodestruccion#1.0' 100 | => 'http://www.sat.gob.mx/sitio_internet/cfd/certificadodestruccion/certificadodedestruccion.xsd', 101 | 'http://www.sat.gob.mx/arteantiguedades#1.0' 102 | => 'http://www.sat.gob.mx/sitio_internet/cfd/arteantiguedades/obrasarteantiguedades.xsd', 103 | 'http://www.sat.gob.mx/ine#1.1' 104 | => 'http://www.sat.gob.mx/sitio_internet/cfd/ine/ine11.xsd', 105 | 'http://www.sat.gob.mx/ine#1.0' 106 | => 'http://www.sat.gob.mx/sitio_internet/cfd/ine/ine10.xsd', 107 | 'http://www.sat.gob.mx/ComercioExterior11#1.1' 108 | => 'http://www.sat.gob.mx/sitio_internet/cfd/ComercioExterior11/ComercioExterior11.xsd', 109 | 'http://www.sat.gob.mx/ComercioExterior20#2.0' 110 | => 'http://www.sat.gob.mx/sitio_internet/cfd/ComercioExterior20/ComercioExterior20.xsd', 111 | 'http://www.sat.gob.mx/ComercioExterior#1.0' 112 | => 'http://www.sat.gob.mx/sitio_internet/cfd/ComercioExterior/ComercioExterior10.xsd', 113 | 'http://www.sat.gob.mx/Pagos#1.0' 114 | => 'http://www.sat.gob.mx/sitio_internet/cfd/Pagos/Pagos10.xsd', 115 | 'http://www.sat.gob.mx/Pagos20#2.0' 116 | => 'http://www.sat.gob.mx/sitio_internet/cfd/Pagos/Pagos20.xsd', 117 | 'http://www.sat.gob.mx/GastosHidrocarburos10#1.0' 118 | => 'http://www.sat.gob.mx/sitio_internet/cfd/GastosHidrocarburos10/GastosHidrocarburos10.xsd', 119 | 'http://www.sat.gob.mx/IngresosHidrocarburos10#1.0' 120 | => 'http://www.sat.gob.mx/sitio_internet/cfd/IngresosHidrocarburos10/IngresosHidrocarburos.xsd', 121 | 'http://www.sat.gob.mx/iedu#1.0' 122 | => 'http://www.sat.gob.mx/sitio_internet/cfd/iedu/iedu.xsd', 123 | 'http://www.sat.gob.mx/ventavehiculos#1.1' 124 | => 'http://www.sat.gob.mx/sitio_internet/cfd/ventavehiculos/ventavehiculos11.xsd', 125 | 'http://www.sat.gob.mx/ventavehiculos#1.0' 126 | => 'http://www.sat.gob.mx/sitio_internet/cfd/ventavehiculos/ventavehiculos.xsd', 127 | 'http://www.sat.gob.mx/terceros#1.1' 128 | => 'http://www.sat.gob.mx/sitio_internet/cfd/terceros/terceros11.xsd', 129 | 'http://www.sat.gob.mx/acreditamiento#1.0' 130 | => 'http://www.sat.gob.mx/sitio_internet/cfd/acreditamiento/AcreditamientoIEPS10.xsd', 131 | 'http://www.sat.gob.mx/CartaPorte#1.0' 132 | => 'http://www.sat.gob.mx/sitio_internet/cfd/CartaPorte/CartaPorte.xsd', 133 | 'http://www.sat.gob.mx/CartaPorte20#2.0' 134 | => 'http://www.sat.gob.mx/sitio_internet/cfd/CartaPorte/CartaPorte20.xsd', 135 | 'http://www.sat.gob.mx/CartaPorte30#3.0' 136 | => 'http://www.sat.gob.mx/sitio_internet/cfd/CartaPorte/CartaPorte30.xsd', 137 | 'http://www.sat.gob.mx/CartaPorte31#3.1' 138 | => 'http://www.sat.gob.mx/sitio_internet/cfd/CartaPorte/CartaPorte31.xsd', 139 | 'http://www.sat.gob.mx/esquemas/retencionpago/1/arrendamientoenfideicomiso#1.0' 140 | => 'http://www.sat.gob.mx/esquemas/retencionpago/1/arrendamientoenfideicomiso/arrendamientoenfideicomiso.xsd', 141 | 'http://www.sat.gob.mx/esquemas/retencionpago/1/dividendos#1.0' 142 | => 'http://www.sat.gob.mx/esquemas/retencionpago/1/dividendos/dividendos.xsd', 143 | 'http://www.sat.gob.mx/esquemas/retencionpago/1/enajenaciondeacciones#1.0' 144 | => 'http://www.sat.gob.mx/esquemas/retencionpago/1/enajenaciondeacciones/enajenaciondeacciones.xsd', 145 | 'http://www.sat.gob.mx/esquemas/retencionpago/1/pagosaextranjeros#1.0' 146 | => 'http://www.sat.gob.mx/esquemas/retencionpago/1/pagosaextranjeros/pagosaextranjeros.xsd', 147 | 'http://www.sat.gob.mx/esquemas/retencionpago/1/fideicomisonoempresarial#1.0' 148 | => 'http://www.sat.gob.mx/esquemas/retencionpago/1/fideicomisonoempresarial/fideicomisonoempresarial.xsd', 149 | 'http://www.sat.gob.mx/esquemas/retencionpago/1/intereses#1.0' 150 | => 'http://www.sat.gob.mx/esquemas/retencionpago/1/intereses/intereses.xsd', 151 | 'http://www.sat.gob.mx/esquemas/retencionpago/1/intereseshipotecarios#1.0' 152 | => 'http://www.sat.gob.mx/esquemas/retencionpago/1/intereseshipotecarios/intereseshipotecarios.xsd', 153 | 'http://www.sat.gob.mx/esquemas/retencionpago/1/operacionesconderivados#1.0' 154 | => 'http://www.sat.gob.mx/esquemas/retencionpago/1/operacionesconderivados/operacionesconderivados.xsd', 155 | 'http://www.sat.gob.mx/esquemas/retencionpago/1/planesderetiro11#1.1' 156 | => 'http://www.sat.gob.mx/esquemas/retencionpago/1/planesderetiro11/planesderetiro11.xsd', 157 | 'http://www.sat.gob.mx/esquemas/retencionpago/1/planesderetiro#1.0' 158 | => 'http://www.sat.gob.mx/esquemas/retencionpago/1/planesderetiro/planesderetiro.xsd', 159 | 'http://www.sat.gob.mx/esquemas/retencionpago/1/premios#1.0' 160 | => 'http://www.sat.gob.mx/esquemas/retencionpago/1/premios/premios.xsd', 161 | 'http://www.sat.gob.mx/esquemas/retencionpago/1/sectorfinanciero#1.0' 162 | => 'http://www.sat.gob.mx/esquemas/retencionpago/1/sectorfinanciero/sectorfinanciero.xsd', 163 | 'http://www.sat.gob.mx/esquemas/retencionpago/1/PlataformasTecnologicas10#1.0' 164 | => 'http://www.sat.gob.mx/esquemas/retencionpago/1/' 165 | . 'PlataformasTecnologicas10/ServiciosPlataformasTecnologicas10.xsd', 166 | ]; 167 | 168 | public function clean(DOMDocument $document): void 169 | { 170 | $xpath = CfdiXPath::createFromDocument($document); 171 | $schemaLocationAttributes = $xpath->querySchemaLocations(); 172 | foreach ($schemaLocationAttributes as $schemaLocationAttribute) { 173 | $this->cleanNodeAttribute($document, $schemaLocationAttribute); 174 | } 175 | } 176 | 177 | private function cleanNodeAttribute(DOMDocument $document, DOMAttr $attribute): void 178 | { 179 | $schemaLocation = SchemaLocation::createFromValue((string) $attribute->nodeValue); 180 | foreach ($schemaLocation->getPairs() as $namespace => $location) { 181 | $version = $this->obtainVersionOfNamespace($document, $namespace); 182 | $location = $this->obtainLocationForNamespaceVersion($namespace, $version, $location); 183 | $schemaLocation->setPair($namespace, $location); 184 | } 185 | $attribute->nodeValue = $schemaLocation->asValue(); 186 | } 187 | 188 | private function obtainVersionOfNamespace(DOMDocument $document, string $namespace): string 189 | { 190 | return $this->obtainAttributeValueFromFirstNodeOfNamespace($document, $namespace, 'Version') 191 | ?: $this->obtainAttributeValueFromFirstNodeOfNamespace($document, $namespace, 'version'); 192 | } 193 | 194 | private function obtainAttributeValueFromFirstNodeOfNamespace( 195 | DOMDocument $document, 196 | string $namespace, 197 | string $attributeName 198 | ): string { 199 | $xpath = new DOMXPath($document); 200 | $xpath->registerNamespace('q', $namespace); 201 | 202 | $nodes = $xpath->query("//q:*[@$attributeName]", null, false); 203 | if (false === $nodes || 0 === $nodes->length) { 204 | return ''; 205 | } 206 | 207 | // @phpstan-ignore-next-line PHPStan is fine, it just reports an impossible scenario considering the query 208 | return $nodes->item(0)->attributes->getNamedItem($attributeName)->nodeValue; 209 | } 210 | 211 | private function obtainLocationForNamespaceVersion(string $namespace, string $version, string $default): string 212 | { 213 | return self::KNOWN_NAMESPACES[$namespace . '#' . $version] ?? $default; 214 | } 215 | 216 | /** 217 | * Pairs of key-value of namespace and version to XSD locations 218 | * Key: namespace#version 219 | * Value: location 220 | * @return array 221 | */ 222 | public static function getKnownNamespaces(): array 223 | { 224 | return self::KNOWN_NAMESPACES; 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/XmlStringCleanerInterface.php: -------------------------------------------------------------------------------- 1 | cleaners = $cleaners; 15 | } 16 | 17 | public static function createDefault(): self 18 | { 19 | return new self( 20 | new XmlStringCleaners\RemoveNonXmlStrings(), 21 | new XmlStringCleaners\SplitXmlDeclarationFromDocument(), 22 | new XmlStringCleaners\AppendXmlDeclaration(), 23 | new XmlStringCleaners\XmlNsSchemaLocation(), 24 | ); 25 | } 26 | 27 | public function clean(string $xml): string 28 | { 29 | foreach ($this->cleaners as $cleaner) { 30 | $xml = $cleaner->clean($xml); 31 | } 32 | return $xml; 33 | } 34 | 35 | public function withOutCleaners(ExcludeList $excludeList): self 36 | { 37 | $cleaners = $excludeList->filterObjects(...$this->cleaners); 38 | return new self(...$cleaners); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/XmlStringCleaners/AppendXmlDeclaration.php: -------------------------------------------------------------------------------- 1 | ' . "\n" . $xml; 15 | } 16 | return $xml; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/XmlStringCleaners/RemoveDuplicatedCfdi3Namespace.php: -------------------------------------------------------------------------------- 1 | '); 19 | if (false === $posLastGreaterThan) { 20 | return ''; 21 | } 22 | 23 | $length = $posLastGreaterThan - $posFirstLessThan + 1; 24 | if ($length <= 0) { 25 | return ''; 26 | } 27 | 28 | return substr($xml, $posFirstLessThan, $length); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/XmlStringCleaners/SplitXmlDeclarationFromDocument.php: -------------------------------------------------------------------------------- 1 | )([\s]*?)<#m', "\$1\n<", $xml) ?: ''; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/XmlStringCleaners/XmlNsSchemaLocation.php: -------------------------------------------------------------------------------- 1 | \s|.)*?>/u', $xml, $matches); 19 | /** @var array $parts */ 20 | $parts = preg_split('/<(?>\s|.)*?>/u', $xml); 21 | 22 | $buffer = [$parts[0]]; 23 | foreach ($matches[0] as $index => $match) { 24 | $buffer[] = $this->cleanTagContent($match); 25 | $buffer[] = $parts[$index + 1]; 26 | } 27 | 28 | return implode('', $buffer); 29 | } 30 | 31 | private function cleanTagContent(string $content): string 32 | { 33 | if (! str_contains($content, 'xmlns:schemaLocation="')) { 34 | // nothing to do 35 | return $content; 36 | } 37 | 38 | if (! str_contains($content, 'xsi:schemaLocation="')) { 39 | // safely replace to "xsi:schemaLocation" 40 | return preg_replace('/(\s)xmlns:schemaLocation="/', '$1xsi:schemaLocation="', $content) ?? ''; 41 | } 42 | 43 | // remove xmlns:schemaLocation attribute 44 | return preg_replace('/(\s)*xmlns:schemaLocation="(.|\s)*?"/', '', $content) ?? ''; 45 | } 46 | } 47 | --------------------------------------------------------------------------------