From 8cb18c119e89b9fa58932cbaf37c7234efce5a63 Mon Sep 17 00:00:00 2001 From: mendozagit Date: Sun, 11 Oct 2026 21:20:00 -0600 Subject: [PATCH] =?UTF-8?q?fiscalapi-csf=201.0.0:=20lector=20de=20la=20Con?= =?UTF-8?q?stancia=20de=20Situaci=C3=B3n=20Fiscal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extraído del parser de fapi-front (profile/csf): mismos límites (.pdf, 10 KB–5 MB, %PDF-, ≤ 6 páginas), mensajes y campos; salida ESM + CJS con tipos, pdfjs-dist como peer (^6.3.289), PdfJsLike inyectable. - Etiquetas y catálogo c_RegimenFiscal comparados en forma canónica (sin acentos, minúsculas, espacios colapsados) sin alterar los valores; errores con code tipado. - TS estricto y ESLint strictTypeChecked sin any/unknown/as/!. - CI (lint, typecheck, test, build, pack) y publicación manual solo desde main con NPM_TOKEN de secrets, --provenance e --ignore-scripts. - 499 pruebas; npm link probado (ESM, CJS, tipos); 6 CSF locales idénticas al original (no versionadas). --- .github/workflows/ci.yml | 59 + .github/workflows/publish.yml | 91 + .gitignore | 15 + LICENSE.txt | 373 ++++ README.md | 118 ++ eslint.config.mjs | 46 + package-lock.json | 2817 ++++++++++++++++++++++++++ package.json | 72 + scripts/write-dist-package-json.mjs | 6 + src/canonical-text.ts | 185 ++ src/csf-labels.ts | 46 + src/csf-limits.ts | 40 + src/csf-parser.ts | 202 ++ src/csf-pdf-reader.ts | 12 + src/csf-regimes-section.ts | 63 + src/csf-tax-regimes.ts | 72 + src/csf-text.ts | 47 + src/csf-validation-error.ts | 42 + src/csf.models.ts | 53 + src/index.ts | 23 + src/pdf-text-extractor.ts | 120 ++ src/pdf-text-lines.ts | 49 + src/required-group.ts | 10 + test/csf-label-variations.test.ts | 173 ++ test/csf-parser-rules.test.ts | 115 ++ test/csf-parser.test.ts | 106 + test/csf-pdf-reader.test.ts | 212 ++ test/fixtures/fake-pdfjs.ts | 46 + test/fixtures/synthetic-csf-lines.ts | 75 + test/fixtures/synthetic-csf-pdf.ts | 141 ++ test/pdf-text-lines.test.ts | 39 + tsconfig.base.json | 30 + tsconfig.cjs.json | 8 + tsconfig.esm.json | 8 + tsconfig.json | 11 + vitest.config.mts | 8 + 36 files changed, 5533 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 eslint.config.mjs create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/write-dist-package-json.mjs create mode 100644 src/canonical-text.ts create mode 100644 src/csf-labels.ts create mode 100644 src/csf-limits.ts create mode 100644 src/csf-parser.ts create mode 100644 src/csf-pdf-reader.ts create mode 100644 src/csf-regimes-section.ts create mode 100644 src/csf-tax-regimes.ts create mode 100644 src/csf-text.ts create mode 100644 src/csf-validation-error.ts create mode 100644 src/csf.models.ts create mode 100644 src/index.ts create mode 100644 src/pdf-text-extractor.ts create mode 100644 src/pdf-text-lines.ts create mode 100644 src/required-group.ts create mode 100644 test/csf-label-variations.test.ts create mode 100644 test/csf-parser-rules.test.ts create mode 100644 test/csf-parser.test.ts create mode 100644 test/csf-pdf-reader.test.ts create mode 100644 test/fixtures/fake-pdfjs.ts create mode 100644 test/fixtures/synthetic-csf-lines.ts create mode 100644 test/fixtures/synthetic-csf-pdf.ts create mode 100644 test/pdf-text-lines.test.ts create mode 100644 tsconfig.base.json create mode 100644 tsconfig.cjs.json create mode 100644 tsconfig.esm.json create mode 100644 tsconfig.json create mode 100644 vitest.config.mts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5ff2465 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + # 22.13 es el mínimo de pdfjs-dist (engines); 24 es la versión con la que se publica. + node-version: ['22', '24'] + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: ${{ matrix.node-version }} + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + - name: Build + run: npm run build + + # El paquete solo debe llevar dist, README.md, LICENSE.txt y package.json. + - name: Verify package contents + run: | + npm pack --dry-run --json > pack.json + unexpected=$(node -e " + const [pack] = require('./pack.json'); + const allowed = /^(dist\/.+|README\.md|LICENSE\.txt|package\.json)$/; + console.log(pack.files.map(f => f.path).filter(p => !allowed.test(p)).join('\n')); + ") + if [ -n "$unexpected" ]; then + echo "::error::Archivos inesperados en el paquete:" + echo "$unexpected" + exit 1 + fi + echo "Contenido del paquete verificado." diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..5dc0668 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,91 @@ +name: NPM Publish + +on: + workflow_dispatch: + +# id-token: write habilita Trusted Publishing (OIDC) de npm, que publica sin token. +# Mientras no se dé de alta el "trusted publisher" del paquete en npmjs.com, npm usa +# el NPM_TOKEN del .npmrc que escribe setup-node. +permissions: + contents: read + id-token: write + +jobs: + build-and-publish: + # Solo se publica desde main, aunque el workflow se dispare a mano desde otra rama. + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + # Node 24 trae npm >= 11.5.1, requisito de Trusted Publishing. + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + registry-url: 'https://registry.npmjs.org/' + + - name: Get package version + run: echo "VERSION=$(node -p "require('./package.json').version")" >> "$GITHUB_ENV" + + # El registry de npm responde 404 (no 401) a cualquier PUT sin credencial válida. + # Sin esta verificación, un token vencido se manifiesta al final como un + # "E404 Not Found - PUT" que parece un problema del paquete y no de autenticación. + - name: Verify npm credentials + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: | + if [ -z "$NODE_AUTH_TOKEN" ]; then + echo "::error::El secret NPM_TOKEN no existe o está vacío." + exit 1 + fi + if ! npm whoami; then + echo "::error::NPM_TOKEN inválido o expirado. Los granular access tokens de npm caducan: genera uno nuevo con permiso Read and write sobre el paquete y actualiza el secret NPM_TOKEN." + exit 1 + fi + # Informativo: si el token es válido pero no cubre este paquete, el publish + # también devolvería 404. Aquí se ve el permiso real que trae el token. + npm access list packages || echo "No fue posible listar los permisos del token (no bloquea el publish)." + + - name: Verify version is not already published + run: | + if npm view "$(node -p "require('./package.json').name")@${VERSION}" version > /dev/null 2>&1; then + echo "::error::La versión ${VERSION} ya está publicada en npm. Incrementa la versión en package.json." + exit 1 + fi + echo "La versión ${VERSION} está libre." + + # Sin scripts de instalación de dependencias: nada de terceros se ejecuta con el token disponible. + - name: Install dependencies + run: npm ci --ignore-scripts + + # Las mismas compuertas que CI: no se publica nada que no pase lint, tipos y pruebas. + - name: Lint, typecheck and test + run: | + npm run lint + npm run typecheck + npm test + + - name: Build package + run: npm run build + + - name: Verify build artifacts + run: | + for file in dist/cjs/index.js dist/cjs/index.d.ts dist/cjs/package.json \ + dist/esm/index.js dist/esm/index.d.ts dist/esm/package.json; do + if [ ! -f "$file" ]; then + echo "::error::Falta $file en el build." + exit 1 + fi + done + echo "Artefactos del build verificados." + + - name: Publish to NPM + # El build ya corrió arriba; --ignore-scripts evita que prepack lo repita con el token en el entorno. + # --provenance firma el origen del paquete (requiere id-token: write). + run: npm publish --access public --provenance --ignore-scripts + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..90b60b6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +# Dependencias +node_modules/ +npm-debug.log* + +# Salida del build y de las pruebas +dist/ +coverage/ +*.tsbuildinfo +*.tgz + +# Entorno local +.env +.env.* +.idea/ +.vscode/ diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..a612ad9 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/README.md b/README.md index b9ea143..b150927 100644 --- a/README.md +++ b/README.md @@ -1 +1,119 @@ # fiscalapi-csf + +Lee la Constancia de Situación Fiscal (CSF) del SAT. + +Valida que un PDF sea una CSF auténtica en su estructura y extrae RFC, tipo de persona, nombre o razón social, CURP, código postal, regímenes fiscales vigentes (con su clave `c_RegimenFiscal`), idCIF, fecha de emisión y estatus en el padrón. + +El PDF se procesa **en el navegador**: el archivo nunca sale del equipo del usuario. Tu aplicación decide qué campos confirmados envía a su servidor. + +## Instalación + +```bash +npm i fiscalapi-csf pdfjs-dist +``` + +`pdfjs-dist` (`^6.3.289`) es una dependencia *peer*: el paquete usa la copia que instala tu aplicación. + +## Uso en el navegador + +```ts +import { parseCsf, readCsfPdfLines } from 'fiscalapi-csf'; + +async function importarConstancia(file: File) { + try { + const csf = parseCsf(await readCsfPdfLines(file)); + console.log(csf.rfc, csf.legalName, csf.zipCode, csf.activeRegimes); + } catch (error) { + // Por nombre y no solo con instanceof: puede haber una copia ESM y otra CJS del paquete. + if (error instanceof Error && error.name === 'CsfValidationError') { + mostrarError(error.message); // mensaje en español, apto para el usuario + } else { + throw error; + } + } +} +``` + +`readCsfPdfLines` valida el archivo, carga `pdfjs-dist` bajo demanda (no engrosa el bundle inicial) y devuelve el texto en renglones; `parseCsf` valida la constancia y extrae los datos. + +## Configuración del worker de pdf.js + +pdf.js lee el PDF en un *web worker*. Solo tu bundler sabe dónde publicar ese archivo, así que configúralo **una vez, antes de la primera lectura**, con una de estas dos opciones de `GlobalWorkerOptions`: + +**`workerPort`** (un `Worker` que crea tu aplicación). Por ejemplo, con Angular, crea `pdf.worker.ts`: + +```ts +// pdf.worker.ts: el bundler lo empaqueta como un chunk .js +import 'pdfjs-dist/build/pdf.worker.min.mjs'; +``` + +y antes de leer la constancia: + +```ts +const { GlobalWorkerOptions } = await import('pdfjs-dist'); +GlobalWorkerOptions.workerPort ??= new Worker(new URL('./pdf.worker', import.meta.url), { type: 'module' }); +``` + +**`workerSrc`** (la URL donde tu aplicación publica `pdfjs-dist/build/pdf.worker.min.mjs`): + +```ts +GlobalWorkerOptions.workerSrc = '/assets/pdf.worker.min.mjs'; +``` + +Sin worker configurado, pdf.js lanza `Error: No "GlobalWorkerOptions.workerSrc" specified.`; no es un `CsfValidationError` porque es un error de integración, no del archivo del usuario. + +## Uso en Node.js + +La build principal de `pdfjs-dist` es para navegadores. En Node.js (por ejemplo, en pruebas) usa la build *legacy* con `extractPdfLines`, que recibe pdf.js inyectado: + +```ts +import { readFile } from 'node:fs/promises'; +import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'; +import { extractPdfLines, parseCsf, readCsfFileBytes } from 'fiscalapi-csf'; + +const file = new File([await readFile('constancia.pdf')], 'constancia.pdf'); +const csf = parseCsf(await extractPdfLines(pdfjs, await readCsfFileBytes(file))); +``` + +El paquete se publica como ES module y CommonJS, con tipos para ambos. + +## API + +| Exportación | Descripción | +|---|---| +| `readCsfPdfLines(file: File): Promise` | Valida el archivo y devuelve los renglones de texto del PDF (navegador). | +| `parseCsf(lines: readonly string[]): CsfData` | Valida la constancia y devuelve sus datos. | +| `readCsfFileBytes(file: File): Promise` | Solo las validaciones del archivo: extensión, tamaño y firma `%PDF-`. | +| `extractPdfLines(pdfjs: PdfJsLike, data: Uint8Array): Promise` | Extrae los renglones con la build de pdf.js que recibas. | +| `CsfValidationError` | Error de validación: `message` para el usuario y `code` (`CsfValidationErrorCode`) para tu código. Distínguelo por `error.code` o `error.name` (`'CsfValidationError'`), no solo con `instanceof`: con una copia ESM y otra CJS del paquete hay dos clases. | +| `CsfData` | `CsfIndividualData \| CsfLegalEntityData`, distinguidos por `personType` (`'PF'` o `'PM'`). | +| `CsfTaxRegime` | `{ code, description, startDate }` de cada régimen vigente. | +| `PdfJsLike` | Subconjunto de pdf.js que usa el lector; el módulo de `pdfjs-dist` lo cumple tal cual. | + +`CsfData` contiene: + +| Campo | Contenido | +|---|---| +| `rfc` | RFC en mayúsculas, consistente en todo el documento y con la cadena original del sello. | +| `personType` | `'PF'` (RFC de 13 caracteres) o `'PM'` (12). | +| `legalName` | Nombre completo o denominación/razón social, en mayúsculas. | +| `curp` | CURP (solo persona física; en persona moral es `undefined`). | +| `zipCode` | Código postal del domicilio fiscal. | +| `activeRegimes` | Regímenes vigentes a la fecha de emisión con clave SAT reconocida. | +| `unrecognizedRegimes` | Regímenes vigentes cuya descripción no corresponde a una clave del catálogo. | +| `idCif` | idCIF de la constancia. | +| `issueDate` | Fecha de emisión (`aaaa-mm-dd`) tomada de la cadena original del sello. | +| `status` | `'ACTIVO'` o `'REACTIVADO'`. | + +## Límites y validaciones + +- Extensión `.pdf`, tamaño entre 10 KB y 5 MB y firma `%PDF-`, antes de abrir el archivo. +- De 1 a 6 páginas; solo se extrae el texto: no se renderiza, no se cargan fuentes ni se ejecuta XFA. +- PDF con contraseña, dañado o escaneado (sin texto): se rechaza con su propio `code`. +- Secciones obligatorias de la CSF, idCIF, cadena original del sello, RFC consistente, estatus activo, CURP coherente con el RFC (persona física), nombre igual al de la cédula, código postal y al menos un régimen vigente. +- Las etiquetas del documento se reconocen sin importar acentos, mayúsculas ni espacios (incluidos los no separables); los valores se devuelven como vienen en el PDF. +- Por lo mismo, un texto con forma de etiqueta dentro de un valor (por ejemplo `CURP :` o `Código Postal :` en una razón social) se toma como etiqueta: corta el valor o hace que se rechace la constancia. + +## Licencia + +[MPL-2.0](LICENSE.txt) © FISCAL API S DE R.L DE C.V diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..6ccffef --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,46 @@ +import js from '@eslint/js'; +import { defineConfig } from 'eslint/config'; +import tseslint from 'typescript-eslint'; + +// Tipado estricto: además de strictTypeChecked y stylisticTypeChecked, se prohíben las construcciones que esconden tipos +// (`unknown` escrito, aserciones `as` salvo `as const`, `x`, `!` y cualquier comentario @ts-*). +const STRICT_TYPING_RESTRICTIONS = [ + { selector: 'TSAnyKeyword', message: 'No uses any: declara un tipo con nombre.' }, + { selector: 'TSUnknownKeyword', message: 'No escribas unknown: declara un tipo con nombre y un type guard.' }, + { + selector: 'TSAsExpression:not([typeAnnotation.type="TSTypeReference"][typeAnnotation.typeName.name="const"])', + message: 'No uses aserciones as (salvo as const): narra el tipo con un type guard.', + }, + { selector: 'TSTypeAssertion', message: 'No uses aserciones : narra el tipo con un type guard.' }, + { selector: 'TSNonNullExpression', message: 'No uses !: maneja el caso null o undefined.' }, +]; + +export default defineConfig( + { ignores: ['dist/', 'coverage/', 'node_modules/'] }, + { + files: ['**/*.ts', '**/*.mts'], + extends: [js.configs.recommended, tseslint.configs.strictTypeChecked, tseslint.configs.stylisticTypeChecked], + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/ban-ts-comment': ['error', { + 'ts-check': true, + 'ts-expect-error': true, + 'ts-ignore': true, + 'ts-nocheck': true, + }], + 'no-restricted-syntax': ['error', ...STRICT_TYPING_RESTRICTIONS], + }, + }, + { + files: ['**/*.mjs'], + extends: [js.configs.recommended], + languageOptions: { + globals: { URL: 'readonly' }, + }, + }, +); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..b3a5776 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2817 @@ +{ + "name": "fiscalapi-csf", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fiscalapi-csf", + "version": "1.0.0", + "license": "MPL-2.0", + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.19.2", + "eslint": "^10.12.0", + "pdfjs-dist": "^6.4.299", + "rimraf": "^6.1.3", + "typescript": "~6.0.3", + "typescript-eslint": "^8.71.1", + "vitest": "^5.0.3" + }, + "engines": { + "node": ">=22.13.0" + }, + "peerDependencies": { + "pdfjs-dist": "^6.3.289" + } + }, + "node_modules/@cacheable/memory": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz", + "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/utils": "^2.5.0", + "@keyv/bigmap": "^1.3.1", + "hookified": "^1.15.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@cacheable/utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz", + "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.5.1", + "keyv": "^5.6.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.7.0.tgz", + "integrity": "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz", + "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@keyv/bigmap": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz", + "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hashery": "^1.4.0", + "hookified": "^1.15.0" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "keyv": "^5.6.0" + } + }, + "node_modules/@keyv/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.11.tgz", + "integrity": "sha512-vt5tog1cr+QKadIddGe+3LrcX0EmjEd0izF717Uwf92mBmgYfq2XRmL2r4bZU40spB69exxhrBn9I91HYEr/7w==", + "dev": true, + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.11", + "@napi-rs/canvas-darwin-arm64": "1.0.11", + "@napi-rs/canvas-darwin-x64": "1.0.11", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.11", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.11", + "@napi-rs/canvas-linux-arm64-musl": "1.0.11", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.11", + "@napi-rs/canvas-linux-x64-gnu": "1.0.11", + "@napi-rs/canvas-linux-x64-musl": "1.0.11", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.11", + "@napi-rs/canvas-win32-x64-msvc": "1.0.11" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.11.tgz", + "integrity": "sha512-dtJS4FQEfH9OwSYLtMrEnj7T8DzBbJc/97LI/OkiWOYpW/iW5t6R/gcxVrUXS8l45gCVpwMufgucRe/pe0ISqA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.11.tgz", + "integrity": "sha512-J/JylWoJTG8OQqZaiqly4IODyAxNofUlZo0wtSBJtHk6AdymbdIkPynAM1JvQaZpPaG5h+msk9QuK8y4i1ooqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.11.tgz", + "integrity": "sha512-nlecCk3xy06eAGC7krZtzNDm9UNTHKuHQYK8ZYegdjzgSL8anGt/eLJZm86OWaNgZkwQwvDqfSmG8Nqe6MPZNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.11.tgz", + "integrity": "sha512-DKImmG+sTPolE0kGPsYPBxTtOjb3INALp32O2lW2CfSr0bAzSEKKuu+GrQbtilClyoPOahUErS3OrznFDraoWA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.11.tgz", + "integrity": "sha512-55sykACGENj5j1rhj+81AO8JbR9HYgGjRZ7/H+pNr4VE1DvINKYNMTcutk8ZmWxk276jAQvUKJ3+Kdj9GXU3lQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.11.tgz", + "integrity": "sha512-6Jz6jys17yXydO+WdJgu7JhoB4K7/w9sWHByzzTmLjQghUx3TDMWvLv+UcCdnWhg4oprET67EMymnrETdeGNAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.11.tgz", + "integrity": "sha512-lf2XeEebWiUYVOVEjLXxF7dfXrBBSEOBn9AqBh39VYXrPt5cDqI+1t58IFSjn8npqkg5jIOs8TQbjKABbFW1gQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.11.tgz", + "integrity": "sha512-iDapw7nFSE5tsmQxcH2+KotJhyfVPmHd6vUOQN/Em+8WNbkZJwJ/rDowlJMFnMl0SYSDSfgKCNUGi6Sn5++Smg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.11.tgz", + "integrity": "sha512-26q26AyITo3CGZX4HWFIMgBMA1lu3YN07gxzBm4tW6Hglq/OVyTP50kDrmWuKybCX6dh3m8Ly+c7YhI+sqCgCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.11.tgz", + "integrity": "sha512-cUO96IdsfvIj2S+2PNcDSkTcLhjPj8pIOEWRV1Kj70MOP4wnE9zSk7N4C1EKHlFGRcgdR5AkbnBZj0dfHK9nbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.11.tgz", + "integrity": "sha512-DFYp/ZQRdKunI/LPc0UkRXG1vlNjJKOJf8O8Ctb8+wm2Nt2ELg9Eamc31D2VoakkfGo8L+z0Iejz6WtFbQwyGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", + "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.1.tgz", + "integrity": "sha512-nNlBf6HuotqMgZvSkWD5hNdemiBIxQ2NmrlgfcUIeZRs+BbakjscCQRq/Hb74EFVnFiSWWdL12JruqwBy3ERDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/type-utils": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.71.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.12", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz", + "integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.1.tgz", + "integrity": "sha512-oM/5sAqz/l1v/zYK1qTOPrn2+JIrBGC1V6uNYucRO5gh6eT9vWJ/RhPbGE59byvZEUQ4VP6XkLBaS5Jti5jpvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.1.tgz", + "integrity": "sha512-Oijc9RUsohHjncg6iGi7gCtjnEjYt9FWv9u6ygK8uKcHK5eLjLAkqERQ9BaytbpceOFdk6kifPCS/zAZE+A99g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.71.1", + "@typescript-eslint/types": "^8.71.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.1.tgz", + "integrity": "sha512-1YUDZXdTnXLlob03SFHBipdyMBsDVm7zoSz0ytTJ39dk5J1TgsTK03VlR/dLXG/RLLUrPcYbD4rp23BmHxHP0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.1.tgz", + "integrity": "sha512-oTvkml5SxXhgg+WWKhod5qWQICDXtOB+/Hi9VdPKFAANJ2b7YOgMffgvzXuqog7R6A/JtrzRf128dsxummyWhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.1.tgz", + "integrity": "sha512-kH3t3jZCYv2PZSBEWPKTariLrCaoF1CLoPitYFBJOhAGEwWnSRAtGEifrfrpBa4K9JlwWNZotgA5w3/wLSk/FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.1.tgz", + "integrity": "sha512-ybYnPTUwg3VQQOFEiBPLs8Jxl1ry2vXdn1kVKguomBbvxpetdu3MKTNDqFNUrlG8ylP9MpZosmXMFYlGQHk1oQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.1.tgz", + "integrity": "sha512-pSKSEK1JJpHs6KiKVtABCE4iJK5d7FLFzDXQNBQ2oRY7LEd3UwOt3TZemTgu9syTIf56dJWlTn/pdmrO9oKUXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.71.1", + "@typescript-eslint/tsconfig-utils": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/visitor-keys": "8.71.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.1.tgz", + "integrity": "sha512-CBjT6gfAz3DW2j9Q4moFnqt208Aq+506sVGXfpRcyAeqn0EpayL0mjdWoCO2t3RC7GewNrFxdeehuEaPg6DMzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.71.1", + "@typescript-eslint/types": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.1.tgz", + "integrity": "sha512-0GxiUGqMU0qXJt58cBsiG3DP8lUmBaFo2vChtUX+CT7PnR3v/VGLLrzVP4K9r1lNiZXjHYefMBbPunplr09v6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.71.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz", + "integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.3", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz", + "integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/cacheable": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz", + "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cacheable/memory": "^2.2.0", + "@cacheable/utils": "^2.5.0", + "hookified": "^1.15.0", + "keyv": "^5.6.0", + "qified": "^0.10.1" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.12.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.12.0.tgz", + "integrity": "sha512-npHjrHb2o4ehH3mE+YuxABR+0gyb0aVWCIolgJDArwCiHEIsonBm3ZKjel5b0U5+a+MbEvIyRCGCzD3eKKK2NA==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.7.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.3", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "11.1.5 || >11.1.6 <12", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "11.1.5", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz", + "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^6.1.23" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "6.1.23", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz", + "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cacheable": "^2.5.0", + "flatted": "^3.4.2", + "hookified": "^1.15.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hashery": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", + "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^1.15.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/hookified": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", + "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", + "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@keyv/serialize": "^1.1.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "peer": true, + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/pdfjs-dist": { + "version": "6.4.299", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.4.299.tgz", + "integrity": "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.10" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC", + "peer": true + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qified": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz", + "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hookified": "^2.1.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/qified/node_modules/hookified": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz", + "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==", + "dev": true, + "license": "MIT" + }, + "node_modules/rimraf": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", + "integrity": "sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "glob": "^13.0.3", + "package-json-from-dist": "^1.0.1" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "engines": { + "node": "20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.71.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.1.tgz", + "integrity": "sha512-oiNVPC3/NbV1FpaHU07l8O6ynqG9v4PAiW2WvTrAmZdR0f2HI/gE668ElMLCK7Xbsu5k2fK5C4fXNiq05j7/nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.71.1", + "@typescript-eslint/parser": "8.71.1", + "@typescript-eslint/typescript-estree": "8.71.1", + "@typescript-eslint/utils": "8.71.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz", + "integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.3", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "^6.1.4", + "tinyexec": "^1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.3", + "@vitest/browser-preview": "5.0.3", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.3", + "@vitest/coverage-v8": "5.0.3", + "@vitest/ui": "5.0.3", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..06f6d5e --- /dev/null +++ b/package.json @@ -0,0 +1,72 @@ +{ + "name": "fiscalapi-csf", + "version": "1.0.0", + "description": "Lector de la Constancia de Situación Fiscal (CSF) del SAT en el navegador: valida el PDF y extrae RFC, nombre, código postal y regímenes vigentes.", + "main": "dist/cjs/index.js", + "module": "dist/esm/index.js", + "types": "dist/cjs/index.d.ts", + "exports": { + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.ts", + "default": "./dist/cjs/index.js" + } + } + }, + "sideEffects": false, + "files": [ + "dist", + "README.md", + "LICENSE.txt" + ], + "scripts": { + "clean": "rimraf dist", + "build": "npm run clean && npm run build:cjs && npm run build:esm && npm run build:package-json", + "build:cjs": "tsc -p tsconfig.cjs.json", + "build:esm": "tsc -p tsconfig.esm.json", + "build:package-json": "node scripts/write-dist-package-json.mjs", + "typecheck": "tsc -p tsconfig.json", + "lint": "eslint .", + "test": "vitest run", + "prepack": "npm run build" + }, + "keywords": [ + "fiscalapi", + "csf", + "constancia de situación fiscal", + "sat", + "rfc", + "mexico", + "pdf" + ], + "author": "FISCAL API S DE R.L DE C.V", + "license": "MPL-2.0", + "engines": { + "node": ">=22.13.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/FiscalAPI/fiscalapi-csf.git" + }, + "homepage": "https://github.com/FiscalAPI/fiscalapi-csf#readme", + "bugs": { + "url": "https://github.com/FiscalAPI/fiscalapi-csf/issues" + }, + "peerDependencies": { + "pdfjs-dist": "^6.3.289" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@types/node": "^24.19.2", + "eslint": "^10.12.0", + "pdfjs-dist": "^6.4.299", + "rimraf": "^6.1.3", + "typescript": "~6.0.3", + "typescript-eslint": "^8.71.1", + "vitest": "^5.0.3" + } +} diff --git a/scripts/write-dist-package-json.mjs b/scripts/write-dist-package-json.mjs new file mode 100644 index 0000000..b8b9953 --- /dev/null +++ b/scripts/write-dist-package-json.mjs @@ -0,0 +1,6 @@ +// Marca el formato de cada salida: los .js de dist/cjs son CommonJS y los de dist/esm son ES modules. +// Sin estos package.json, Node interpretaría ambos según el package.json raíz (que no declara "type"). +import { writeFileSync } from 'node:fs'; + +writeFileSync(new URL('../dist/cjs/package.json', import.meta.url), `${JSON.stringify({ type: 'commonjs' })}\n`); +writeFileSync(new URL('../dist/esm/package.json', import.meta.url), `${JSON.stringify({ type: 'module' })}\n`); diff --git a/src/canonical-text.ts b/src/canonical-text.ts new file mode 100644 index 0000000..cf157fb --- /dev/null +++ b/src/canonical-text.ts @@ -0,0 +1,185 @@ +import { requiredGroup } from './required-group.js'; + +/* + * Forma canónica para comparar etiquetas sin depender de cambios cosméticos que el SAT hace al PDF entre + * versiones: acentos agregados o quitados, mayúsculas y espacios. Solo sirve para LOCALIZAR etiquetas; + * los valores se leen siempre del texto original con el mapa de posiciones de CanonicalText. + */ + +const LOCALE = 'es-MX'; +const COMBINING_MARK = /\p{M}/gu; +/** `\s` cubre espacios no separables (U+00A0, U+202F), tipográficos (U+2000–U+200A), U+3000, U+FEFF y saltos; se agrega U+200B. */ +const WHITESPACE = /^[\s\u200b]$/u; +const LINE_BREAK = /^[\n\r\u2028\u2029]$/u; +const COLON = ':'; +const SPACE = ' '; +const NEW_LINE = '\n'; +const REGEXP_SPECIAL = /[.*+?^${}()|[\]\\/]/g; + +/** Posiciones en el texto original de un tramo de la forma canónica. */ +export interface SourceSpan { + readonly start: number; + readonly end: number; +} + +interface PendingSeparator { + separator: typeof SPACE | typeof NEW_LINE; + readonly start: number; + end: number; +} + +/** + * Carácter sin acentos ni diéresis y en minúsculas. + * NFD separa la letra base de sus marcas (é → e + U+0301); al quitar las marcas, "Régimen" y "Regimen" son iguales. + * NFD (y no NFKD) no convierte dígitos ni letras de compatibilidad, así que los valores numéricos no cambian. + * Las minúsculas con un locale fijo hacen el resultado determinista sin importar el idioma del navegador. + */ +function foldCharacter(char: string): string { + return char.normalize('NFD').replace(COMBINING_MARK, '').toLocaleLowerCase(LOCALE); +} + +/** Construye la forma canónica y, por cada carácter de ella, el tramo del texto original que lo produjo. */ +class CanonicalTextBuilder { + private value = ''; + private readonly starts: number[] = []; + private readonly ends: number[] = []; + private pending: PendingSeparator | null = null; + + constructor(private readonly source: string) {} + + build(): CanonicalText { + let index = 0; + for (const char of this.source) { + const end = index + char.length; + if (WHITESPACE.test(char)) this.addWhitespace(char, index, end); + else this.addCharacter(foldCharacter(char), index, end); + index = end; + } + // Un separador pendiente al final se descarta: equivale a recortar el texto. + return new CanonicalText(this.source, this.value, this.starts, this.ends); + } + + /** Cada racha de espacios cuenta como un solo separador; si incluye un salto, se conserva como salto de línea. */ + private addWhitespace(char: string, start: number, end: number): void { + if (this.value.length === 0) return; // espacios al inicio: se recortan + const separator = LINE_BREAK.test(char) ? NEW_LINE : SPACE; + if (this.pending === null) { + this.pending = { separator, start, end }; + return; + } + this.pending.end = end; + if (separator === NEW_LINE) this.pending.separator = NEW_LINE; + } + + private addCharacter(folded: string, start: number, end: number): void { + if (folded.length === 0) { + // Marca suelta (acento ya descompuesto): pertenece al carácter anterior en el texto original. + if (this.pending === null && this.ends.length > 0) this.ends[this.ends.length - 1] = end; + return; + } + this.flushPending(folded); + this.push(folded, start, end); + } + + /** "RFC :" equivale a "RFC:": el espacio antes de los dos puntos no distingue etiquetas. */ + private flushPending(next: string): void { + const pending = this.pending; + if (pending === null) return; + this.pending = null; + if (next === COLON && pending.separator === SPACE) return; + this.push(pending.separator, pending.start, pending.end); + } + + private push(text: string, start: number, end: number): void { + this.value += text; + // Una posición por unidad UTF-16 agregada, para que los índices de la regex sobre `value` coincidan. + this.starts.push(...Array(text.length).fill(start)); + this.ends.push(...Array(text.length).fill(end)); + } +} + +/** Una coincidencia buscada en la forma canónica, con acceso a su texto original. */ +export class CanonicalMatch { + constructor(private readonly text: CanonicalText, private readonly indices: RegExpIndicesArray) {} + + /** Tramo del texto original que ocupa toda la coincidencia. */ + get sourceSpan(): SourceSpan { + return this.requiredSpan(0); + } + + /** Texto ORIGINAL del grupo de captura (sin canonizar), o undefined si el grupo no participó. */ + group(index: number): string | undefined { + const span = this.spanOf(index); + return span === undefined ? undefined : this.text.sourceText(span); + } + + /** Texto ORIGINAL de un grupo obligatorio del patrón (ver requiredGroup). */ + requiredGroup(index: number): string { + return this.text.sourceText(this.requiredSpan(index)); + } + + private spanOf(index: number): SourceSpan | undefined { + const range = this.indices[index]; + return range === undefined ? undefined : this.text.sourceSpan(range[0], range[1]); + } + + private requiredSpan(index: number): SourceSpan { + const [start, end] = requiredGroup(this.indices, index); + return this.text.sourceSpan(start, end); + } +} + +/** Texto original junto con su forma canónica y el mapa de posiciones entre ambos. */ +export class CanonicalText { + constructor( + readonly source: string, + readonly value: string, + private readonly starts: readonly number[], + private readonly ends: readonly number[], + ) {} + + static of(source: string): CanonicalText { + return new CanonicalTextBuilder(source).build(); + } + + /** Primera coincidencia del patrón (escrito sobre la forma canónica y con la bandera `d`). */ + find(pattern: RegExp): CanonicalMatch | null { + const match = pattern.exec(this.value); + return match === null ? null : new CanonicalMatch(this, indicesOf(match)); + } + + /** Todas las coincidencias del patrón (con las banderas `g` y `d`). */ + findAll(pattern: RegExp): CanonicalMatch[] { + return [...this.value.matchAll(pattern)].map(match => new CanonicalMatch(this, indicesOf(match))); + } + + /** Tramo del texto original que corresponde al tramo [start, end) de la forma canónica. */ + sourceSpan(start: number, end: number): SourceSpan { + const sourceStart = this.starts[start] ?? this.source.length; + const sourceEnd = end > start ? this.ends[end - 1] ?? this.source.length : sourceStart; + return { start: sourceStart, end: sourceEnd }; + } + + sourceText(span: SourceSpan): string { + return this.source.slice(span.start, span.end); + } +} + +function indicesOf(match: RegExpExecArray | RegExpMatchArray): RegExpIndicesArray { + if (match.indices === undefined) throw new Error('Los patrones sobre la forma canónica deben usar la bandera d.'); + return match.indices; +} + +/** + * Forma canónica de una etiqueta: sin acentos, en minúsculas, con cada racha de espacios (incluidos los no + * separables y los saltos) reducida a un espacio, recortada y sin espacio antes de los dos puntos. + * Los dos puntos se conservan: separan la etiqueta de su valor y evitan confundir palabras del valor con etiquetas. + */ +export function canonicalLabel(text: string): string { + return CanonicalText.of(text).value.replaceAll(NEW_LINE, SPACE); +} + +/** Patrón regex de una etiqueta sobre la forma canónica: entre palabras admite espacio o salto de línea. */ +export function labelPattern(label: string): string { + return canonicalLabel(label).replace(REGEXP_SPECIAL, '\\$&').replaceAll(SPACE, '[ \\n]'); +} diff --git a/src/csf-labels.ts b/src/csf-labels.ts new file mode 100644 index 0000000..e3945b0 --- /dev/null +++ b/src/csf-labels.ts @@ -0,0 +1,46 @@ +/* + * Etiquetas impresas en la CSF, declaradas una sola vez. Se escriben como las imprime el SAT; las comparaciones + * se hacen siempre sobre su forma canónica (ver canonical-text.ts), así que acentos, mayúsculas y espacios + * distintos en el PDF no las rompen. + */ + +/** Etiquetas de campo que pueden compartir renglón; cada una delimita el valor de la etiqueta anterior. */ +export const CSF_FIELD_LABELS = [ + 'RFC:', 'CURP:', 'Nombre (s):', 'Primer Apellido:', 'Segundo Apellido:', 'Denominación/Razón Social:', + 'Régimen Capital:', 'Nombre Comercial:', 'Fecha inicio de operaciones:', 'Estatus en el padrón:', + 'Fecha de último cambio de estado:', 'Código Postal:', 'Tipo de Vialidad:', 'Nombre de Vialidad:', + 'Número Exterior:', 'Número Interior:', 'Nombre de la Colonia:', 'Nombre de la Localidad:', + 'Nombre del Municipio o Demarcación Territorial:', 'Nombre de la Entidad Federativa:', 'Entre Calle:', 'Y Calle:', +] as const; + +export type CsfFieldLabel = (typeof CSF_FIELD_LABELS)[number]; + +/** Títulos de sección y textos fijos del documento. */ +export const CsfSectionLabel = { + IdentificationCard: 'CÉDULA DE IDENTIFICACIÓN FISCAL', + Constancia: 'CONSTANCIA DE SITUACIÓN FISCAL', + PlaceAndDateOfIssue: 'Lugar y Fecha de Emisión', + TaxpayerIdentification: 'Datos de Identificación del Contribuyente:', + RegisteredAddress: 'Datos del domicilio registrado', + Regimes: 'Regímenes:', + RegimesTableHeader: 'Régimen Fecha Inicio Fecha Fin', + Obligations: 'Obligaciones:', + PersonalDataNotice: 'Sus datos personales', + SealChain: 'Cadena Original Sello:', + DigitalSeal: 'Sello Digital:', + IdCif: 'idCIF:', + Page: 'Página', + PageOf: 'de', +} as const; + +/** Secciones que toda CSF contiene; si falta alguna, el archivo no es una constancia. */ +export const CSF_REQUIRED_SECTIONS = [ + CsfSectionLabel.IdentificationCard, + CsfSectionLabel.Constancia, + CsfSectionLabel.PlaceAndDateOfIssue, + CsfSectionLabel.TaxpayerIdentification, + CsfSectionLabel.RegisteredAddress, + CsfSectionLabel.Regimes, + CsfSectionLabel.SealChain, + CsfSectionLabel.DigitalSeal, +] as const; diff --git a/src/csf-limits.ts b/src/csf-limits.ts new file mode 100644 index 0000000..f6e3a72 --- /dev/null +++ b/src/csf-limits.ts @@ -0,0 +1,40 @@ +import { CsfValidationError, CsfValidationErrorCode } from './csf-validation-error.js'; + +/* + * Límites que debe cumplir el archivo antes de interpretarlo. Una CSF del SAT es un PDF de pocas páginas + * y de decenas o cientos de KB; fuera de esos límites no es una constancia y no vale la pena abrirla. + */ + +const MAX_FILE_BYTES = 5 * 1024 * 1024; +const MIN_FILE_BYTES = 10 * 1024; +const MAX_PAGES = 6; +const PDF_EXTENSION = /\.pdf$/i; +const PDF_SIGNATURE = '%PDF-'; + +/** Validaciones del archivo que no requieren abrir el PDF: extensión, tamaño y firma %PDF-. */ +export async function readCsfFileBytes(file: File): Promise { + if (!PDF_EXTENSION.test(file.name)) { + throw new CsfValidationError(CsfValidationErrorCode.NotPdfFile, 'Selecciona un archivo PDF.'); + } + if (file.size > MAX_FILE_BYTES) { + throw new CsfValidationError(CsfValidationErrorCode.FileTooLarge, 'El archivo excede el tamaño máximo de 5 MB.'); + } + if (file.size < MIN_FILE_BYTES) { + throw new CsfValidationError(CsfValidationErrorCode.FileTooSmall, + 'El archivo es demasiado pequeño para ser una constancia de situación fiscal.'); + } + + const bytes = new Uint8Array(await file.arrayBuffer()); + const signature = String.fromCharCode(...bytes.subarray(0, PDF_SIGNATURE.length)); + if (signature !== PDF_SIGNATURE) { + throw new CsfValidationError(CsfValidationErrorCode.InvalidPdfSignature, 'El archivo no es un PDF válido.'); + } + return bytes; +} + +export function assertCsfPageCount(pageCount: number): void { + if (pageCount < 1 || pageCount > MAX_PAGES) { + throw new CsfValidationError(CsfValidationErrorCode.InvalidPageCount, + 'El número de páginas no corresponde a una constancia de situación fiscal.'); + } +} diff --git a/src/csf-parser.ts b/src/csf-parser.ts new file mode 100644 index 0000000..115b6ef --- /dev/null +++ b/src/csf-parser.ts @@ -0,0 +1,202 @@ +import { CanonicalText, labelPattern } from './canonical-text.js'; +import { CSF_REQUIRED_SECTIONS, CsfSectionLabel, type CsfFieldLabel } from './csf-labels.js'; +import { + CSF_ACTIVE_STATUSES, + CsfPersonType, + type CsfData, + type CsfIndividualData, + type CsfLegalEntityData, + type CsfTaxpayerStatus, +} from './csf.models.js'; +import { readRegimesSection, type CsfRegimesSection } from './csf-regimes-section.js'; +import { collapseWhitespace, containsLabel, fieldValue, labelIndex, normalizeLine, removePageMarkers } from './csf-text.js'; +import { CsfValidationError, CsfValidationErrorCode } from './csf-validation-error.js'; + +/* + * Parser determinista de la Constancia de Situación Fiscal (CSF) del SAT. + * Recibe las líneas de texto del PDF (ver csf-pdf-reader.ts) y valida estructura y consistencia + * antes de devolver los datos. Cualquier desviación lanza CsfValidationError, en el orden de los pasos. + * Las etiquetas se localizan en la forma canónica del texto; los valores se leen del texto original. + */ + +// Mismo patrón que ValidationService.rfcPattern de fapi-front. +const RFC_PATTERN = /^[A-Z&Ñ]{3,4}[0-9]{2}(0[1-9]|1[0-2])(0[1-9]|[12][0-9]|3[01])[A-Z0-9]{2}[0-9A]$/; +const RFC_TOKEN = /(? + | (Pick & { readonly curp: undefined }); + +function fail(code: CsfValidationErrorCode, message: string): never { + throw new CsfValidationError(code, message); +} + +/** Renglones normalizados y sin el pie "Página [n] de [m]". */ +function cleanLines(lines: readonly string[]): string[] { + return lines + .map(normalizeLine) + .map(line => removePageMarkers(line).trim()) + .filter(line => line.length > 0); +} + +function assertCsfStructure(text: CanonicalText): void { + if (!CSF_REQUIRED_SECTIONS.every(section => containsLabel(text, section))) { + fail(CsfValidationErrorCode.NotACsf, 'El archivo no es una Constancia de Situación Fiscal del SAT (faltan secciones obligatorias).'); + } +} + +function readIdCif(text: CanonicalText): string { + return text.find(ID_CIF)?.group(1) ?? fail(CsfValidationErrorCode.MissingIdCif, 'La constancia no contiene un idCIF válido.'); +} + +function readSealChain(text: CanonicalText): SealChain { + const chain = text.find(SEAL_CHAIN) + ?? fail(CsfValidationErrorCode.MissingSealChain, 'La constancia no contiene una cadena original de sello válida.'); + return { + issueDate: `${chain.requiredGroup(1)}-${chain.requiredGroup(2)}-${chain.requiredGroup(3)}`, + rfc: chain.requiredGroup(4).toUpperCase(), + }; +} + +/** RFC con patrón válido y consistente en encabezado, cédula, datos de identificación y cadena original. */ +function readRfc(text: CanonicalText, sealRfc: string): string { + const rfc = fieldValue(text, 'RFC:')?.toUpperCase() ?? fail(CsfValidationErrorCode.MissingRfc, 'La constancia no contiene el RFC.'); + if (!RFC_PATTERN.test(rfc)) fail(CsfValidationErrorCode.InvalidRfc, `El RFC de la constancia (${rfc}) no tiene un formato válido.`); + if (rfc !== sealRfc) fail(CsfValidationErrorCode.RfcSealMismatch, 'El RFC de la constancia no coincide con el de la cadena original del sello.'); + + // La cadena original y el sello van después del aviso de datos personales y no cuentan como apariciones. + const sealStart = labelIndex(text, CsfSectionLabel.PersonalDataNotice); + const content = sealStart > 0 ? text.source.slice(0, sealStart) : text.source; + const rfcTokens = content.match(RFC_TOKEN) ?? []; + if (rfcTokens.length < MIN_RFC_OCCURRENCES || rfcTokens.some(token => token !== rfc)) { + fail(CsfValidationErrorCode.InconsistentRfc, 'El RFC no es consistente en todo el documento.'); + } + return rfc; +} + +function isActiveStatus(status: string): status is CsfTaxpayerStatus { + return CSF_ACTIVE_STATUSES.some(active => active === status); +} + +function readActiveStatus(text: CanonicalText): CsfTaxpayerStatus { + const status = fieldValue(text, 'Estatus en el padrón:')?.toUpperCase() + ?? fail(CsfValidationErrorCode.MissingStatus, 'La constancia no contiene el estatus en el padrón.'); + if (!isActiveStatus(status)) { + fail(CsfValidationErrorCode.InactiveTaxpayer, + `El contribuyente tiene estatus "${status}" en el padrón del SAT; solo se aceptan constancias con estatus activo.`); + } + return status; +} + +function readIndividualIdentity(text: CanonicalText, rfc: string): CsfIdentity { + const curp = fieldValue(text, 'CURP:')?.toUpperCase() + ?? fail(CsfValidationErrorCode.MissingCurp, 'La constancia de persona física no contiene la CURP.'); + if (!CURP_PATTERN.test(curp)) fail(CsfValidationErrorCode.InvalidCurp, `La CURP de la constancia (${curp}) no tiene un formato válido.`); + if (curp.substring(BIRTH_DATE_START, BIRTH_DATE_END) !== rfc.substring(BIRTH_DATE_START, BIRTH_DATE_END)) { + fail(CsfValidationErrorCode.CurpRfcDateMismatch, 'La fecha de la CURP no coincide con la del RFC.'); + } + + const names = fieldValue(text, 'Nombre (s):'); + const firstLastName = fieldValue(text, 'Primer Apellido:'); + const secondLastName = fieldValue(text, 'Segundo Apellido:'); + if (!names || firstLastName === null || secondLastName === null) { + fail(CsfValidationErrorCode.MissingFullName, 'La constancia no contiene el nombre completo del contribuyente.'); + } + // El segundo apellido puede venir vacío. + const fullName = [names, firstLastName, secondLastName].filter(part => part.length > 0).join(' '); + return { personType: CsfPersonType.Individual, legalName: collapseWhitespace(fullName).toUpperCase(), curp }; +} + +function readLegalEntityIdentity(text: CanonicalText): CsfIdentity { + if (fieldValue(text, 'CURP:')) fail(CsfValidationErrorCode.UnexpectedCurp, 'La constancia de persona moral no debe contener CURP.'); + const legalName = collapseWhitespace(fieldValue(text, 'Denominación/Razón Social:') ?? ''); + if (!legalName) fail(CsfValidationErrorCode.MissingLegalName, 'La constancia no contiene la denominación o razón social.'); + return { personType: CsfPersonType.LegalEntity, legalName: legalName.toUpperCase(), curp: undefined }; +} + +function readIdentity(text: CanonicalText, rfc: string): CsfIdentity { + return rfc.length === INDIVIDUAL_RFC_LENGTH ? readIndividualIdentity(text, rfc) : readLegalEntityIdentity(text); +} + +/** El nombre debe coincidir con el de la cédula de identificación (encabezado). */ +function assertNameMatchesHeader(text: CanonicalText, legalName: string): void { + const header = text.source.slice(0, labelIndex(text, CsfSectionLabel.TaxpayerIdentification)); + if (!collapseWhitespace(header).toUpperCase().includes(legalName)) { + fail(CsfValidationErrorCode.LegalNameMismatch, 'El nombre o razón social no coincide con el de la cédula de identificación fiscal.'); + } +} + +function readZipCode(text: CanonicalText): string { + return text.find(ZIP_CODE)?.group(1) ?? fail(CsfValidationErrorCode.MissingZipCode, 'La constancia no contiene un código postal válido.'); +} + +function readActiveRegimes(lines: readonly string[], issueDate: string): CsfRegimesSection { + const regimes = readRegimesSection(lines, issueDate); + if (regimes.active.length === 0) { + if (regimes.unrecognized.length > 0) { + fail(CsfValidationErrorCode.UnrecognizedRegime, + `No se reconoce el régimen fiscal de la constancia: ${regimes.unrecognized.join('; ')}.`); + } + fail(CsfValidationErrorCode.NoActiveRegimes, 'La constancia no contiene regímenes fiscales vigentes.'); + } + return regimes; +} + +/** + * Valida y extrae los datos fiscales de las líneas de texto de una CSF. + * @param lines Líneas del PDF en orden de lectura (todas las páginas). + */ +export function parseCsf(lines: readonly string[]): CsfData { + const clean = cleanLines(lines); + const text = CanonicalText.of(clean.join('\n')); + + assertCsfStructure(text); + const idCif = readIdCif(text); + const seal = readSealChain(text); + const rfc = readRfc(text, seal.rfc); + const status = readActiveStatus(text); + const identity = readIdentity(text, rfc); + assertNameMatchesHeader(text, identity.legalName); + const zipCode = readZipCode(text); + const regimes = readActiveRegimes(clean, seal.issueDate); + + return { + rfc, + ...identity, + zipCode, + activeRegimes: regimes.active, + unrecognizedRegimes: regimes.unrecognized, + idCif, + issueDate: seal.issueDate, + status, + }; +} diff --git a/src/csf-pdf-reader.ts b/src/csf-pdf-reader.ts new file mode 100644 index 0000000..3b56061 --- /dev/null +++ b/src/csf-pdf-reader.ts @@ -0,0 +1,12 @@ +import { readCsfFileBytes } from './csf-limits.js'; +import { extractPdfLines } from './pdf-text-extractor.js'; + +/** + * Valida el archivo y devuelve los renglones de texto del PDF. Carga pdfjs-dist bajo demanda para no engrosar + * el bundle inicial del consumidor. El worker de pdfjs lo configura el consumidor antes de llamar + * (GlobalWorkerOptions.workerPort o workerSrc), porque solo su bundler sabe dónde servirlo (ver README). + */ +export async function readCsfPdfLines(file: File): Promise { + const data = await readCsfFileBytes(file); + return extractPdfLines(await import('pdfjs-dist'), data); +} diff --git a/src/csf-regimes-section.ts b/src/csf-regimes-section.ts new file mode 100644 index 0000000..a9ba2ab --- /dev/null +++ b/src/csf-regimes-section.ts @@ -0,0 +1,63 @@ +import { canonicalLabel } from './canonical-text.js'; +import { CsfSectionLabel } from './csf-labels.js'; +import type { CsfTaxRegime } from './csf.models.js'; +import { collapseWhitespace } from './csf-text.js'; +import { mapTaxRegimeCode } from './csf-tax-regimes.js'; +import { requiredGroup } from './required-group.js'; +import { CsfValidationError, CsfValidationErrorCode } from './csf-validation-error.js'; + +/** Regímenes vigentes de la constancia, separados según se reconozca o no su clave SAT. */ +export interface CsfRegimesSection { + readonly active: readonly CsfTaxRegime[]; + readonly unrecognized: readonly string[]; +} + +const SECTION_START = canonicalLabel(CsfSectionLabel.Regimes); +const SECTION_ENDS = [CsfSectionLabel.Obligations, CsfSectionLabel.PersonalDataNotice, CsfSectionLabel.SealChain].map(canonicalLabel); +const TABLE_HEADER = canonicalLabel(CsfSectionLabel.RegimesTableHeader); +/** "descripción dd/mm/aaaa [dd/mm/aaaa]": la descripción puede ocupar varias líneas y la fecha fin es opcional. */ +const REGIME_ROW = /(.+?)\s+(\d{2}\/\d{2}\/\d{4})(?:\s+(\d{2}\/\d{2}\/\d{4}))?(?=\s|$)/g; + +function isSectionEnd(canonicalLine: string): boolean { + return SECTION_ENDS.some(end => canonicalLine.startsWith(end)); +} + +/** Renglones de la sección "Regímenes:" sin el encabezado de la tabla, unidos en un solo texto (original). */ +function sectionBody(lines: readonly string[]): string { + const start = lines.findIndex(line => canonicalLabel(line).startsWith(SECTION_START)); + if (start < 0) { + throw new CsfValidationError(CsfValidationErrorCode.MissingRegimesSection, 'La constancia no contiene la sección de regímenes.'); + } + + const body: string[] = []; + for (const line of lines.slice(start + 1)) { + const canonicalLine = canonicalLabel(line); + if (isSectionEnd(canonicalLine)) break; + if (canonicalLine !== TABLE_HEADER) body.push(line); + } + return body.join(' '); +} + +/** dd/mm/aaaa → aaaa-mm-dd, para comparar fechas como texto. */ +function toIsoDate(ddmmyyyy: string): string { + return ddmmyyyy.split('/').reverse().join('-'); +} + +/** + * Extrae los regímenes de la sección "Regímenes:". Un régimen es vigente si no tiene fecha fin o esta es + * posterior a la fecha de emisión; un régimen con clave repetida se conserva solo la primera vez. + */ +export function readRegimesSection(lines: readonly string[], issueDate: string): CsfRegimesSection { + const active: CsfTaxRegime[] = []; + const unrecognized: string[] = []; + for (const row of sectionBody(lines).matchAll(REGIME_ROW)) { + const description = collapseWhitespace(requiredGroup(row, 1)).replace(/\.$/, ''); + const endDate = row[3]; + if (endDate !== undefined && toIsoDate(endDate) <= issueDate) continue; + + const code = mapTaxRegimeCode(description); + if (code === null) unrecognized.push(description); + else if (!active.some(regime => regime.code === code)) active.push({ code, description, startDate: requiredGroup(row, 2) }); + } + return { active, unrecognized }; +} diff --git a/src/csf-tax-regimes.ts b/src/csf-tax-regimes.ts new file mode 100644 index 0000000..7b5ca05 --- /dev/null +++ b/src/csf-tax-regimes.ts @@ -0,0 +1,72 @@ +import { canonicalLabel } from './canonical-text.js'; + +/** + * Mapeo determinista de la descripción del régimen (como la imprime la CSF) a la clave SAT c_RegimenFiscal. + * Las claves son las del catálogo del backend (SatTaxRegimeSeeder / SatCatalogs.InvoiceTaxRegimeCodes). + * La comparación es exacta tras normalizar (ver normalizeRegimeDescription); no hay coincidencia difusa. + */ +const REGIME_CATALOG = [ + { code: '601', descriptions: ['general de ley personas morales'] }, + { code: '603', descriptions: ['personas morales con fines no lucrativos'] }, + { code: '605', descriptions: ['sueldos y salarios e ingresos asimilados a salarios'] }, + { code: '606', descriptions: ['arrendamiento'] }, + { code: '607', descriptions: ['enajenacion o adquisicion de bienes'] }, + { code: '608', descriptions: ['demas ingresos'] }, + { + code: '610', + descriptions: [ + 'residentes en el extranjero sin establecimiento permanente en mexico', + 'residentes en extranjero sin establecimiento permanente en mexico', + ], + }, + { code: '611', descriptions: ['ingresos por dividendos socios y accionistas', 'ingresos por dividendos'] }, + { + code: '612', + descriptions: [ + 'personas fisicas con actividades empresariales y profesionales', + 'actividades empresariales y profesionales', + ], + }, + { code: '614', descriptions: ['ingresos por intereses'] }, + { code: '615', descriptions: ['ingresos por obtencion de premios'] }, + { code: '616', descriptions: ['sin obligaciones fiscales'] }, + { code: '620', descriptions: ['sociedades cooperativas de produccion que optan por diferir sus ingresos'] }, + { code: '621', descriptions: ['incorporacion fiscal'] }, + { code: '622', descriptions: ['actividades agricolas ganaderas silvicolas y pesqueras'] }, + { code: '623', descriptions: ['opcional para grupos de sociedades'] }, + { code: '624', descriptions: ['coordinados'] }, + { + code: '625', + descriptions: [ + 'actividades empresariales con ingresos a traves de plataformas tecnologicas', + 'actividades empresariales a traves de plataformas tecnologicas', + ], + }, + { code: '626', descriptions: ['simplificado de confianza'] }, +] as const; + +/** Clave SAT c_RegimenFiscal que el lector reconoce. */ +export type SatTaxRegimeCode = (typeof REGIME_CATALOG)[number]['code']; + +/** + * Forma canónica de la descripción (ver canonicalLabel), sin puntuación y sin el prefijo "Régimen de (las|los)", + * que la CSF imprime y el catálogo no. Se aplica igual a la descripción del PDF y a las del catálogo. + */ +export function normalizeRegimeDescription(description: string): string { + return canonicalLabel(description) + .replace(/[^a-z0-9 ]+/g, ' ') + .replace(/ +/g, ' ') + .trim() + .replace(/^regimen (de (las |los |la |el )?|del )?/, ''); +} + +/** Índice descripción canónica → clave, calculado una sola vez al cargar el módulo. */ +const CODE_BY_DESCRIPTION = new Map( + REGIME_CATALOG.flatMap(({ code, descriptions }) => + descriptions.map(description => [normalizeRegimeDescription(description), code] as const)) +); + +/** Clave SAT del régimen, o null si la descripción no se reconoce. */ +export function mapTaxRegimeCode(description: string): SatTaxRegimeCode | null { + return CODE_BY_DESCRIPTION.get(normalizeRegimeDescription(description)) ?? null; +} diff --git a/src/csf-text.ts b/src/csf-text.ts new file mode 100644 index 0000000..3017ec3 --- /dev/null +++ b/src/csf-text.ts @@ -0,0 +1,47 @@ +import { CanonicalText, labelPattern } from './canonical-text.js'; +import { CSF_FIELD_LABELS, CsfSectionLabel, type CsfFieldLabel } from './csf-labels.js'; + +/* + * Consultas sobre el texto de la CSF. Las etiquetas se buscan en la forma canónica (tolerante a acentos, + * mayúsculas y espacios); los valores se devuelven tal como vienen en el texto original. + */ + +const LABEL_BOUNDARY = CSF_FIELD_LABELS.map(labelPattern).join('|'); +const PAGE_MARKER = new RegExp( + `${labelPattern(CsfSectionLabel.Page)} \\[\\d+\\] ${labelPattern(CsfSectionLabel.PageOf)} \\[\\d+\\]`, 'gd'); + +/** NFC y espacios homogéneos: el PDF puede traer espacio duro (U+00A0), espacios tipográficos (U+2000–U+200B) y tabuladores. */ +export function normalizeLine(line: string): string { + return line.normalize('NFC').replace(/[\u00a0\u2000-\u200b\t]/g, ' ').replace(/ {2,}/g, ' ').trim(); +} + +export function collapseWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +/** Quita el pie "Página [n] de [m]" del renglón; el resto del renglón queda como venía. */ +export function removePageMarkers(line: string): string { + let kept = ''; + let cursor = 0; + for (const { sourceSpan } of CanonicalText.of(line).findAll(PAGE_MARKER)) { + kept += line.slice(cursor, sourceSpan.start); + cursor = sourceSpan.end; + } + return kept + line.slice(cursor); +} + +/** Valor de una etiqueta en su mismo renglón, hasta la siguiente etiqueta conocida o el fin de línea. */ +export function fieldValue(text: CanonicalText, label: CsfFieldLabel): string | null { + // La etiqueta va al inicio del renglón o después de un espacio; el valor es el texto ORIGINAL que la sigue. + const pattern = new RegExp(`(?`; + * aquí se fija como números para leer las coordenadas sin perder el tipado. + */ +export interface PdfTextItem extends Omit { + readonly transform: readonly number[]; +} + +/** Contenido de texto de una página: fragmentos de texto y marcas de contenido (estas se descartan). */ +export interface PdfTextContent { + readonly items: readonly (PdfTextItem | TextMarkedContent)[]; +} + +export interface PdfPageLike { + getTextContent(): Promise; +} + +export interface PdfDocumentLike { + readonly numPages: number; + getPage(pageNumber: number): Promise; +} + +export interface PdfLoadingTaskLike { + readonly promise: Promise; + destroy(): Promise; +} + +/** + * Subconjunto de la API de pdfjs-dist que usa el lector. El módulo de pdfjs-dist lo cumple tal cual; + * existe para inyectar otra build (p. ej. la legacy de Node en pruebas). + */ +export interface PdfJsLike { + getDocument(params: DocumentInitParameters): PdfLoadingTaskLike; +} + +/** Matriz [a, b, c, d, e, f] de pdfjs: `d` es la escala vertical (tamaño de fuente); `e` y `f`, la posición. */ +type TextMatrix = readonly [number, number, number, number, number, number]; + +interface PlacedPdfTextItem extends PdfTextItem { + readonly transform: TextMatrix; +} + +const TEXT_MATRIX_LENGTH = 6; +/** Nombre que pdfjs da a la excepción de un PDF cifrado con contraseña. */ +const PASSWORD_EXCEPTION = 'PasswordException'; + +/** Solo lectura de texto: sin fuentes, sin red por rangos, sin XFA y deteniéndose ante el primer error. */ +const TEXT_ONLY_OPTIONS = { + disableFontFace: true, + disableAutoFetch: true, + disableStream: true, + disableRange: true, + stopAtErrors: true, + useSystemFonts: false, + enableXfa: false, +} as const satisfies DocumentInitParameters; + +function hasTextMatrix(transform: readonly number[]): transform is TextMatrix { + return transform.length === TEXT_MATRIX_LENGTH; +} + +/** Distingue un fragmento de texto con posición de una marca de contenido (que no trae `str`). */ +function isPlacedTextItem(item: PdfTextItem | TextMarkedContent): item is PlacedPdfTextItem { + return 'str' in item && hasTextMatrix(item.transform); +} + +function toPositionedText(item: PlacedPdfTextItem): PositionedText { + const [, , , scaleY, x, y] = item.transform; + return { text: item.str, x, y, width: item.width, fontSize: Math.abs(scaleY) || 1 }; +} + +/** Abre el documento; si falla, libera la tarea y traduce el error de pdfjs a un CsfValidationError. */ +async function openDocument(loadingTask: PdfLoadingTaskLike): Promise { + try { + return await loadingTask.promise; + } catch (error) { + await loadingTask.destroy(); + // Las excepciones de pdfjs heredan de Error y se distinguen por `name`. + throw error instanceof Error && error.name === PASSWORD_EXCEPTION + ? new CsfValidationError(CsfValidationErrorCode.PasswordProtected, + 'El PDF está protegido con contraseña; descarga la constancia original del SAT.') + : new CsfValidationError(CsfValidationErrorCode.UnreadablePdf, + 'No se pudo leer el PDF; el archivo está dañado o no es un PDF válido.'); + } +} + +async function readPageLines(pdf: PdfDocumentLike, pageNumber: number): Promise { + const page = await pdf.getPage(pageNumber); + const { items } = await page.getTextContent(); + return groupIntoLines(items.filter(isPlacedTextItem).map(toPositionedText)); +} + +/** + * Extrae el texto del PDF como renglones en orden de lectura, página por página. + * No renderiza, no ejecuta scripts ni carga fuentes; el documento se destruye siempre. + */ +export async function extractPdfLines(pdfjs: PdfJsLike, data: Uint8Array): Promise { + const loadingTask = pdfjs.getDocument({ data, ...TEXT_ONLY_OPTIONS }); + const pdf = await openDocument(loadingTask); + try { + assertCsfPageCount(pdf.numPages); + const lines: string[] = []; + for (let pageNumber = 1; pageNumber <= pdf.numPages; pageNumber++) { + lines.push(...await readPageLines(pdf, pageNumber)); + } + + if (lines.join('').trim().length === 0) { + throw new CsfValidationError(CsfValidationErrorCode.NoText, + 'El PDF no contiene texto (parece un escaneo o imagen); descarga la constancia original del SAT.'); + } + return lines; + } finally { + await loadingTask.destroy(); + } +} diff --git a/src/pdf-text-lines.ts b/src/pdf-text-lines.ts new file mode 100644 index 0000000..f35072e --- /dev/null +++ b/src/pdf-text-lines.ts @@ -0,0 +1,49 @@ +/** Fragmento de texto con su posición en la página, listo para agruparse en renglones. */ +export interface PositionedText { + readonly text: string; + readonly x: number; + readonly y: number; + readonly width: number; + readonly fontSize: number; +} + +interface TextRow { + readonly y: number; + readonly items: PositionedText[]; +} + +/** Tolerancia vertical (pt) para considerar dos fragmentos de texto en el mismo renglón. */ +const LINE_TOLERANCE = 2; +/** Hueco horizontal, relativo al tamaño de fuente, a partir del cual dos fragmentos llevan espacio entre sí. */ +const WORD_GAP_RATIO = 0.15; + +/** + * Agrupa los fragmentos por coordenada Y y los ordena por X, de modo que el resultado no depende del orden + * en que el generador del PDF escribió el texto. Devuelve los renglones de arriba hacia abajo. + */ +export function groupIntoLines(items: readonly PositionedText[]): string[] { + const rows: TextRow[] = []; + for (const item of items) { + if (item.text.length === 0) continue; + const row = rows.find(candidate => Math.abs(candidate.y - item.y) <= LINE_TOLERANCE); + if (row) row.items.push(item); + else rows.push({ y: item.y, items: [item] }); + } + + return rows + .sort((a, b) => b.y - a.y) + .map(row => joinRow(row.items)) + .filter(line => line.length > 0); +} + +function joinRow(items: PositionedText[]): string { + let line = ''; + let previousEnd: number | null = null; + for (const item of items.sort((a, b) => a.x - b.x)) { + // Fragmentos contiguos (mismo texto partido) se concatenan; separados por un hueco, llevan espacio. + if (previousEnd !== null && item.x - previousEnd > item.fontSize * WORD_GAP_RATIO) line += ' '; + line += item.text; + previousEnd = item.x + item.width; + } + return line.replace(/\s+/g, ' ').trim(); +} diff --git a/src/required-group.ts b/src/required-group.ts new file mode 100644 index 0000000..dae6b66 --- /dev/null +++ b/src/required-group.ts @@ -0,0 +1,10 @@ +/** + * Elemento obligatorio de un resultado de regex (un grupo de captura o su rango de índices). Si falta, el patrón + * está mal escrito (error de programación), no es un dato inválido de la constancia. El mensaje no incluye el + * texto analizado porque contiene datos personales del contribuyente. + */ +export function requiredGroup(groups: readonly (T | undefined)[], index: number): T { + const value = groups[index]; + if (value === undefined) throw new Error(`Un patrón de la CSF no capturó el grupo ${String(index)}.`); + return value; +} diff --git a/test/csf-label-variations.test.ts b/test/csf-label-variations.test.ts new file mode 100644 index 0000000..7a90f70 --- /dev/null +++ b/test/csf-label-variations.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; +import { canonicalLabel } from '../src/canonical-text.js'; +import { CSF_FIELD_LABELS, CsfSectionLabel } from '../src/csf-labels.js'; +import { parseCsf } from '../src/csf-parser.js'; +import { mapTaxRegimeCode } from '../src/csf-tax-regimes.js'; +import { pfLines, pmLines } from './fixtures/synthetic-csf-lines.js'; + +/* + * Resiliencia a cambios cosméticos del SAT en las etiquetas: acentos, mayúsculas y espacios. + * Los valores extraídos no deben cambiar. Texto sintético (ver fixtures); ningún dato real. + */ + +const NO_BREAK_SPACE = String.fromCodePoint(0x00a0); +const NARROW_NO_BREAK_SPACE = String.fromCodePoint(0x202f); +const EM_SPACE = String.fromCodePoint(0x2003); +const ACUTE = { a: 'á', e: 'é', i: 'í', o: 'ó', u: 'ú', A: 'Á', E: 'É', I: 'Í', O: 'Ó', U: 'Ú' } as const; + +type Variation = (label: string) => string; + +function isAccentableVowel(letter: string): letter is keyof typeof ACUTE { + return letter in ACUTE; +} + +function stripAccents(text: string): string { + return text.normalize('NFD').replace(/\p{M}/gu, '').normalize('NFC'); +} + +/** Acentúa la primera vocal que no lo tenga ("Primer" → "Prímer"). */ +function addAccent(text: string): string { + return text.replace(/[aeiouAEIOU](?!\p{M})/u, vowel => (isAccentableVowel(vowel) ? ACUTE[vowel] : vowel)); +} + +function titleCase(text: string): string { + return text.toLowerCase().replace(/(^|[\s(/])(\p{L})/gu, (_, separator: string, letter: string) => separator + letter.toUpperCase()); +} + +const VARIATIONS: Record = { + 'sin acentos': stripAccents, + 'con acento extra': addAccent, + 'MAYÚSCULAS': label => label.toUpperCase(), + 'minúsculas': label => label.toLowerCase(), + 'Título': titleCase, + 'espacios dobles': label => label.replaceAll(' ', ' '), + 'espacio no separable U+00A0': label => label.replaceAll(' ', NO_BREAK_SPACE), + 'espacio angosto no separable U+202F': label => label.replaceAll(' ', NARROW_NO_BREAK_SPACE), + 'espacios al inicio y al final': label => `${EM_SPACE}${label}${NO_BREAK_SPACE} `, + 'espacio antes de los dos puntos': label => label.replace(/:$/, ' :'), + 'combinada (sin acentos, Título y U+202F)': label => titleCase(stripAccents(label)).replaceAll(' ', NARROW_NO_BREAK_SPACE), +}; + +/** Etiquetas presentes en los renglones sintéticos de persona física y de persona moral. */ +const PF_LABELS = [ + 'CÉDULA DE IDENTIFICACIÓN FISCAL', 'CONSTANCIA DE SITUACIÓN FISCAL', 'Lugar y Fecha de Emisión', 'idCIF:', + 'Datos de Identificación del Contribuyente:', 'RFC:', 'CURP:', 'Nombre (s):', 'Primer Apellido:', 'Segundo Apellido:', + 'Fecha inicio de operaciones:', 'Estatus en el padrón:', 'Fecha de último cambio de estado:', 'Nombre Comercial:', + 'Datos del domicilio registrado', 'Código Postal:', 'Tipo de Vialidad:', 'Nombre de Vialidad:', 'Número Exterior:', + 'Página', 'Regímenes:', 'Régimen Fecha Inicio Fecha Fin', 'Sus datos personales', 'Cadena Original Sello:', 'Sello Digital:', +]; +const PM_LABELS = ['Denominación/Razón Social:', 'Régimen Capital:', 'Obligaciones:']; + +interface LabelCase { + readonly label: string; + readonly variation: string; + readonly vary: Variation; +} + +/** Combinaciones etiqueta × variación que sí cambian el texto (una variación sin efecto no prueba nada). */ +function labelCases(labels: readonly string[]): LabelCase[] { + return labels.flatMap(label => Object.entries(VARIATIONS) + .filter(([, vary]) => vary(label) !== label) + .map(([variation, vary]) => ({ label, variation, vary }))); +} + +function varyLabel(lines: readonly string[], { label, vary }: LabelCase): string[] { + return lines.map(line => line.replaceAll(label, vary(label))); +} + +describe('canonicalLabel', () => { + it.each([ + ['Régimen Capital:', 'regimen capital:'], + ['RÉGIMEN CAPITAL :', 'regimen capital:'], + [`${EM_SPACE}régimen${NARROW_NO_BREAK_SPACE}capital:${NO_BREAK_SPACE}`, 'regimen capital:'], + ['Estatus en el\npadrón:', 'estatus en el padron:'], + ['Denominación/Razón Social:', 'denominacion/razon social:'], + ])('%j → %j', (label, canonical) => { + expect(canonicalLabel(label)).toBe(canonical); + }); + + it('ninguna etiqueta colapsa con otra en su forma canónica', () => { + const labels = [...CSF_FIELD_LABELS, ...Object.values(CsfSectionLabel)]; + expect(new Set(labels.map(canonicalLabel)).size).toBe(labels.length); + }); +}); + +describe('parseCsf con variaciones cosméticas en las etiquetas', () => { + it.each(labelCases(PF_LABELS))('persona física: "$label" $variation', labelCase => { + expect(parseCsf(varyLabel(pfLines(), labelCase))).toStrictEqual(parseCsf(pfLines())); + }); + + it.each(labelCases(PM_LABELS))('persona moral: "$label" $variation', labelCase => { + expect(parseCsf(varyLabel(pmLines(), labelCase))).toStrictEqual(parseCsf(pmLines())); + }); + + it('quita el pie de página aunque cambie de forma y caiga dentro de la tabla de regímenes', () => { + const regimes = [ + 'Régimen de Sueldos y Salarios e Ingresos Asimilados a Salarios 29/03/2016', + `PAGINA${NARROW_NO_BREAK_SPACE}[2]${NARROW_NO_BREAK_SPACE}De [3]`, + 'Régimen Simplificado de Confianza 01/01/2022', + ]; + const csf = parseCsf(pfLines({ regimes })); + expect(csf.activeRegimes).toStrictEqual([ + { code: '605', description: 'Régimen de Sueldos y Salarios e Ingresos Asimilados a Salarios', startDate: '29/03/2016' }, + { code: '626', description: 'Régimen Simplificado de Confianza', startDate: '01/01/2022' }, + ]); + }); +}); + +describe('corte del valor en la siguiente etiqueta del mismo renglón', () => { + const NEXT_LABEL = 'Régimen Capital:'; + + /** Une la razón social y la etiqueta siguiente en un solo renglón, como en algunas versiones de la CSF. */ + function sharedLine(nextLabel: string): string[] { + return pmLines() + .filter(line => !line.startsWith(NEXT_LABEL)) + .map(line => (line.startsWith('Denominación/Razón Social:') ? `${line} ${nextLabel} SOCIEDAD CIVIL` : line)); + } + + // Mata: quitar la etiqueta siguiente como límite del valor (la razón social absorbería "Régimen Capital: …"). + it('la razón social termina donde empieza la etiqueta siguiente', () => { + expect(parseCsf(sharedLine(NEXT_LABEL)).legalName).toBe('ESCUELA KEMPER URGATE'); + }); + + // Mata: delimitar con la etiqueta literal en vez de su forma canónica. + it.each(Object.entries(VARIATIONS).filter(([, vary]) => vary(NEXT_LABEL) !== NEXT_LABEL))( + 'con la etiqueta siguiente en variación %s', + (_, vary) => { + expect(parseCsf(sharedLine(vary(NEXT_LABEL))).legalName).toBe('ESCUELA KEMPER URGATE'); + }, + ); +}); + +describe('mapTaxRegimeCode con variaciones cosméticas de la descripción', () => { + // Descripciones de c_RegimenFiscal tal como las publica el SAT (catCFDI 4.0), más el prefijo que imprime la CSF. + const SAT_DESCRIPTIONS = [ + ['601', 'Régimen General de Ley Personas Morales'], + ['603', 'Régimen de las Personas Morales con Fines no Lucrativos'], + ['605', 'Régimen de Sueldos y Salarios e Ingresos Asimilados a Salarios'], + ['606', 'Régimen de Arrendamiento'], + ['607', 'Régimen de Enajenación o Adquisición de Bienes'], + ['608', 'Régimen de los Demás ingresos'], + ['610', 'Residentes en el Extranjero sin Establecimiento Permanente en México'], + ['611', 'Régimen de Ingresos por Dividendos (socios y accionistas)'], + ['612', 'Régimen de las Personas Físicas con Actividades Empresariales y Profesionales'], + ['614', 'Régimen de los Ingresos por intereses'], + ['615', 'Régimen de los ingresos por obtención de premios'], + ['616', 'Sin obligaciones fiscales'], + ['620', 'Sociedades Cooperativas de Producción que optan por diferir sus ingresos'], + ['621', 'Régimen de Incorporación Fiscal'], + ['622', 'Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras'], + ['623', 'Opcional para Grupos de Sociedades'], + ['624', 'Coordinados'], + ['625', 'Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas'], + ['626', 'Régimen Simplificado de Confianza'], + ] as const; + + const cases = SAT_DESCRIPTIONS.flatMap(([code, description]) => Object.entries(VARIATIONS) + .filter(([, vary]) => vary(description) !== description) + .map(([variation, vary]) => ({ code, variation, description: vary(description) }))); + + it.each(cases)('$code $variation', ({ code, description }) => { + expect(mapTaxRegimeCode(description)).toBe(code); + }); +}); diff --git a/test/csf-parser-rules.test.ts b/test/csf-parser-rules.test.ts new file mode 100644 index 0000000..9f2b06a --- /dev/null +++ b/test/csf-parser-rules.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { parseCsf } from '../src/csf-parser.js'; +import { CsfValidationError, CsfValidationErrorCode } from '../src/csf-validation-error.js'; +import { PF_CURP, PF_RFC, PM_RFC, pfLines, pmLines } from './fixtures/synthetic-csf-lines.js'; + +/* + * Reglas de validación del parser que la spec migrada no cubría, cada una con la mutación que detecta. + * Datos sintéticos (ver fixtures). + */ + +function expectRejected(lines: readonly string[], code: CsfValidationErrorCode, message: RegExp): void { + expect(() => parseCsf(lines)).toThrow(CsfValidationError); + expect(() => parseCsf(lines)).toThrow(message); + expect(() => parseCsf(lines)).toThrow(expect.objectContaining({ code })); +} + +function replaceLine(lines: readonly string[], current: string, replacement: string): string[] { + return lines.map(line => (line === current ? replacement : line)); +} + +describe('parseCsf: formato de RFC y CURP', () => { + // Mata: quitar la validación del patrón del RFC (fallaría después, por la cadena del sello). + it('rechaza un RFC con formato inválido', () => { + expectRejected( + replaceLine(pfLines(), `RFC: ${PF_RFC}`, 'RFC: PEGJ801301AB1'), + CsfValidationErrorCode.InvalidRfc, + /^El RFC de la constancia \(PEGJ801301AB1\) no tiene un formato válido\.$/, + ); + }); + + // Mata: quitar la validación del patrón de la CURP. + it('rechaza una CURP con formato inválido', () => { + expectRejected( + replaceLine(pfLines(), `CURP: ${PF_CURP}`, 'CURP: PEGJ800101ZJCRRN09'), + CsfValidationErrorCode.InvalidCurp, + /^La CURP de la constancia \(PEGJ800101ZJCRRN09\) no tiene un formato válido\.$/, + ); + }); + + // Mata: quitar el rechazo de CURP en persona moral. + it('rechaza una persona moral con CURP', () => { + const lines = pmLines().flatMap(line => (line === `RFC: ${PM_RFC}` ? [line, 'CURP: PEGJ800101HJCRRN09'] : [line])); + expectRejected(lines, CsfValidationErrorCode.UnexpectedCurp, /^La constancia de persona moral no debe contener CURP\.$/); + }); + + // Mata: rechazar la etiqueta CURP vacía en persona moral (el original solo rechaza si trae valor). + it('acepta una persona moral con la etiqueta CURP vacía', () => { + const lines = pmLines().flatMap(line => (line === `RFC: ${PM_RFC}` ? [line, 'CURP:'] : [line])); + expect(parseCsf(lines).personType).toBe('PM'); + }); +}); + +describe('parseCsf: consistencia del RFC', () => { + // Mata: mínimo de apariciones reducido (3 → 2). + it('rechaza un RFC que aparece menos de 3 veces antes del aviso de datos personales', () => { + const lines = pfLines(); + lines.splice(lines.indexOf(PF_RFC), 1); + expectRejected(lines, CsfValidationErrorCode.InconsistentRfc, /^El RFC no es consistente en todo el documento\.$/); + }); + + // Mata: tolerar espacios junto a los | del título en la cadena original del sello (texto sellado). + it('rechaza la cadena original con espacios junto al título', () => { + const lines = pmLines().map(line => line.replace('|CONSTANCIA DE SITUACIÓN FISCAL|', '| CONSTANCIA DE SITUACIÓN FISCAL|')); + expectRejected(lines, CsfValidationErrorCode.MissingSealChain, /^La constancia no contiene una cadena original de sello válida\.$/); + }); +}); + +describe('parseCsf: regímenes', () => { + // Mata: quitar la deduplicación por clave (se conserva el primero). + it('conserva solo la primera aparición de cada clave', () => { + const csf = parseCsf(pfLines({ + regimes: [ + 'Régimen de Sueldos y Salarios e Ingresos Asimilados a Salarios 01/01/2010', + 'Sueldos y Salarios e Ingresos Asimilados a Salarios 01/01/2015', + ], + })); + expect(csf.activeRegimes).toStrictEqual([ + { code: '605', description: 'Régimen de Sueldos y Salarios e Ingresos Asimilados a Salarios', startDate: '01/01/2010' }, + ]); + }); + + // Mata: `fecha fin <= emisión` → `fecha fin < emisión`. + it('descarta un régimen cuya fecha fin es la fecha de emisión', () => { + const csf = parseCsf(pfLines({ + regimes: ['Régimen de Incorporación Fiscal 01/01/2015 02/04/2026', 'Régimen Simplificado de Confianza 01/01/2022'], + })); + expect(csf.activeRegimes.map(regime => regime.code)).toEqual(['626']); + }); + + // Mata: descartar también los regímenes que terminan después de la emisión. + it('conserva un régimen cuya fecha fin es posterior a la emisión', () => { + const csf = parseCsf(pfLines({ + regimes: ['Régimen de Incorporación Fiscal 01/01/2015 03/04/2026', 'Régimen Simplificado de Confianza 01/01/2022'], + })); + expect(csf.activeRegimes.map(regime => regime.code)).toEqual(['621', '626']); + }); + + // Mata: confundir "sin regímenes vigentes" con "régimen no reconocido". + it('rechaza una constancia sin regímenes vigentes', () => { + expectRejected( + pfLines({ regimes: ['Régimen Simplificado de Confianza 01/01/2022 31/12/2025'] }), + CsfValidationErrorCode.NoActiveRegimes, + /^La constancia no contiene regímenes fiscales vigentes\.$/, + ); + }); + + // Mata: buscar el inicio de la sección en cualquier parte del renglón (debe empezar con "Regímenes:"). + it('rechaza una constancia cuyo renglón no empieza con "Regímenes:"', () => { + expectRejected( + replaceLine(pfLines(), 'Regímenes:', 'Sección de Regímenes:'), + CsfValidationErrorCode.MissingRegimesSection, + /^La constancia no contiene la sección de regímenes\.$/, + ); + }); +}); diff --git a/test/csf-parser.test.ts b/test/csf-parser.test.ts new file mode 100644 index 0000000..d06b8a8 --- /dev/null +++ b/test/csf-parser.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { parseCsf } from '../src/csf-parser.js'; +import { CsfValidationError, CsfValidationErrorCode } from '../src/csf-validation-error.js'; +import { mapTaxRegimeCode } from '../src/csf-tax-regimes.js'; +import { PF_CURP, PF_RFC, pfLines, pmLines } from './fixtures/synthetic-csf-lines.js'; + +/** + * Equivale al toThrowMatching de Jasmine original (la excepción es CsfValidationError y su mensaje cumple el + * patrón) y además verifica el `code`, que es parte de la API pública. + */ +function expectRejected(lines: string[], code: CsfValidationErrorCode, message: RegExp): void { + expect(() => parseCsf(lines)).toThrow(CsfValidationError); + expect(() => parseCsf(lines)).toThrow(message); + expect(() => parseCsf(lines)).toThrow(expect.objectContaining({ code })); +} + +describe('parseCsf', () => { + it('extrae los datos de una persona física con un régimen', () => { + const csf = parseCsf(pfLines()); + expect(csf.rfc).toBe(PF_RFC); + expect(csf.personType).toBe('PF'); + expect(csf.curp).toBe(PF_CURP); + expect(csf.legalName).toBe('JUAN PEREZ GARCIA'); + expect(csf.zipCode).toBe('01010'); + expect(csf.activeRegimes.map(r => r.code)).toEqual(['605']); + expect(csf.issueDate).toBe('2026-04-02'); + expect(csf.idCif).toBe('12345678901'); + }); + + it('acepta segundo apellido vacío', () => { + expect(parseCsf(pfLines({ secondLastName: '' })).legalName).toBe('JUAN PEREZ'); + }); + + it('devuelve todos los regímenes vigentes', () => { + const csf = parseCsf(pfLines({ + regimes: [ + 'Régimen de Sueldos y Salarios e Ingresos Asimilados a Salarios 29/03/2016', + 'Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas. 12/03/2025', + ], + })); + expect(csf.activeRegimes.map(r => r.code)).toEqual(['605', '625']); + }); + + it('omite regímenes con fecha fin anterior a la emisión', () => { + const csf = parseCsf(pfLines({ + regimes: [ + 'Régimen de Incorporación Fiscal 01/01/2015 31/12/2021', + 'Régimen Simplificado de Confianza 01/01/2022', + ], + })); + expect(csf.activeRegimes.map(r => r.code)).toEqual(['626']); + }); + + it('extrae los datos de una persona moral', () => { + const csf = parseCsf(pmLines()); + expect(csf.personType).toBe('PM'); + expect(csf.legalName).toBe('ESCUELA KEMPER URGATE'); + expect(csf.curp).toBeUndefined(); + expect(csf.zipCode).toBe('45116'); + expect(csf.activeRegimes.map(r => r.code)).toEqual(['601']); + }); + + it('rechaza un documento que no es CSF', () => { + expectRejected(['FACTURA', 'RFC emisor: EKU9003173C9', 'Total: 100.00'], CsfValidationErrorCode.NotACsf, /no es una Constancia/); + }); + + it('rechaza si falta una sección obligatoria', () => { + expectRejected(pfLines().filter(l => !l.startsWith('Datos del domicilio')), CsfValidationErrorCode.NotACsf, /faltan secciones/); + }); + + it('rechaza RFC inconsistente con la cadena original', () => { + expectRejected(pfLines().map(l => l.replace(`RFC: ${PF_RFC}`, 'RFC: PEGJ800101AB2')), CsfValidationErrorCode.RfcSealMismatch, /cadena original/); + }); + + it('rechaza RFC inconsistente en el encabezado', () => { + expectRejected(pfLines().map(l => (l === PF_RFC ? 'PEGJ800101AB2' : l)), CsfValidationErrorCode.InconsistentRfc, /no es consistente/); + }); + + it('rechaza contribuyentes que no están activos', () => { + expectRejected(pfLines({ status: 'CANCELADO' }), CsfValidationErrorCode.InactiveTaxpayer, /estatus "CANCELADO"/); + }); + + it('rechaza nombre distinto al de la cédula', () => { + expectRejected(pfLines().map(l => l.replace('Nombre (s): JUAN', 'Nombre (s): PEDRO')), CsfValidationErrorCode.LegalNameMismatch, /no coincide con el de la cédula/); + }); + + it('rechaza CURP cuya fecha no coincide con el RFC', () => { + expectRejected(pfLines().map(l => l.replace(`CURP: ${PF_CURP}`, 'CURP: PEGJ800102HJCRRN09')), CsfValidationErrorCode.CurpRfcDateMismatch, /fecha de la CURP/); + }); + + it('rechaza si ningún régimen vigente es reconocido', () => { + expectRejected(pfLines({ regimes: ['Régimen Inventado 01/01/2020'] }), CsfValidationErrorCode.UnrecognizedRegime, /No se reconoce el régimen/); + }); +}); + +describe('mapTaxRegimeCode', () => { + it('ignora acentos, mayúsculas, puntuación y el prefijo "Régimen de las"', () => { + expect(mapTaxRegimeCode('RÉGIMEN DE LAS ACTIVIDADES EMPRESARIALES CON INGRESOS A TRAVÉS DE PLATAFORMAS TECNOLÓGICAS.')).toBe('625'); + expect(mapTaxRegimeCode('Régimen de las Personas Físicas con Actividades Empresariales y Profesionales')).toBe('612'); + expect(mapTaxRegimeCode('Régimen Simplificado de Confianza')).toBe('626'); + }); + + it('no hace coincidencias parciales', () => { + expect(mapTaxRegimeCode('Régimen General')).toBeNull(); + }); +}); diff --git a/test/csf-pdf-reader.test.ts b/test/csf-pdf-reader.test.ts new file mode 100644 index 0000000..52f0e31 --- /dev/null +++ b/test/csf-pdf-reader.test.ts @@ -0,0 +1,212 @@ +import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'; +import { describe, expect, it } from 'vitest'; +import { readCsfFileBytes } from '../src/csf-limits.js'; +import { parseCsf } from '../src/csf-parser.js'; +import { CsfValidationError, CsfValidationErrorCode } from '../src/csf-validation-error.js'; +import { extractPdfLines } from '../src/pdf-text-extractor.js'; +import { FakePdfJs, textItem } from './fixtures/fake-pdfjs.js'; +import { + buildPdf, + SYNTHETIC_CSF_LEGAL_NAME, + SYNTHETIC_CSF_RFC, + syntheticCsfIdentityPage, + syntheticCsfPages, +} from './fixtures/synthetic-csf-pdf.js'; + +// En Node se inyecta la build legacy de pdfjs-dist: la de navegador necesita un worker real. +const KB = 1024; + +function pdfFile(bytes: Uint8Array, name = 'constancia.pdf'): File { + return new File([bytes], name, { type: 'application/pdf' }); +} + +/** La operación falla con un CsfValidationError del código esperado y con el mensaje para el usuario. */ +async function expectCsfRejection( + operation: Promise, + code: CsfValidationErrorCode, + message: RegExp, +): Promise { + await expect(operation).rejects.toBeInstanceOf(CsfValidationError); + await expect(operation).rejects.toHaveProperty('code', code); + await expect(operation).rejects.toThrow(message); +} + +describe('lectura y parse de una CSF sintética en PDF', () => { + it('extrae los campos de la constancia de punta a punta', async () => { + const bytes = await readCsfFileBytes(pdfFile(buildPdf(syntheticCsfPages()))); + const csf = parseCsf(await extractPdfLines(pdfjs, bytes)); + + expect(csf).toStrictEqual({ + rfc: SYNTHETIC_CSF_RFC, + personType: 'PM', + legalName: SYNTHETIC_CSF_LEGAL_NAME, + curp: undefined, + zipCode: '42501', + activeRegimes: [{ code: '601', description: 'Régimen General de Ley Personas Morales', startDate: '17/03/1990' }], + unrecognizedRegimes: [], + idCif: '19010012345', + issueDate: '2026-01-15', + status: 'ACTIVO', + }); + }); + + it('une en un renglón los fragmentos con la misma coordenada Y, ordenados por X', async () => { + const lines = await extractPdfLines(pdfjs, buildPdf(syntheticCsfPages())); + + expect(lines).toContain('Código Postal:42501 Tipo de Vialidad: CALLE'); + expect(lines).toContain('Régimen General de Ley Personas Morales 17/03/1990'); + }); +}); + +describe('readCsfFileBytes', () => { + it('rechaza un archivo que no es .pdf', async () => { + await expectCsfRejection( + readCsfFileBytes(pdfFile(buildPdf(syntheticCsfPages()), 'constancia.png')), + CsfValidationErrorCode.NotPdfFile, + /^Selecciona un archivo PDF\.$/, + ); + }); + + it('rechaza un archivo mayor a 5 MB', async () => { + await expectCsfRejection( + readCsfFileBytes(pdfFile(buildPdf(syntheticCsfPages(), 5 * KB * KB + KB))), + CsfValidationErrorCode.FileTooLarge, + /tamaño máximo de 5 MB/, + ); + }); + + it('rechaza un archivo menor a 10 KB', async () => { + await expectCsfRejection( + readCsfFileBytes(pdfFile(buildPdf(syntheticCsfPages(), 0))), + CsfValidationErrorCode.FileTooSmall, + /demasiado pequeño/, + ); + }); + + it('rechaza un archivo sin la firma %PDF-', async () => { + const bytes = new Uint8Array(12 * KB).fill(0x20); + await expectCsfRejection( + readCsfFileBytes(pdfFile(bytes)), + CsfValidationErrorCode.InvalidPdfSignature, + /no es un PDF válido/, + ); + }); +}); + +describe('extractPdfLines', () => { + it('rechaza un PDF con más de 6 páginas', async () => { + const pages = Array.from({ length: 7 }, syntheticCsfIdentityPage); + await expectCsfRejection( + extractPdfLines(pdfjs, buildPdf(pages)), + CsfValidationErrorCode.InvalidPageCount, + /número de páginas/, + ); + }); + + it('rechaza un PDF sin texto', async () => { + await expectCsfRejection( + extractPdfLines(pdfjs, buildPdf([[]])), + CsfValidationErrorCode.NoText, + /no contiene texto/, + ); + }); + + it('rechaza un PDF dañado', async () => { + const damaged = buildPdf(syntheticCsfPages()).subarray(0, 200); + await expectCsfRejection( + extractPdfLines(pdfjs, damaged), + CsfValidationErrorCode.UnreadablePdf, + /dañado o no es un PDF válido/, + ); + }); +}); + +/** Bytes del tamaño exacto pedido con la firma %PDF-: basta para las validaciones previas a abrir el PDF. */ +function signedBytes(size: number): Uint8Array { + const bytes = new Uint8Array(size).fill(0x20); + bytes.set(new TextEncoder().encode('%PDF-')); + return bytes; +} + +describe('readCsfFileBytes en los bordes de tamaño', () => { + // Mata: `size > máximo` → `size >= máximo`. + it('acepta exactamente 5 MB', async () => { + await expect(readCsfFileBytes(pdfFile(signedBytes(5 * KB * KB)))).resolves.toHaveLength(5 * KB * KB); + }); + + // Mata: máximo ampliado. + it('rechaza 5 MB + 1 byte', async () => { + await expectCsfRejection( + readCsfFileBytes(pdfFile(signedBytes(5 * KB * KB + 1))), + CsfValidationErrorCode.FileTooLarge, + /tamaño máximo de 5 MB/, + ); + }); + + // Mata: `size < mínimo` → `size <= mínimo`. + it('acepta exactamente 10 KB', async () => { + await expect(readCsfFileBytes(pdfFile(signedBytes(10 * KB)))).resolves.toHaveLength(10 * KB); + }); + + // Mata: mínimo reducido. + it('rechaza 10 KB − 1 byte', async () => { + await expectCsfRejection( + readCsfFileBytes(pdfFile(signedBytes(10 * KB - 1))), + CsfValidationErrorCode.FileTooSmall, + /demasiado pequeño/, + ); + }); +}); + +describe('extractPdfLines con pdf.js inyectado', () => { + const page = [textItem('Constancia', 10, 700)]; + + // Mata: `páginas > 6` → `páginas >= 6`; además verifica que el documento se libere. + it('acepta 6 páginas y libera el documento', async () => { + const fake = FakePdfJs.withPages(Array.from({ length: 6 }, () => page)); + await expect(extractPdfLines(fake, new Uint8Array())).resolves.toHaveLength(6); + expect(fake.destroyCount).toBe(1); + }); + + // Mata: máximo de páginas ampliado. + it('rechaza 7 páginas y libera el documento', async () => { + const fake = FakePdfJs.withPages(Array.from({ length: 7 }, () => page)); + await expectCsfRejection(extractPdfLines(fake, new Uint8Array()), CsfValidationErrorCode.InvalidPageCount, /número de páginas/); + expect(fake.destroyCount).toBe(1); + }); + + // Mata: quitar la condición `páginas < 1`. + it('rechaza un documento sin páginas', async () => { + await expectCsfRejection( + extractPdfLines(FakePdfJs.withPages([]), new Uint8Array()), + CsfValidationErrorCode.InvalidPageCount, + /número de páginas/, + ); + }); + + // Mata: no distinguir la PasswordException (o comparar otro nombre). + it('reporta un PDF protegido con contraseña y libera la tarea', async () => { + const fake = FakePdfJs.failingWith(Object.assign(new Error('No password given'), { name: 'PasswordException' })); + await expectCsfRejection( + extractPdfLines(fake, new Uint8Array()), + CsfValidationErrorCode.PasswordProtected, + /^El PDF está protegido con contraseña; descarga la constancia original del SAT\.$/, + ); + expect(fake.destroyCount).toBe(1); + }); + + // Mata: tratar toda falla de apertura como contraseña. + it('reporta como ilegible cualquier otra falla al abrir', async () => { + await expectCsfRejection( + extractPdfLines(FakePdfJs.failingWith(new Error('Invalid PDF structure')), new Uint8Array()), + CsfValidationErrorCode.UnreadablePdf, + /dañado o no es un PDF válido/, + ); + }); + + // Mata: quitar el type guard (las marcas de contenido no traen texto ni posición). + it('descarta las marcas de contenido', async () => { + const marked = [{ type: 'beginMarkedContent', id: '' }, textItem('RFC:', 10, 700), { type: 'endMarkedContent', id: '' }]; + await expect(extractPdfLines(FakePdfJs.withPages([marked]), new Uint8Array())).resolves.toEqual(['RFC:']); + }); +}); diff --git a/test/fixtures/fake-pdfjs.ts b/test/fixtures/fake-pdfjs.ts new file mode 100644 index 0000000..93e2a3f --- /dev/null +++ b/test/fixtures/fake-pdfjs.ts @@ -0,0 +1,46 @@ +import type { TextMarkedContent } from 'pdfjs-dist/types/src/display/api.js'; +import type { + PdfDocumentLike, + PdfJsLike, + PdfLoadingTaskLike, + PdfTextItem, +} from '../../src/pdf-text-extractor.js'; + +export type FakePageItems = readonly (PdfTextItem | TextMarkedContent)[]; + +const FONT_SIZE = 9; + +/** Fragmento de texto como lo entrega pdf.js: matriz [a, b, c, d, e, f] con la posición en e y f. */ +export function textItem(str: string, x: number, y: number): PdfTextItem { + return { str, dir: 'ltr', transform: [FONT_SIZE, 0, 0, FONT_SIZE, x, y], width: str.length * 5, height: FONT_SIZE, fontName: 'F1', hasEOL: false }; +} + +/** pdf.js falso para probar el lector sin un PDF real; cuenta cuántas veces se libera el documento. */ +export class FakePdfJs implements PdfJsLike { + destroyCount = 0; + + private constructor(private readonly open: () => Promise) {} + + static withPages(pages: readonly FakePageItems[]): FakePdfJs { + return new FakePdfJs(() => Promise.resolve({ + numPages: pages.length, + getPage: (pageNumber: number) => Promise.resolve({ + getTextContent: () => Promise.resolve({ items: pages[pageNumber - 1] ?? [] }), + }), + })); + } + + static failingWith(error: Error): FakePdfJs { + return new FakePdfJs(() => Promise.reject(error)); + } + + getDocument(): PdfLoadingTaskLike { + return { + promise: this.open(), + destroy: () => { + this.destroyCount++; + return Promise.resolve(); + }, + }; + } +} diff --git a/test/fixtures/synthetic-csf-lines.ts b/test/fixtures/synthetic-csf-lines.ts new file mode 100644 index 0000000..434487d --- /dev/null +++ b/test/fixtures/synthetic-csf-lines.ts @@ -0,0 +1,75 @@ +/* + * Renglones de texto SINTÉTICOS de una CSF (como los arma extractPdfLines). El repositorio es público: + * los datos son ficticios; la persona moral usa el RFC de pruebas del SAT EKU9003173C9. + */ + +export const PF_RFC = 'PEGJ800101AB1'; +export const PF_CURP = 'PEGJ800101HJCRRN09'; +export const PM_RFC = 'EKU9003173C9'; + +export function pfLines(overrides: { regimes?: string[]; status?: string; secondLastName?: string } = {}): string[] { + const secondLastName = overrides.secondLastName ?? 'GARCIA'; + return [ + 'Página [1] de [2]', + 'CÉDULA DE IDENTIFICACIÓN FISCAL', + 'CONSTANCIA DE SITUACIÓN FISCAL', + PF_RFC, + 'Registro Federal de Contribuyentes', + `JUAN PEREZ${secondLastName ? ' ' + secondLastName : ''}`, + 'Nombre, denominación o razón Lugar y Fecha de Emisión', + 'social', + 'ZAPOPAN , JALISCO A 02 DE ABRIL DE 2026', + 'idCIF: 12345678901', + 'VALIDA TU INFORMACIÓN', + 'FISCAL', + PF_RFC, + 'Datos de Identificación del Contribuyente:', + `RFC: ${PF_RFC}`, + `CURP: ${PF_CURP}`, + 'Nombre (s): JUAN', + 'Primer Apellido: PEREZ', + `Segundo Apellido:${secondLastName ? ' ' + secondLastName : ''}`, + 'Fecha inicio de operaciones: 19 DE DICIEMBRE DE 2012', + `Estatus en el padrón: ${overrides.status ?? 'ACTIVO'}`, + 'Fecha de último cambio de estado: 28 DE SEPTIEMBRE DE 2013', + 'Nombre Comercial:', + 'Datos del domicilio registrado', + 'Código Postal:01010 Tipo de Vialidad: CALLE', + 'Nombre de Vialidad: PRINCIPAL Número Exterior: 1', + 'Página [2] de [2]', + 'Regímenes:', + 'Régimen Fecha Inicio Fecha Fin', + ...(overrides.regimes ?? ['Régimen de Sueldos y Salarios e Ingresos Asimilados a Salarios 01/05/2024']), + 'Sus datos personales son incorporados y protegidos en los sistemas del SAT', + `Cadena Original Sello: ||2026/04/02 11:40:42|${PF_RFC}|CONSTANCIA DE SITUACIÓN`, + 'FISCAL|200001088888800000041|FICTICIO-RESTO-DE-LA-CADENA||', + 'Sello Digital: FICTICIO-SELLO-DIGITAL-SIN-VALOR-FISCAL', + ]; +} + +export function pmLines(): string[] { + return [ + 'CÉDULA DE IDENTIFICACIÓN FISCAL', + 'CONSTANCIA DE SITUACIÓN FISCAL', + PM_RFC, + 'Registro Federal de Contribuyentes', + 'ESCUELA KEMPER URGATE', + 'Nombre, denominación o razón Lugar y Fecha de Emisión', + 'idCIF: 12345678902', + PM_RFC, + 'Datos de Identificación del Contribuyente:', + `RFC: ${PM_RFC}`, + 'Denominación/Razón Social: ESCUELA KEMPER URGATE', + 'Régimen Capital: SOCIEDAD ANONIMA DE CAPITAL VARIABLE', + 'Nombre Comercial: EKU', + 'Estatus en el padrón: REACTIVADO', + 'Datos del domicilio registrado', + 'Código Postal:45116 Tipo de Vialidad: AVENIDA (AV.)', + 'Regímenes:', + 'Régimen Fecha Inicio Fecha Fin', + 'Régimen General de Ley Personas Morales 04/03/2024', + 'Obligaciones:', + `Cadena Original Sello: ||2024/11/01|${PM_RFC}|CONSTANCIA DE SITUACIÓN FISCAL|200001088888800000031||`, + 'Sello Digital: FICTICIO-SELLO-DIGITAL-SIN-VALOR-FISCAL', + ]; +} diff --git a/test/fixtures/synthetic-csf-pdf.ts b/test/fixtures/synthetic-csf-pdf.ts new file mode 100644 index 0000000..49d17df --- /dev/null +++ b/test/fixtures/synthetic-csf-pdf.ts @@ -0,0 +1,141 @@ +/* + * Constancia de Situación Fiscal SINTÉTICA para las pruebas. El repositorio es público: ningún dato proviene + * de una constancia real. Se usa el RFC de pruebas del SAT EKU9003173C9 (ESCUELA KEMPER URGATE) y valores + * ficticios (idCIF, domicilio, cadena original y sello). El PDF se genera en memoria con la estructura de + * texto de una CSF: renglones con coordenadas, etiquetas que comparten renglón y dos páginas. + */ + +export const SYNTHETIC_CSF_RFC = 'EKU9003173C9'; +export const SYNTHETIC_CSF_LEGAL_NAME = 'ESCUELA KEMPER URGATE'; + +/** Fragmento de texto en una posición de la página (puntos PDF, origen abajo a la izquierda). */ +export interface PdfTextFragment { + readonly text: string; + readonly x: number; + readonly y: number; +} + +export type PdfPageText = readonly PdfTextFragment[]; + +const FONT_SIZE = 9; +const LEFT = 40; +const VALUE_COLUMN = 300; +const PAGE_WIDTH = 612; +const PAGE_HEIGHT = 792; +/** Las CSF reales pesan más de 10 KB por el QR y los logotipos; un comentario PDF alcanza el mínimo sin afectar el texto. */ +const MIN_REALISTIC_SIZE = 12 * 1024; + +/** Renglones apilados de arriba hacia abajo; cada renglón puede tener varios fragmentos (columnas). */ +function stackRows(rows: readonly (readonly string[])[], top: number): PdfPageText { + return rows.flatMap((row, index) => + row.map((text, column) => ({ text, x: column === 0 ? LEFT : VALUE_COLUMN, y: top - index * 14 }))); +} + +/** Página 1: cédula, datos de identificación y domicilio. */ +export function syntheticCsfIdentityPage(): PdfPageText { + return stackRows([ + ['CÉDULA DE IDENTIFICACIÓN FISCAL'], + ['CONSTANCIA DE SITUACIÓN FISCAL'], + [SYNTHETIC_CSF_RFC], + ['Registro Federal de Contribuyentes'], + [SYNTHETIC_CSF_LEGAL_NAME], + ['Nombre, denominación o razón', 'Lugar y Fecha de Emisión'], + ['social', 'ZAPOPAN , JALISCO A 15 DE ENERO DE 2026'], + ['idCIF: 19010012345'], + ['VALIDA TU INFORMACIÓN FISCAL'], + [SYNTHETIC_CSF_RFC], + ['Datos de Identificación del Contribuyente:'], + [`RFC: ${SYNTHETIC_CSF_RFC}`], + [`Denominación/Razón Social: ${SYNTHETIC_CSF_LEGAL_NAME}`], + ['Régimen Capital: SOCIEDAD CIVIL'], + ['Nombre Comercial:'], + ['Fecha inicio de operaciones: 17 DE MARZO DE 1990'], + ['Estatus en el padrón: ACTIVO'], + ['Fecha de último cambio de estado: 17 DE MARZO DE 1990'], + ['Datos del domicilio registrado'], + ['Código Postal:42501', 'Tipo de Vialidad: CALLE'], + ['Nombre de Vialidad: FICTICIA', 'Número Exterior: 100'], + ['Página [1] de [2]'], + ], 750); +} + +/** Página 2: regímenes (uno ya concluido y uno vigente), obligaciones, cadena original y sello. */ +export function syntheticCsfRegimesPage(): PdfPageText { + return stackRows([ + ['Regímenes:'], + ['Régimen', 'Fecha Inicio Fecha Fin'], + ['Régimen de Incorporación Fiscal', '01/01/2014 31/12/2021'], + ['Régimen General de Ley Personas Morales', '17/03/1990'], + ['Obligaciones:'], + ['Declaración anual de ISR del ejercicio Personas morales.'], + ['Sus datos personales son incorporados y protegidos en los sistemas del SAT'], + [`Cadena Original Sello: ||2026/01/15 10:20:30|${SYNTHETIC_CSF_RFC}|CONSTANCIA DE SITUACIÓN FISCAL|`], + ['200001088888800000031||'], + ['Sello Digital: FICTICIO-SELLO-DIGITAL-SIN-VALOR-FISCAL'], + ['Página [2] de [2]'], + ], 750); +} + +export function syntheticCsfPages(): readonly PdfPageText[] { + return [syntheticCsfIdentityPage(), syntheticCsfRegimesPage()]; +} + +/** Escapa un texto como string literal PDF; los acentos van en WinAnsiEncoding (octal), igual que Latin-1. */ +function pdfLiteral(text: string): string { + let literal = ''; + for (const char of text) { + const code = char.charCodeAt(0); + if (char === '(' || char === ')' || char === '\\') literal += `\\${char}`; + else if (code >= 0x20 && code < 0x7f) literal += char; + else if (code >= 0xa0 && code <= 0xff) literal += `\\${code.toString(8)}`; + else throw new Error(`Carácter fuera de WinAnsiEncoding en el fixture: U+${code.toString(16)}`); + } + return `(${literal})`; +} + +function contentStream(page: PdfPageText): string { + return page + .map(f => `BT /F1 ${String(FONT_SIZE)} Tf 1 0 0 1 ${String(f.x)} ${String(f.y)} Tm ${pdfLiteral(f.text)} Tj ET`) + .join('\n'); +} + +/** + * Arma un PDF 1.4 mínimo y válido (catálogo, páginas, fuente estándar Helvetica y una tabla xref exacta). + * El texto es ASCII tras escapar, así que cada carácter ocupa un byte y los offsets de la xref son exactos. + */ +export function buildPdf(pages: readonly PdfPageText[], minSize = MIN_REALISTIC_SIZE): Uint8Array { + const fontId = 3; + const firstPageId = 4; + const pageIds = pages.map((_, index) => firstPageId + index * 2); + const objects = new Map([ + [1, '<< /Type /Catalog /Pages 2 0 R >>'], + [2, `<< /Type /Pages /Kids [${pageIds.map(id => `${String(id)} 0 R`).join(' ')}] /Count ${String(pages.length)} >>`], + [fontId, '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>'], + ]); + pages.forEach((page, index) => { + const pageId = firstPageId + index * 2; + const stream = contentStream(page); + objects.set(pageId, `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${String(PAGE_WIDTH)} ${String(PAGE_HEIGHT)}] ` + + `/Resources << /Font << /F1 ${String(fontId)} 0 R >> >> /Contents ${String(pageId + 1)} 0 R >>`); + objects.set(pageId + 1, `<< /Length ${String(stream.length)} >>\nstream\n${stream}\nendstream`); + }); + + let pdf = '%PDF-1.4\n'; + const padding = Math.max(0, minSize - 2048); + if (padding > 0) pdf += `%${'0'.repeat(padding)}\n`; + + // Los ids son consecutivos desde 1, así que la xref lista los offsets en el mismo orden. + const offsets: number[] = []; + for (const [id, body] of [...objects.entries()].sort(([a], [b]) => a - b)) { + offsets.push(pdf.length); + pdf += `${String(id)} 0 obj\n${body}\nendobj\n`; + } + + const xrefOffset = pdf.length; + const size = offsets.length + 1; + pdf += `xref\n0 ${String(size)}\n0000000000 65535 f \n`; + for (const offset of offsets) pdf += `${String(offset).padStart(10, '0')} 00000 n \n`; + pdf += `trailer\n<< /Size ${String(size)} /Root 1 0 R >>\nstartxref\n${String(xrefOffset)}\n%%EOF\n`; + + return new TextEncoder().encode(pdf); +} diff --git a/test/pdf-text-lines.test.ts b/test/pdf-text-lines.test.ts new file mode 100644 index 0000000..f46fcda --- /dev/null +++ b/test/pdf-text-lines.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { groupIntoLines, type PositionedText } from '../src/pdf-text-lines.js'; + +const FONT_SIZE = 10; + +function fragment(text: string, x: number, y: number, width = text.length * 5): PositionedText { + return { text, x, y, width, fontSize: FONT_SIZE }; +} + +describe('groupIntoLines', () => { + // Mata: tolerancia `<=` → `<` y tolerancia reducida. + it('une en un renglón fragmentos a 2 pt de distancia vertical', () => { + expect(groupIntoLines([fragment('RFC:', 10, 700), fragment('EKU9003173C9', 60, 702)])).toEqual(['RFC: EKU9003173C9']); + }); + + // Mata: tolerancia ampliada (p. ej. 3 pt). + it('separa en renglones fragmentos a más de 2 pt de distancia vertical', () => { + expect(groupIntoLines([fragment('RFC:', 10, 700), fragment('EKU9003173C9', 60, 702.5)])).toEqual(['EKU9003173C9', 'RFC:']); + }); + + // Mata: orden de renglones invertido y orden horizontal invertido. + it('ordena los renglones de arriba hacia abajo y los fragmentos de izquierda a derecha', () => { + const items = [fragment('abajo', 10, 100), fragment('derecha', 60, 500), fragment('izquierda', 10, 500)]; + expect(groupIntoLines(items)).toEqual(['izquierda derecha', 'abajo']); + }); + + // Mata: hueco `>` → `>=` y razón del hueco en 0. + it('concatena sin espacio los fragmentos contiguos (mismo texto partido)', () => { + const gapAtThreshold = 0.15 * FONT_SIZE; + const items = [fragment('Régi', 10, 300, 20), fragment('men', 30, 300, 15), fragment('es:', 45 + gapAtThreshold, 300, 15)]; + expect(groupIntoLines(items)).toEqual(['Régimenes:']); + }); + + // Mata: razón del hueco ampliada (los fragmentos separados quedarían pegados). + it('separa con un espacio los fragmentos con un hueco mayor al 15 % del tamaño de fuente', () => { + const items = [fragment('Código', 10, 300, 30), fragment('Postal:', 40 + 0.15 * FONT_SIZE + 0.01, 300, 35)]; + expect(groupIntoLines(items)).toEqual(['Código Postal:']); + }); +}); diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..41a5a4c --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "ES2022", + "DOM" + ], + "types": [], + "rootDir": "./src", + "strict": true, + "noImplicitReturns": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "useUnknownInCatchVariables": true, + "noPropertyAccessFromIndexSignature": true, + "inlineSources": true + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/tsconfig.cjs.json b/tsconfig.cjs.json new file mode 100644 index 0000000..a02e7ac --- /dev/null +++ b/tsconfig.cjs.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "./dist/cjs" + } +} diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 0000000..e383a18 --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2022", + "moduleResolution": "Bundler", + "outDir": "./dist/esm" + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..85f0740 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "module": "ES2022", + "moduleResolution": "Bundler", + "rootDir": ".", + "types": ["node"], + "noEmit": true + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.mts"] +} diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 0000000..a71174d --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['test/**/*.test.ts'], + }, +});