[ADD] outputs: pdf-lib, Noto Sans, paper sizes, badge imposition
Badges and printed views need a PDF writer, an embedded font whose metrics the tests can measure, and the paper the machine prints on. pdf-lib with fontkit writes the sheet; Noto Sans (OFL) ships with its provenance and SHA-256, and travels into www/ with every third-party notice that the minified bundle drops. The paper is kept per machine in reglages_locaux.json. A4 and Letter derive the A6 grid from their dimensions; the PDF libraries load in two deferred chunks, each under the 1000 kB threshold. Checked: 3048 node, 639 browser and 67 long tests green from the index. --- FR --- [ADD] sorties : pdf-lib, Noto Sans, formats de papier, imposition Les badges et les vues imprimables exigent un producteur de PDF, une police embarquée que les épreuves mesurent, et le papier de la machine. pdf-lib et fontkit écrivent la planche ; Noto Sans (OFL) arrive avec sa provenance et son SHA-256, et voyage dans www/ avec chaque avis de tiers que le paquet minifié retire. Le papier se garde par machine dans reglages_locaux.json. A4 et Lettre dérivent la grille A6 de leurs dimensions ; les bibliothèques PDF se chargent en deux morceaux différés, chacun sous le seuil de 1000 ko. Vérifié : 3048 node, 639 navigateur et 67 longues vertes depuis l'index. Assisted-by: Claude Opus 5.5
This commit is contained in:
parent
3028fca2ed
commit
e1245dcc08
31 changed files with 3666 additions and 57 deletions
|
|
@ -58,3 +58,11 @@ L'exécutable porte la version datée du logiciel dans son nom, selon le gabarit
|
|||
|
||||
© 2026 TechnoLibre. GNU Affero General Public License, version 3 ou ultérieure ;
|
||||
texte complet dans `LICENSE`.
|
||||
|
||||
L'application embarque la police **Noto Sans** (Regular et Bold), sous la
|
||||
**SIL Open Font License 1.1**, sans nom de police réservé : elle sert à la
|
||||
planche de badges et à l'interface. Son texte est dans
|
||||
`public/licences/NotoSans-OFL.txt`, sa provenance dans `src/polices/PROVENANCE`.
|
||||
Les avis des paquets que la page incorpore — Svelte, pdf-lib, fontkit et ce
|
||||
qu'ils tirent — sont dans `public/licences/TIERS.txt`. Les deux fichiers
|
||||
voyagent avec la page, dans `www/licences/`.
|
||||
|
|
|
|||
350
package-lock.json
generated
350
package-lock.json
generated
|
|
@ -11,12 +11,15 @@
|
|||
"devDependencies": {
|
||||
"@capacitor/cli": "^8.5.2",
|
||||
"@capacitor/core": "^8.5.2",
|
||||
"@pdf-lib/fontkit": "1.1.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@vitest/browser-playwright": "^5.0.3",
|
||||
"@vitest/coverage-v8": "^5.0.3",
|
||||
"electron": "^44.5.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"fast-check": "^4.10.2",
|
||||
"pdf-lib": "1.17.1",
|
||||
"pdfjs-dist": "6.4.299",
|
||||
"playwright": "^1.63.0",
|
||||
"svelte": "^5.57.1",
|
||||
"vite": "^8.3.2",
|
||||
|
|
@ -732,6 +735,283 @@
|
|||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.10.tgz",
|
||||
"integrity": "sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ==",
|
||||
"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.10",
|
||||
"@napi-rs/canvas-darwin-arm64": "1.0.10",
|
||||
"@napi-rs/canvas-darwin-x64": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-arm64-musl": "1.0.10",
|
||||
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-x64-gnu": "1.0.10",
|
||||
"@napi-rs/canvas-linux-x64-musl": "1.0.10",
|
||||
"@napi-rs/canvas-win32-arm64-msvc": "1.0.10",
|
||||
"@napi-rs/canvas-win32-x64-msvc": "1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/canvas-android-arm64": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.10.tgz",
|
||||
"integrity": "sha512-0sDMvme+7fj6bHERUaBs5SvEyWaORc91VUkaB4RrtG7p6dBrBNGy1of8cyEKDxygd1FlHtW+Thunj8lSixzY7A==",
|
||||
"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.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.10.tgz",
|
||||
"integrity": "sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg==",
|
||||
"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.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.10.tgz",
|
||||
"integrity": "sha512-iuuk5plGxAcxurxv2ycF2yR20y5zvaBZ4heQdhPpg3EMFD21PhlY1V+YwSRmYRWzbvKL474UYdw3WIg2qfafyw==",
|
||||
"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.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.10.tgz",
|
||||
"integrity": "sha512-kH7GFR5Unpm77pKSEsmkqvzeQCRD7/MQ7upmeLLjg4XpQ62dX/R6THFsp6vkF1Pkco2aKUq9KhoLM4W5twdk7g==",
|
||||
"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.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.10.tgz",
|
||||
"integrity": "sha512-0/Hj7IwM9pmzdwxiqkp4YLJ8dKqASBrWQ3Y7dWUSU/4dgvCL9O6YNvX7xVUJjonQvdD3IE9ESS/nXa5srQqoVw==",
|
||||
"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.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.10.tgz",
|
||||
"integrity": "sha512-fmjKM0P2MLF9O6XzCQsRjhB+jlLzaf6tFQU36hMPMsYup+VThYvpjjhqNLOc/Ee3tHUE3r13H2BSJx/yXz4pFA==",
|
||||
"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.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.10.tgz",
|
||||
"integrity": "sha512-O/BUrGwrs3pVP9PpD/3++ZiOpjHMJlmiB7aBgswP8rbe4egPfiFkFxmcCczoLvZa72TaQX5lJ8Udedb6rc/Hvg==",
|
||||
"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.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.10.tgz",
|
||||
"integrity": "sha512-48HkZPQeAN/R+9NPpY64tceoyCUW5xYYtHKZnC+BG11qiihXJCbH+xfbgGU+OdYp1Q4s84HDl9ILU0KBK6SBOQ==",
|
||||
"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.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.10.tgz",
|
||||
"integrity": "sha512-QYHVi7WZ9v+Wm9OQyHLMTnJ8QiBPVX94BgbCHFJC/4bCFMfvX7fRzo4FJPgkRP0ISz3ulYNow/FCbQOPjzhYjg==",
|
||||
"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.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.10.tgz",
|
||||
"integrity": "sha512-bWK+YajM/8sL0mMgLtug2s063FVBPzFV4QAn987CQomMKlmzrBY6Trz43KY+24hq7IOa3qNhUAgP/XMEF50ZEQ==",
|
||||
"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.10",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.10.tgz",
|
||||
"integrity": "sha512-9WtzW85PaIhtB27iXaR/9RrLCqHLbqD3Z8Q+kh17NjFxwQXFXU5vWbYH4vEH+drM307TTyK3hidZgpt09G5T3g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
|
||||
|
|
@ -755,6 +1035,36 @@
|
|||
"url": "https://github.com/sponsors/oxc-project"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/fontkit": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz",
|
||||
"integrity": "sha512-KjMd7grNapIWS/Dm0gvfHEilSyAmeLvrEGVcqLGi0VYebuqqzTbgF29efCx7tvx+IEbG3zQciRSWl3GkUSvjZg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/standard-fonts": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
|
||||
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@pdf-lib/upng": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz",
|
||||
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^1.0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/@peculiar/asn1-schema": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.10.0.tgz",
|
||||
|
|
@ -4481,6 +4791,13 @@
|
|||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"dev": true,
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
|
|
@ -4525,6 +4842,39 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdf-lib": {
|
||||
"version": "1.17.1",
|
||||
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
|
||||
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@pdf-lib/standard-fonts": "^1.0.0",
|
||||
"@pdf-lib/upng": "^1.0.1",
|
||||
"pako": "^1.0.11",
|
||||
"tslib": "^1.11.1"
|
||||
}
|
||||
},
|
||||
"node_modules/pdf-lib/node_modules/tslib": {
|
||||
"version": "1.14.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
|
||||
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
|
||||
"dev": true,
|
||||
"license": "0BSD"
|
||||
},
|
||||
"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/pe-library": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/pe-library/-/pe-library-0.4.1.tgz",
|
||||
|
|
|
|||
|
|
@ -23,12 +23,15 @@
|
|||
"devDependencies": {
|
||||
"@capacitor/cli": "^8.5.2",
|
||||
"@capacitor/core": "^8.5.2",
|
||||
"@pdf-lib/fontkit": "1.1.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^7.3.1",
|
||||
"@vitest/browser-playwright": "^5.0.3",
|
||||
"@vitest/coverage-v8": "^5.0.3",
|
||||
"electron": "^44.5.1",
|
||||
"electron-builder": "^26.15.3",
|
||||
"fast-check": "^4.10.2",
|
||||
"pdf-lib": "1.17.1",
|
||||
"pdfjs-dist": "6.4.299",
|
||||
"playwright": "^1.63.0",
|
||||
"svelte": "^5.57.1",
|
||||
"vite": "^8.3.2",
|
||||
|
|
|
|||
93
public/licences/NotoSans-OFL.txt
Normal file
93
public/licences/NotoSans-OFL.txt
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
1119
public/licences/TIERS.txt
Normal file
1119
public/licences/TIERS.txt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -73,13 +73,16 @@ export const CONSTANTES = Object.freeze(
|
|||
valeur: 0.3,
|
||||
unite: 'px/cm',
|
||||
mesure: 'échelle sous laquelle le plan retire les listes de noms',
|
||||
raison: "sous elle, dessiner les listes coûte plus d'une image par déplacement de vue (§ 17, point 6)",
|
||||
configuration: 'banc de mesure, grande démonstration, 33 tables avec listes',
|
||||
proprietaire: 'banc',
|
||||
raison:
|
||||
"sous elle, les listes de la grande démonstration ne se lisent plus : les dessiner n'apporte rien " +
|
||||
"à l'opérateur et charge chaque image (§ 17, point 6). Leur coût ne dépend pas du zoom : le temps " +
|
||||
'par image reste une garde du banc, pas son arbitre',
|
||||
configuration: 'grande démonstration, 33 tables avec listes, machine de référence, cadre de 1 366 × 700 px',
|
||||
proprietaire: 'manuel',
|
||||
statut: 'defautDeTravail',
|
||||
protocole:
|
||||
'banc de mesure : temps par image pendant un déplacement de vue sur la grande démonstration, 33 tables ' +
|
||||
'avec listes, à plusieurs k ; le plus petit k qui tient le critère de temps par image, écrit dans ce registre',
|
||||
"une personne qui n'a pas écrit le logiciel lit les listes de la grande démonstration sur la machine " +
|
||||
'de référence à k décroissant ; le plus petit k où elle les lit sans erreur, écrit dans ce registre',
|
||||
},
|
||||
{
|
||||
nom: 'LARGEUR_BLOC_NOMS_CM',
|
||||
|
|
@ -109,6 +112,171 @@ export const CONSTANTES = Object.freeze(
|
|||
'mesurée à chaque montage du plan : measureText du gabarit « Ôjgq Æ W » sur la police calculée des ' +
|
||||
"listes ; aucune valeur n'est écrite dans ce registre",
|
||||
},
|
||||
{
|
||||
nom: 'ZONE_SILENCE_MM',
|
||||
valeur: 8,
|
||||
unite: 'mm',
|
||||
mesure: "distance en deçà de laquelle aucun texte ni aucune marque ne s'approche d'un bord de badge",
|
||||
raison:
|
||||
'la garantie de la coupe (§ 11.3), qui couvre deux bornes différentes selon le bord : sur un bord de ' +
|
||||
"coupe, le décalage de l'imprimante, qui déplace la planche entière, plus l'erreur de main, qui " +
|
||||
"déplace la lame ; sur un bord de feuille, la marge non imprimable de l'imprimante et le biais " +
|
||||
"d'entraînement du papier. La plus grande des deux se relève, et ne se calcule pas",
|
||||
configuration: 'imprimantes visées, chaque papier des sorties, massicot et ciseaux',
|
||||
proprietaire: 'manuel',
|
||||
statut: 'defautDeTravail',
|
||||
protocole:
|
||||
'sur chaque imprimante visée : une planche de filets imprimée, son décalage mesuré à la règle, plus ' +
|
||||
"l'erreur de dix coupes ; puis sa marge non imprimable et le biais d'entraînement sur dix feuilles. " +
|
||||
'La plus grande des deux bornes, arrondie au millimètre supérieur, écrite dans ce registre',
|
||||
},
|
||||
{
|
||||
nom: 'HAUTEUR_CAPITALE_NOM_MAX_MM',
|
||||
valeur: 9,
|
||||
unite: 'mm',
|
||||
mesure: "hauteur de capitale du nom sur le badge, au plus grand corps que la mise en page lui donne",
|
||||
raison: "le nom se lit à un mètre (§ 11.4) ; au-delà, il replie trop tôt sans se lire mieux",
|
||||
configuration: 'badge A6 imprimé, police embarquée, lecture à un mètre',
|
||||
proprietaire: 'manuel',
|
||||
statut: 'defautDeTravail',
|
||||
protocole:
|
||||
"impression réelle d'une planche ; lecture à un mètre par une personne qui n'a pas écrit le " +
|
||||
'logiciel ; la hauteur retenue, écrite dans ce registre',
|
||||
},
|
||||
{
|
||||
nom: 'HAUTEUR_CAPITALE_NOM_PLANCHER_MM',
|
||||
valeur: 4,
|
||||
unite: 'mm',
|
||||
mesure: 'hauteur de capitale du nom sous laquelle la mise en page ne descend pas : un nom plus long replie',
|
||||
raison: "sous elle, le nom ne se lit plus à un mètre (§ 11.5) ; elle se convertit en corps par la hauteur de capitale de la police embarquée",
|
||||
configuration: 'badge A6 imprimé, police embarquée, lecture à un mètre',
|
||||
proprietaire: 'manuel',
|
||||
statut: 'defautDeTravail',
|
||||
protocole:
|
||||
"impression réelle d'une planche à hauteurs décroissantes ; lecture à un mètre par une personne qui " +
|
||||
"n'a pas écrit le logiciel ; la plus petite lue sans erreur, écrite dans ce registre",
|
||||
},
|
||||
{
|
||||
nom: 'CORPS_TOURS_PT',
|
||||
valeur: 14,
|
||||
unite: 'pt',
|
||||
mesure: 'corps des lignes de tour du badge',
|
||||
raison: 'la table et le siège de chaque tour se lisent en main, badge au cou (§ 11.4)',
|
||||
configuration: 'badge A6 imprimé, police embarquée, lecture en main',
|
||||
proprietaire: 'manuel',
|
||||
statut: 'defautDeTravail',
|
||||
protocole:
|
||||
"impression réelle d'une planche ; lecture en main par une personne qui n'a pas écrit le logiciel ; " +
|
||||
'le corps retenu, écrit dans ce registre',
|
||||
},
|
||||
{
|
||||
nom: 'CORPS_TOURS_PLANCHER_PT',
|
||||
valeur: 10,
|
||||
unite: 'pt',
|
||||
mesure: 'corps sous lequel les lignes de tour ne descendent pas',
|
||||
raison: 'au-delà, des tours trop nombreux se refusent plutôt que de devenir illisibles (§ 11.5)',
|
||||
configuration: 'badge A6 imprimé, police embarquée, lecture en main',
|
||||
proprietaire: 'manuel',
|
||||
statut: 'defautDeTravail',
|
||||
protocole:
|
||||
"impression réelle d'une planche à corps décroissants ; lecture en main par une personne qui n'a pas " +
|
||||
'écrit le logiciel ; le plus petit corps lu sans erreur, écrit dans ce registre',
|
||||
},
|
||||
{
|
||||
nom: 'CORPS_APPARTENANCE_PT',
|
||||
valeur: 12,
|
||||
unite: 'pt',
|
||||
mesure: "corps de la ligne d'appartenance du badge",
|
||||
raison: "l'appartenance se lit en main, sous le nom, sans lui disputer la lecture à un mètre (§ 11.4)",
|
||||
configuration: 'badge A6 imprimé, police embarquée, lecture en main',
|
||||
proprietaire: 'manuel',
|
||||
statut: 'defautDeTravail',
|
||||
protocole:
|
||||
"impression réelle d'une planche ; lecture en main par une personne qui n'a pas écrit le logiciel ; " +
|
||||
'le corps retenu, écrit dans ce registre',
|
||||
},
|
||||
{
|
||||
nom: 'CORPS_PIED_PT',
|
||||
valeur: 8,
|
||||
unite: 'pt',
|
||||
mesure: "corps du pied du badge : nom et date de l'événement, horodatage du placement",
|
||||
raison: "le pied se lit en main, à l'accueil, et ne prend pas la place du nom (§ 11.4)",
|
||||
configuration: 'badge A6 imprimé, police embarquée, lecture en main',
|
||||
proprietaire: 'manuel',
|
||||
statut: 'defautDeTravail',
|
||||
protocole:
|
||||
"impression réelle d'une planche ; lecture en main par une personne qui n'a pas écrit le logiciel ; " +
|
||||
'le corps retenu, écrit dans ce registre',
|
||||
},
|
||||
{
|
||||
nom: 'CORPS_MIN_IMPRESSION_MM',
|
||||
valeur: 2.5,
|
||||
unite: 'mm',
|
||||
mesure: 'hauteur de caractère du plan imprimé en deçà de laquelle le plan se découpe en pages',
|
||||
raison:
|
||||
"un plan réduit sur une page n'est utile que s'il se lit. Un banc de navigateur ne mesure pas une " +
|
||||
'imprimante, pas plus que la zone de silence (§ 11.3) : la mesure relève du niveau manuel',
|
||||
configuration: 'imprimante visée, chaque papier des sorties, lecture à bout de bras',
|
||||
proprietaire: 'manuel',
|
||||
statut: 'defautDeTravail',
|
||||
protocole:
|
||||
"page d'essai à corps décroissants imprimée sur l'imprimante visée, lue à bout de bras ; le plus " +
|
||||
'petit corps lu sans erreur, écrit dans ce registre',
|
||||
},
|
||||
{
|
||||
nom: 'MARGE_PAGE_MM',
|
||||
valeur: 10,
|
||||
unite: 'mm',
|
||||
mesure: "marge de chaque bord d'une page imprimée par l'aperçu",
|
||||
raison: "le contenu d'une page tient hors de la marge non imprimable de toute imprimante visée (§ 11.7)",
|
||||
configuration: 'imprimantes visées, chaque papier des sorties',
|
||||
proprietaire: 'manuel',
|
||||
statut: 'defautDeTravail',
|
||||
protocole:
|
||||
"marge non imprimable mesurée sur chaque imprimante visée (une page de filets à 1 mm d'écart, lue " +
|
||||
'à la règle) ; la plus grande, écrite dans ce registre',
|
||||
},
|
||||
{
|
||||
nom: 'HAUTEUR_BANDE_IMPRESSION',
|
||||
valeur: null,
|
||||
unite: 'mm',
|
||||
mesure: 'hauteur de ce que porte une feuille imprimée hors de sa zone de contenu : pied, mentions, barre, cartouche',
|
||||
raison: "la hauteur utile d'une page en dépend ; elle suit la police et le rendu, et se lit donc sur la page (§ 14.1)",
|
||||
configuration: "rendu de l'aperçu, relevé à chaque ouverture",
|
||||
proprietaire: 'execution',
|
||||
statut: 'mesuree',
|
||||
protocole:
|
||||
"mesurée à l'ouverture de l'aperçu : tout ce qu'une feuille porte hors de [data-contenu] ; aucune " +
|
||||
"valeur n'est écrite dans ce registre",
|
||||
},
|
||||
{
|
||||
nom: 'TOLERANCE_CHAINE_TEMOIN',
|
||||
valeur: 0.01,
|
||||
unite: 'rapport',
|
||||
mesure: 'écart relatif admis entre la longueur rendue de la chaîne témoin et celle que donnent les avances de la police',
|
||||
raison:
|
||||
"la mesure du § 19.4 trahit une police de repli ; trop serrée, elle échoue sur la gigue du rendu, trop " +
|
||||
'large, elle laisse passer le repli',
|
||||
configuration: 'banc de mesure, Chromium du projet, cadre de 1 366 × 700 px, facteur 1',
|
||||
proprietaire: 'banc',
|
||||
statut: 'defautDeTravail',
|
||||
protocole:
|
||||
"banc de mesure : le quart de l'écart relatif entre la police embarquée et une famille de repli " +
|
||||
'forcée, et au moins trois fois la gigue de dix mesures ; écrit dans ce registre',
|
||||
},
|
||||
{
|
||||
nom: 'RALENTISSEMENT_PLEINE_CHARGE',
|
||||
valeur: 2,
|
||||
unite: '×',
|
||||
mesure: "ralentissement d'une génération quand tous les processeurs logiques travaillent, t(L) / t(1)",
|
||||
raison: "l'estimation de durée d'une génération parallèle compte un cœur chargé plus lent qu'un cœur seul",
|
||||
configuration: "banc de mesure, exécuteur de production dans Chromium, L processeurs logiques",
|
||||
proprietaire: 'banc',
|
||||
statut: 'defautDeTravail',
|
||||
protocole:
|
||||
"banc de mesure : durée d'une même génération sur un travailleur, puis sur L travailleurs ; le " +
|
||||
'rapport ρ des médianes, écrit dans ce registre',
|
||||
},
|
||||
].map((entree) => Object.freeze(entree)),
|
||||
);
|
||||
|
||||
|
|
@ -135,7 +303,9 @@ export function constante(nom) {
|
|||
* type Constante. Un nom vide, ou déjà porté par une entrée précédente, est
|
||||
* une faute ; la valeur est un nombre fini, et null pour une constante
|
||||
* relevée à l'exécution seulement ; les champs textuels ne sont pas vides ;
|
||||
* propriétaire et statut prennent une valeur de leur liste.
|
||||
* propriétaire et statut prennent une valeur de leur liste, et une
|
||||
* constante relevée à l'exécution est mesurée : sa valeur vient du rendu à
|
||||
* chaque relevé, jamais d'un défaut de travail.
|
||||
* @param {ReadonlyArray<Constante>} liste
|
||||
* @returns {Array<{nom: string, champ: string}>}
|
||||
*/
|
||||
|
|
@ -153,7 +323,7 @@ export function verifierRegistre(liste) {
|
|||
if (!estTexte(entree[champ])) faute(champ);
|
||||
}
|
||||
if (!PROPRIETAIRES.has(entree.proprietaire)) faute('proprietaire');
|
||||
if (!STATUTS.has(entree.statut)) faute('statut');
|
||||
if (!STATUTS.has(entree.statut) || (execution && entree.statut !== 'mesuree')) faute('statut');
|
||||
if (!estTexte(entree.protocole)) faute('protocole');
|
||||
}
|
||||
return fautes;
|
||||
|
|
|
|||
|
|
@ -1,31 +1,44 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Épreuves du registre des constantes mesurées (§ 14.1, § 19.10) : les six
|
||||
// entrées du contrat de données, chacune complète, et la vérification qui
|
||||
// nomme l'entrée et le champ d'une faute.
|
||||
// Épreuves du registre des constantes mesurées (§ 14.1, § 19.10) : les
|
||||
// dix-huit entrées du contrat de données, chacune complète et de statut
|
||||
// épinglé, et la vérification qui nomme l'entrée et le champ d'une faute.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { CONSTANTES, constante, verifierRegistre } from './constantes.js';
|
||||
|
||||
// Le contrat de données : nom, valeur de départ, unité, propriétaire.
|
||||
// Le contrat de données : nom, valeur de départ, unité, propriétaire,
|
||||
// statut.
|
||||
const CONTRAT = [
|
||||
['SEUIL_GLISSER_PX', 4, 'px', 'manuel'],
|
||||
['DUREE_PRESSION_LONGUE_MS', 500, 'ms', 'manuel'],
|
||||
['SEUIL_SIEGES_LISIBLES', 16, 'places', 'banc'],
|
||||
['K_LISTES', 0.3, 'px/cm', 'banc'],
|
||||
['LARGEUR_BLOC_NOMS_CM', 500, 'cm', 'manuel'],
|
||||
['HAUTEUR_LIGNE', null, 'cm', 'execution'],
|
||||
['SEUIL_GLISSER_PX', 4, 'px', 'manuel', 'defautDeTravail'],
|
||||
['DUREE_PRESSION_LONGUE_MS', 500, 'ms', 'manuel', 'defautDeTravail'],
|
||||
['SEUIL_SIEGES_LISIBLES', 16, 'places', 'banc', 'defautDeTravail'],
|
||||
['K_LISTES', 0.3, 'px/cm', 'manuel', 'defautDeTravail'],
|
||||
['LARGEUR_BLOC_NOMS_CM', 500, 'cm', 'manuel', 'defautDeTravail'],
|
||||
['HAUTEUR_LIGNE', null, 'cm', 'execution', 'mesuree'],
|
||||
['ZONE_SILENCE_MM', 8, 'mm', 'manuel', 'defautDeTravail'],
|
||||
['HAUTEUR_CAPITALE_NOM_MAX_MM', 9, 'mm', 'manuel', 'defautDeTravail'],
|
||||
['HAUTEUR_CAPITALE_NOM_PLANCHER_MM', 4, 'mm', 'manuel', 'defautDeTravail'],
|
||||
['CORPS_TOURS_PT', 14, 'pt', 'manuel', 'defautDeTravail'],
|
||||
['CORPS_TOURS_PLANCHER_PT', 10, 'pt', 'manuel', 'defautDeTravail'],
|
||||
['CORPS_APPARTENANCE_PT', 12, 'pt', 'manuel', 'defautDeTravail'],
|
||||
['CORPS_PIED_PT', 8, 'pt', 'manuel', 'defautDeTravail'],
|
||||
['CORPS_MIN_IMPRESSION_MM', 2.5, 'mm', 'manuel', 'defautDeTravail'],
|
||||
['MARGE_PAGE_MM', 10, 'mm', 'manuel', 'defautDeTravail'],
|
||||
['HAUTEUR_BANDE_IMPRESSION', null, 'mm', 'execution', 'mesuree'],
|
||||
['TOLERANCE_CHAINE_TEMOIN', 0.01, 'rapport', 'banc', 'defautDeTravail'],
|
||||
['RALENTISSEMENT_PLEINE_CHARGE', 2, '×', 'banc', 'defautDeTravail'],
|
||||
];
|
||||
|
||||
// Une copie du registre, entrée par entrée, que l'épreuve peut altérer.
|
||||
const copie = () => CONSTANTES.map((entree) => ({ ...entree }));
|
||||
|
||||
describe('constantes : le registre (§ 14.1, § 19.10)', () => {
|
||||
test('le registre porte les six entrées du contrat, dans son ordre, chacune complète', () => {
|
||||
assert.equal(CONSTANTES.length, 6);
|
||||
test('le registre porte les dix-huit entrées du contrat, dans son ordre, chacune complète', () => {
|
||||
assert.equal(CONSTANTES.length, 18);
|
||||
assert.deepEqual(
|
||||
CONSTANTES.map(({ nom, valeur, unite, proprietaire }) => [nom, valeur, unite, proprietaire]),
|
||||
CONSTANTES.map(({ nom, valeur, unite, proprietaire, statut }) => [nom, valeur, unite, proprietaire, statut]),
|
||||
CONTRAT,
|
||||
);
|
||||
assert.deepEqual(verifierRegistre(CONSTANTES), []);
|
||||
|
|
@ -34,9 +47,18 @@ describe('constantes : le registre (§ 14.1, § 19.10)', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('tant que son propriétaire ne l\'a pas relevée, une constante se déclare défaut de travail', () => {
|
||||
const statuts = CONSTANTES.map(({ nom, statut }) => [nom, statut]);
|
||||
assert.deepEqual(statuts.slice(0, 5), CONTRAT.slice(0, 5).map(([nom]) => [nom, 'defautDeTravail']));
|
||||
test('tant que son propriétaire ne l\'a pas relevée, une constante se déclare défaut de travail ; une constante relevée à l\'exécution est mesurée', () => {
|
||||
for (const { nom, proprietaire, statut } of CONSTANTES) {
|
||||
assert.equal(statut, proprietaire === 'execution' ? 'mesuree' : 'defautDeTravail', nom);
|
||||
}
|
||||
});
|
||||
|
||||
test('la zone de silence dit ses deux bornes, et K_LISTES est un seuil de lisibilité relu à la main', () => {
|
||||
const entree = (nom) => CONSTANTES.find((c) => c.nom === nom);
|
||||
assert.match(entree('ZONE_SILENCE_MM').raison, /bord de coupe/);
|
||||
assert.match(entree('ZONE_SILENCE_MM').raison, /bord de feuille/);
|
||||
assert.match(entree('K_LISTES').protocole, /n'a pas écrit le logiciel/);
|
||||
assert.match(entree('TOLERANCE_CHAINE_TEMOIN').protocole, /repli/);
|
||||
});
|
||||
|
||||
test('constante rend la valeur par le nom ; un nom inconnu lève', () => {
|
||||
|
|
@ -93,4 +115,16 @@ describe('constantes : le registre (§ 14.1, § 19.10)', () => {
|
|||
// un nombre y serait le relevé d'une autre machine.
|
||||
assert.deepEqual(verifierRegistre([{ ...CONSTANTES[5], valeur: 21.5 }]), [{ nom: 'HAUTEUR_LIGNE', champ: 'valeur' }]);
|
||||
});
|
||||
|
||||
test("verifierRegistre refuse une constante relevée à l'exécution qui n'est pas mesurée (R6), et une constante sans protocole", () => {
|
||||
const execution = CONSTANTES.find((c) => c.nom === 'HAUTEUR_BANDE_IMPRESSION');
|
||||
assert.deepEqual(verifierRegistre([{ ...execution, statut: 'defautDeTravail' }]), [
|
||||
{ nom: 'HAUTEUR_BANDE_IMPRESSION', champ: 'statut' },
|
||||
]);
|
||||
assert.deepEqual(verifierRegistre([{ ...execution }]), []);
|
||||
const sansProtocole = { ...CONSTANTES[6] };
|
||||
delete sansProtocole.protocole;
|
||||
assert.deepEqual(verifierRegistre([sansProtocole]), [{ nom: 'ZONE_SILENCE_MM', champ: 'protocole' }]);
|
||||
assert.deepEqual(verifierRegistre([{ ...CONSTANTES[6], protocole: '' }]), [{ nom: 'ZONE_SILENCE_MM', champ: 'protocole' }]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
20
src/geometrie/papiers.js
Normal file
20
src/geometrie/papiers.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les papiers des sorties et le badge (§ 11.2), en millimètres, portrait :
|
||||
// la seule définition. Le stockage, l'application, l'imposition de la
|
||||
// planche et l'impression en tirent leur domaine — les clés de PAPIERS —
|
||||
// et leurs cotes ; aucun autre module n'écrit le nom d'un papier. Le
|
||||
// logiciel n'en impose aucun par défaut : l'opérateur choisit celui de son
|
||||
// bac (§ 8.5).
|
||||
|
||||
/** @typedef {'A4'|'Lettre'} Papier */
|
||||
|
||||
/** Les papiers, largeur et hauteur en mm, portrait. */
|
||||
export const PAPIERS = Object.freeze({
|
||||
A4: Object.freeze({ largeur: 210, hauteur: 297 }),
|
||||
Lettre: Object.freeze({ largeur: 215.9, hauteur: 279.4 }),
|
||||
});
|
||||
|
||||
/** Le badge : un A6 exact, debout, en mm, sur tout papier. */
|
||||
export const BADGE = Object.freeze({ largeur: 105, hauteur: 148 });
|
||||
25
src/geometrie/papiers.test.js
Normal file
25
src/geometrie/papiers.test.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Les papiers et le badge (§ 11.2) : la seule définition, en millimètres,
|
||||
// portrait, figée.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { BADGE, PAPIERS } from './papiers.js';
|
||||
|
||||
describe('papiers : la seule définition (§ 11.2)', () => {
|
||||
test('A4 210 × 297 et Lettre 215,9 × 279,4, en mm, portrait, dans cet ordre', () => {
|
||||
assert.deepEqual(PAPIERS, { A4: { largeur: 210, hauteur: 297 }, Lettre: { largeur: 215.9, hauteur: 279.4 } });
|
||||
assert.deepEqual(Object.keys(PAPIERS), ['A4', 'Lettre']);
|
||||
for (const { largeur, hauteur } of Object.values(PAPIERS)) assert.ok(hauteur > largeur);
|
||||
});
|
||||
|
||||
test('le badge est un A6 exact, 105 × 148 mm, debout', () => {
|
||||
assert.deepEqual(BADGE, { largeur: 105, hauteur: 148 });
|
||||
});
|
||||
|
||||
test('papiers et badge sont figés, chaque papier compris', () => {
|
||||
assert.ok(Object.isFrozen(PAPIERS) && Object.isFrozen(BADGE));
|
||||
assert.ok(Object.isFrozen(PAPIERS.A4) && Object.isFrozen(PAPIERS.Lettre));
|
||||
});
|
||||
});
|
||||
43
src/impression/imprimante_web.js
Normal file
43
src/impression/imprimante_web.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'imprimante de la plateforme web (§ 11.7, § 13.4) : la frontière de
|
||||
// plateforme « impression » sous un navigateur, qui n'offre que sa boîte de
|
||||
// dialogue. Le papier et l'orientation de la page sont ceux que pose la
|
||||
// feuille d'impression (@page) ; la boîte de dialogue ne dit pas ce
|
||||
// qu'elle en a fait — envoyée, annulée, échouée —, d'où l'issue
|
||||
// « inconnue ». src/main.js seul l'importe, et lui passe la fenêtre de la
|
||||
// page ; les épreuves lui passent une fenêtre factice.
|
||||
import { PAPIERS } from '../geometrie/papiers.js';
|
||||
|
||||
const ORIENTATIONS = new Set(['portrait', 'paysage']);
|
||||
|
||||
/**
|
||||
* @typedef {Object} Imprimante
|
||||
* @property {(options: {papier: import('../geometrie/papiers.js').Papier, orientation: 'portrait'|'paysage'})
|
||||
* => Promise<{issue: 'envoye'|'annule'|'echec'|'inconnue', cause: string|null}>} imprimer
|
||||
*/
|
||||
|
||||
/**
|
||||
* L'imprimante d'une fenêtre de navigateur.
|
||||
*
|
||||
* @param {{print: () => void}} fenetre window, sous la page
|
||||
* @returns {Imprimante} imprimer appelle fenetre.print() une fois et rend
|
||||
* {issue: 'inconnue', cause: null} ; une option hors de son
|
||||
* domaine — un papier hors de PAPIERS, une orientation autre que
|
||||
* portrait ou paysage — rejette TypeError sans rien appeler
|
||||
* @throws {TypeError} une fenêtre sans méthode print
|
||||
*/
|
||||
export function creerImprimanteWeb(fenetre) {
|
||||
if (typeof fenetre?.print !== 'function') throw new TypeError('creerImprimanteWeb : fenêtre sans méthode print');
|
||||
return {
|
||||
async imprimer({ papier, orientation } = {}) {
|
||||
if (typeof papier !== 'string' || !Object.hasOwn(PAPIERS, papier)) {
|
||||
throw new TypeError(`imprimer : papier inconnu ${JSON.stringify(papier)}`);
|
||||
}
|
||||
if (!ORIENTATIONS.has(orientation)) throw new TypeError(`imprimer : orientation inconnue ${JSON.stringify(orientation)}`);
|
||||
fenetre.print();
|
||||
return { issue: 'inconnue', cause: null };
|
||||
},
|
||||
};
|
||||
}
|
||||
67
src/impression/imprimante_web.test.js
Normal file
67
src/impression/imprimante_web.test.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'imprimante de la plateforme web (§ 13.4, § 11.7) : la boîte de dialogue
|
||||
// du navigateur, sans retour sur ce qu'elle devient. Une fenêtre factice
|
||||
// compte ses appels à print.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { PAPIERS } from '../geometrie/papiers.js';
|
||||
import { creerImprimanteWeb } from './imprimante_web.js';
|
||||
|
||||
// Une fenêtre qui ne sait qu'imprimer, et compte ses appels.
|
||||
function fenetreFactice() {
|
||||
const fenetre = {
|
||||
appels: 0,
|
||||
print() {
|
||||
fenetre.appels += 1;
|
||||
},
|
||||
};
|
||||
return fenetre;
|
||||
}
|
||||
|
||||
describe('imprimante web (§ 13.4)', () => {
|
||||
test('imprimer appelle print une fois, et rend l’issue inconnue sans cause, pour chaque papier et chaque orientation', async () => {
|
||||
for (const papier of Object.keys(PAPIERS)) {
|
||||
for (const orientation of ['portrait', 'paysage']) {
|
||||
const fenetre = fenetreFactice();
|
||||
const issue = await creerImprimanteWeb(fenetre).imprimer({ papier, orientation });
|
||||
assert.deepEqual(issue, { issue: 'inconnue', cause: null }, `${papier} ${orientation}`);
|
||||
assert.equal(fenetre.appels, 1, `${papier} ${orientation}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('chaque impression appelle print à nouveau : l’imprimante ne garde aucun état', async () => {
|
||||
const fenetre = fenetreFactice();
|
||||
const imprimante = creerImprimanteWeb(fenetre);
|
||||
await imprimante.imprimer({ papier: 'A4', orientation: 'portrait' });
|
||||
await imprimante.imprimer({ papier: 'A4', orientation: 'paysage' });
|
||||
assert.equal(fenetre.appels, 2);
|
||||
});
|
||||
|
||||
test('une option hors domaine rejette TypeError sans appeler print', async () => {
|
||||
const refusees = [
|
||||
{ papier: 'A3', orientation: 'portrait' },
|
||||
{ papier: 'toString', orientation: 'portrait' },
|
||||
{ papier: undefined, orientation: 'portrait' },
|
||||
{ papier: 'A4', orientation: 'Portrait' },
|
||||
{ papier: 'A4', orientation: 'landscape' },
|
||||
{ papier: 'A4' },
|
||||
];
|
||||
for (const options of refusees) {
|
||||
const fenetre = fenetreFactice();
|
||||
await assert.rejects(creerImprimanteWeb(fenetre).imprimer(options), TypeError, JSON.stringify(options));
|
||||
assert.equal(fenetre.appels, 0, JSON.stringify(options));
|
||||
}
|
||||
const fenetre = fenetreFactice();
|
||||
await assert.rejects(creerImprimanteWeb(fenetre).imprimer(), TypeError);
|
||||
assert.equal(fenetre.appels, 0);
|
||||
});
|
||||
|
||||
test('une fenêtre sans print se refuse à la construction', () => {
|
||||
for (const fenetre of [null, undefined, {}, { print: 'oui' }]) {
|
||||
assert.throws(() => creerImprimanteWeb(fenetre), TypeError, String(fenetre));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -5,8 +5,9 @@
|
|||
// Racine de l'interface (§ 13.4). Ses propriétés sont les dépendances de
|
||||
// l'application — système de fichiers, horloge, exécuteur de génération,
|
||||
// presse-papiers, identifiants, langue, provenance, démonstrations, format
|
||||
// courant, version du produit et sa forme Windows, et en option le thème
|
||||
// de départ — que src/main.js construit pour la machine et monterApp pour
|
||||
// courant, version du produit et sa forme Windows, chargement de la police
|
||||
// embarquée, et en option le thème de départ — que src/main.js construit
|
||||
// pour la machine et monterApp pour
|
||||
// une épreuve : App ne choisit aucune plateforme, et ne lit la version que
|
||||
// dans ses propriétés, la même au bandeau, au titre, à l'accueil et dans
|
||||
// le bloc « À propos ».
|
||||
|
|
@ -70,6 +71,9 @@
|
|||
formatCourant,
|
||||
produitVersion,
|
||||
versionWindows,
|
||||
// () => Promise<{regulier: Uint8Array, gras: Uint8Array}> : les octets
|
||||
// de la police que la production de la planche embarque (§ 11.1).
|
||||
polices,
|
||||
themeInitial,
|
||||
} = untrack(() => dependances);
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
// l'OPFS et le presse-papiers du navigateur. Il construit les dépendances
|
||||
// d'App — l'horloge du système, l'exécuteur de la génération sur le
|
||||
// travailleur, les identifiants tirés par crypto.randomUUID, les
|
||||
// démonstrations livrées nommées par le catalogue — et monte App dans le
|
||||
// démonstrations livrées nommées par le catalogue, le chargement de la
|
||||
// police embarquée — et monte App dans le
|
||||
// corps du document. Le thème de départ est laissé à App, qui lit la
|
||||
// préférence du système.
|
||||
//
|
||||
|
|
@ -21,12 +22,17 @@
|
|||
// hyperthreads compris : l'exécuteur l'annonce au contrôleur, qui en fait
|
||||
// la valeur des cœurs automatiques ; une épreuve donne le sien à
|
||||
// l'exécuteur qu'elle monte.
|
||||
//
|
||||
// La police embarquée (src/polices) n'est importée que d'ici et du montage
|
||||
// des épreuves : App reçoit chargerPolices, dont les deux import() différés
|
||||
// ne chargent les graisses qu'à la première production qui les demande.
|
||||
import { mount } from 'svelte';
|
||||
import { creerExecuteurTravailleur } from './application/executeur_travailleur.js';
|
||||
import { creerHorlogeSysteme } from './application/horloge.js';
|
||||
import { demonstrationsLivrees } from './demo/livrees.js';
|
||||
import App from './interface/App.svelte';
|
||||
import Travailleur from './interface/generation/travailleur.js?worker&inline';
|
||||
import { chargerPolices } from './polices/chargement.js';
|
||||
import { FORMAT } from './stockage/document.js';
|
||||
import { creerFichiersElectron } from './stockage/fichiers_electron.js';
|
||||
import { creerFichiersWeb } from './stockage/fichiers_web.js';
|
||||
|
|
@ -56,5 +62,6 @@ mount(App, {
|
|||
formatCourant: FORMAT,
|
||||
produitVersion: VERSION.affichee,
|
||||
versionWindows: VERSION.windows,
|
||||
polices: chargerPolices,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
259
src/pdf/imposition.js
Normal file
259
src/pdf/imposition.js
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'imposition de la planche de badges (§ 11.2, § 11.3, § 11.6) : où
|
||||
// tombent les badges sur une feuille, combien de feuilles, dans quel ordre,
|
||||
// et où passent les filets de coupe. Fonctions pures, sans bibliothèque ;
|
||||
// les cotes sont en millimètres, l'origine en haut à gauche de la feuille.
|
||||
//
|
||||
// La disposition se déduit des cotes du papier et du badge (papiers.js),
|
||||
// sans table par papier : un papier ajouté à PAPIERS reçoit la sienne.
|
||||
// Deux grilles sont candidates, le badge debout (105 de large, 148 de haut)
|
||||
// ou à plat (148 × 105, tourné de 90° dans le sens trigonométrique) ;
|
||||
// chacune range autant de colonnes et de rangées entières que la feuille en
|
||||
// contient. La grille qui porte le plus de badges l'emporte. À égalité,
|
||||
// celle dont le bloc, centré, laisse la plus grande marge au bord le plus
|
||||
// proche : un filet trop près du bord tombe dans la marge que l'imprimante
|
||||
// n'atteint pas. Sur A4, la grille debout porte quatre badges contre deux ;
|
||||
// sur Lettre, les deux en portent deux, et le bloc à plat laisse 33,95 mm
|
||||
// au bord quand le bloc debout n'en laisse que 2,95.
|
||||
//
|
||||
// Un bloc qui épouse un côté de la feuille part de son coin haut gauche :
|
||||
// le bord du badge y est le bord de la feuille, sans filet, et ce qui reste
|
||||
// fait une seule bande au pied (ou à droite), qu'une coupe retire — le
|
||||
// millimètre perdu de l'A4, jamais réparti dans la hauteur des badges, qui
|
||||
// restent des A6 exacts. Un bloc qui n'en épouse aucun se centre, et
|
||||
// l'encadrement complet le détoure.
|
||||
//
|
||||
// Les calculs se font en centièmes de millimètre entiers, puis se rendent en
|
||||
// millimètres : 215,9 − 148 vaut 67,9 sans erreur d'arrondi, deux filets
|
||||
// colinéaires se reconnaissent par égalité exacte, et 33,95 se lit 33.95.
|
||||
import { constante } from '../geometrie/constantes.js';
|
||||
import { BADGE, PAPIERS } from '../geometrie/papiers.js';
|
||||
|
||||
/**
|
||||
* Une cellule de la planche : la place d'un badge.
|
||||
*
|
||||
* @typedef {Object} Cellule
|
||||
* @property {number} x mm, bord gauche
|
||||
* @property {number} y mm, bord haut
|
||||
* @property {number} largeur mm
|
||||
* @property {number} hauteur mm
|
||||
* @property {boolean} aPlat le badge tourné de 90° dans le sens
|
||||
* trigonométrique, son haut à gauche
|
||||
*/
|
||||
|
||||
/** @typedef {'feuille'|'pile'} Imposition */
|
||||
|
||||
const IMPOSITIONS = new Set(['feuille', 'pile']);
|
||||
|
||||
const enCentiemes = (mm) => Math.round(mm * 100);
|
||||
const enMm = (centiemes) => centiemes / 100;
|
||||
|
||||
// Les cotes du papier en centièmes ; TypeError hors de PAPIERS. Une clé
|
||||
// héritée d'Object.prototype n'en est pas une.
|
||||
function coteDuPapier(papier) {
|
||||
if (typeof papier !== 'string' || !Object.hasOwn(PAPIERS, papier)) {
|
||||
throw new TypeError(`papier inconnu : ${JSON.stringify(papier)}`);
|
||||
}
|
||||
const { largeur, hauteur } = PAPIERS[papier];
|
||||
return { W: enCentiemes(largeur), H: enCentiemes(hauteur) };
|
||||
}
|
||||
|
||||
// La grille d'un papier, en centièmes : origine du bloc, colonnes, rangées,
|
||||
// cote d'une cellule, orientation du badge.
|
||||
function grilleDe(papier) {
|
||||
const { W, H } = coteDuPapier(papier);
|
||||
const debout = { l: enCentiemes(BADGE.largeur), h: enCentiemes(BADGE.hauteur), aPlat: false };
|
||||
const aPlat = { l: debout.h, h: debout.l, aPlat: true };
|
||||
const candidates = [debout, aPlat].map((cellule) => {
|
||||
const colonnes = Math.floor(W / cellule.l);
|
||||
const rangees = Math.floor(H / cellule.h);
|
||||
const bloc = { l: colonnes * cellule.l, h: rangees * cellule.h };
|
||||
return { ...cellule, colonnes, rangees, bloc, marge: Math.min(W - bloc.l, H - bloc.h) / 2 };
|
||||
});
|
||||
const [a, b] = candidates;
|
||||
const nombre = (g) => g.colonnes * g.rangees;
|
||||
const retenue = nombre(b) > nombre(a) || (nombre(b) === nombre(a) && b.marge > a.marge) ? b : a;
|
||||
const epouse = retenue.bloc.l === W || retenue.bloc.h === H;
|
||||
return {
|
||||
...retenue,
|
||||
W,
|
||||
H,
|
||||
x0: epouse ? 0 : (W - retenue.bloc.l) / 2,
|
||||
y0: epouse ? 0 : (H - retenue.bloc.h) / 2,
|
||||
};
|
||||
}
|
||||
|
||||
// Les cellules d'une grille, en centièmes, rangée par rangée de haut en bas,
|
||||
// de gauche à droite dans une rangée.
|
||||
function cellulesEnCentiemes(grille) {
|
||||
const cellulesGrille = [];
|
||||
for (let rangee = 0; rangee < grille.rangees; rangee += 1) {
|
||||
for (let colonne = 0; colonne < grille.colonnes; colonne += 1) {
|
||||
cellulesGrille.push({ x: grille.x0 + colonne * grille.l, y: grille.y0 + rangee * grille.h, l: grille.l, h: grille.h });
|
||||
}
|
||||
}
|
||||
return cellulesGrille;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les cellules d'une planche, rangée par rangée de haut en bas, de gauche à
|
||||
* droite. A4 : quatre cellules de 105 × 148 depuis (0, 0), la bande perdue
|
||||
* de 1 mm au pied. Lettre : deux cellules de 148 × 105 à plat, en un bloc
|
||||
* de 148 × 210 centré, à (33,95 ; 34,7) et (33,95 ; 139,7).
|
||||
*
|
||||
* @param {import('../geometrie/papiers.js').Papier} papier
|
||||
* @returns {Cellule[]} neuves à chaque appel
|
||||
* @throws {TypeError} un papier hors de PAPIERS
|
||||
*/
|
||||
export function cellules(papier) {
|
||||
const grille = grilleDe(papier);
|
||||
return cellulesEnCentiemes(grille).map(({ x, y, l, h }) => ({
|
||||
x: enMm(x),
|
||||
y: enMm(y),
|
||||
largeur: enMm(l),
|
||||
hauteur: enMm(h),
|
||||
aPlat: grille.aPlat,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Le nombre de badges d'une planche : 4 sur A4, 2 sur Lettre.
|
||||
*
|
||||
* @param {import('../geometrie/papiers.js').Papier} papier
|
||||
* @returns {number}
|
||||
* @throws {TypeError} un papier hors de PAPIERS
|
||||
*/
|
||||
export function parPlanche(papier) {
|
||||
const { colonnes, rangees } = grilleDe(papier);
|
||||
return colonnes * rangees;
|
||||
}
|
||||
|
||||
// n, un entier ≥ 0, ou RangeError.
|
||||
function compte(n) {
|
||||
if (!Number.isSafeInteger(n) || n < 0) throw new RangeError(`nombre de badges entier ≥ 0 attendu, reçu ${String(n)}`);
|
||||
return n;
|
||||
}
|
||||
|
||||
/**
|
||||
* Le nombre de planches de n badges : ⌈n / parPlanche⌉, 0 pour n = 0.
|
||||
*
|
||||
* @param {number} n entier ≥ 0, les badges produits
|
||||
* @param {import('../geometrie/papiers.js').Papier} papier
|
||||
* @returns {number}
|
||||
* @throws {RangeError} un n qui n'est pas un entier ≥ 0
|
||||
* @throws {TypeError} un papier hors de PAPIERS
|
||||
*/
|
||||
export function nombrePlanches(n, papier) {
|
||||
return Math.ceil(compte(n) / parPlanche(papier));
|
||||
}
|
||||
|
||||
/**
|
||||
* L'ordre des badges sur les planches (§ 11.6) : par planche, l'indice
|
||||
* 0…n−1 du badge de chaque cellule, dans l'ordre de cellules(papier), null
|
||||
* pour une cellule vide. Avec S planches de P cellules, la cellule p (1…P)
|
||||
* de la planche i (1…S) porte le badge P(i−1)+p feuille par feuille, et
|
||||
* (p−1)S+i en pile : la rame coupée, la pile de chaque cellule se lit dans
|
||||
* l'ordre, et les piles 1…P posées l'une sur l'autre redonnent 1…n. Les
|
||||
* cellules vides, au-delà de n, sont au bout de cet ordre.
|
||||
*
|
||||
* @param {number} n entier ≥ 0
|
||||
* @param {import('../geometrie/papiers.js').Papier} papier
|
||||
* @param {Imposition} imposition
|
||||
* @returns {Array<Array<number|null>>} S planches de P places ; [] pour n = 0
|
||||
* @throws {RangeError} un n qui n'est pas un entier ≥ 0
|
||||
* @throws {TypeError} un papier ou une imposition inconnus
|
||||
*/
|
||||
export function imposer(n, papier, imposition) {
|
||||
const S = nombrePlanches(n, papier);
|
||||
if (!IMPOSITIONS.has(imposition)) throw new TypeError(`imposition inconnue : ${JSON.stringify(imposition)}`);
|
||||
const P = parPlanche(papier);
|
||||
const planches = [];
|
||||
for (let i = 0; i < S; i += 1) {
|
||||
const planche = [];
|
||||
for (let p = 0; p < P; p += 1) {
|
||||
const badge = imposition === 'feuille' ? P * i + p : p * S + i;
|
||||
planche.push(badge < n ? badge : null);
|
||||
}
|
||||
planches.push(planche);
|
||||
}
|
||||
return planches;
|
||||
}
|
||||
|
||||
// Les intervalles [debut, fin] donnés, réunis quand ils se chevauchent ou
|
||||
// se touchent, triés par début.
|
||||
function reunir(intervalles) {
|
||||
const tries = [...intervalles].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
const reunis = [];
|
||||
for (const [debut, fin] of tries) {
|
||||
const dernier = reunis.at(-1);
|
||||
if (dernier !== undefined && debut <= dernier[1]) dernier[1] = Math.max(dernier[1], fin);
|
||||
else reunis.push([debut, fin]);
|
||||
}
|
||||
return reunis;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les filets de coupe d'une planche (§ 11.3) : continus, sur les bords des
|
||||
* cellules occupées qui ne sont pas le bord de la feuille, réunis en un
|
||||
* segment par axe quand ils se touchent. Un segment ne se trace que s'il
|
||||
* borde une cellule occupée : une cellule vide d'une dernière planche reste
|
||||
* sans filet ni mention. Les verticaux d'abord, par x puis par y, puis les
|
||||
* horizontaux, par y puis par x.
|
||||
*
|
||||
* A4, planche pleine : x = 105 de y 0 à 296 ; y = 148 et y = 296 de x 0 à
|
||||
* 210 — trois axes. Lettre, pleine : x = 33,95 et 181,95 de y 34,7 à
|
||||
* 244,7 ; y = 34,7, 139,7 et 244,7 de x 33,95 à 181,95 — cinq axes.
|
||||
*
|
||||
* @param {import('../geometrie/papiers.js').Papier} papier
|
||||
* @param {boolean[]} occupees une valeur par cellule, dans l'ordre de cellules(papier)
|
||||
* @returns {Array<{x1: number, y1: number, x2: number, y2: number}>} mm, x1 ≤ x2, y1 ≤ y2
|
||||
* @throws {TypeError} un papier hors de PAPIERS, une valeur qui n'est pas un booléen
|
||||
* @throws {RangeError} une occupation qui n'a pas une valeur par cellule
|
||||
*/
|
||||
export function filets(papier, occupees) {
|
||||
const grille = grilleDe(papier);
|
||||
const toutes = cellulesEnCentiemes(grille);
|
||||
if (!Array.isArray(occupees) || occupees.length !== toutes.length) {
|
||||
throw new RangeError(`filets : ${toutes.length} occupations attendues, une par cellule`);
|
||||
}
|
||||
if (!occupees.every((valeur) => typeof valeur === 'boolean')) throw new TypeError('filets : occupation booléenne attendue');
|
||||
const verticaux = new Map();
|
||||
const horizontaux = new Map();
|
||||
const ajouter = (axes, position, intervalle) => axes.set(position, [...(axes.get(position) ?? []), intervalle]);
|
||||
toutes.forEach(({ x, y, l, h }, rang) => {
|
||||
if (!occupees[rang]) return;
|
||||
for (const bord of [x, x + l]) if (bord !== 0 && bord !== grille.W) ajouter(verticaux, bord, [y, y + h]);
|
||||
for (const bord of [y, y + h]) if (bord !== 0 && bord !== grille.H) ajouter(horizontaux, bord, [x, x + l]);
|
||||
});
|
||||
const segments = (axes, vertical) =>
|
||||
[...axes.keys()]
|
||||
.sort((a, b) => a - b)
|
||||
.flatMap((position) =>
|
||||
reunir(axes.get(position)).map(([debut, fin]) =>
|
||||
vertical
|
||||
? { x1: enMm(position), y1: enMm(debut), x2: enMm(position), y2: enMm(fin) }
|
||||
: { x1: enMm(debut), y1: enMm(position), x2: enMm(fin), y2: enMm(position) },
|
||||
),
|
||||
);
|
||||
return [...segments(verticaux, true), ...segments(horizontaux, false)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Le cadre utile d'une cellule : la cellule en retrait de ZONE_SILENCE_MM
|
||||
* de chaque bord, où seuls texte et marques se posent (§ 11.3) — 89 × 132
|
||||
* pour un badge debout, 132 × 89 à plat.
|
||||
*
|
||||
* @param {Cellule} cellule
|
||||
* @returns {{x: number, y: number, largeur: number, hauteur: number}} mm
|
||||
*/
|
||||
export function cadreUtile({ x, y, largeur, hauteur }) {
|
||||
const zone = enCentiemes(constante('ZONE_SILENCE_MM'));
|
||||
return {
|
||||
x: enMm(enCentiemes(x) + zone),
|
||||
y: enMm(enCentiemes(y) + zone),
|
||||
largeur: enMm(enCentiemes(largeur) - 2 * zone),
|
||||
hauteur: enMm(enCentiemes(hauteur) - 2 * zone),
|
||||
};
|
||||
}
|
||||
231
src/pdf/imposition.test.js
Normal file
231
src/pdf/imposition.test.js
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// L'imposition de la planche (§ 11.2, § 11.3, § 11.6), éprouvée à égalité
|
||||
// sur les deux papiers : les cellules au millimètre, la bande perdue de
|
||||
// l'A4, le bloc centré du Lettre, les filets qui ne bordent que des
|
||||
// cellules occupées, le cadre utile en retrait de la zone de silence, la
|
||||
// pagination sur les comptes qui séparent troncature et arrondi par excès,
|
||||
// et l'ordre des deux impositions.
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { constante } from '../geometrie/constantes.js';
|
||||
import { PAPIERS } from '../geometrie/papiers.js';
|
||||
import { cadreUtile, cellules, filets, imposer, nombrePlanches, parPlanche } from './imposition.js';
|
||||
|
||||
const IMPOSITIONS = ['feuille', 'pile'];
|
||||
const NOMS_PAPIERS = ['A4', 'Lettre'];
|
||||
|
||||
// Égalité à un millionième de millimètre : les cotes du Lettre ne sont pas
|
||||
// des entiers.
|
||||
const proche = (a, b) => Math.abs(a - b) < 1e-6;
|
||||
|
||||
describe('imposition : les cellules (§ 11.2)', () => {
|
||||
test('A4 : quatre cellules de 105 × 148 exactement, deux colonnes et deux rangées depuis le coin haut gauche, debout', () => {
|
||||
assert.deepEqual(cellules('A4'), [
|
||||
{ x: 0, y: 0, largeur: 105, hauteur: 148, aPlat: false },
|
||||
{ x: 105, y: 0, largeur: 105, hauteur: 148, aPlat: false },
|
||||
{ x: 0, y: 148, largeur: 105, hauteur: 148, aPlat: false },
|
||||
{ x: 105, y: 148, largeur: 105, hauteur: 148, aPlat: false },
|
||||
]);
|
||||
});
|
||||
|
||||
test('A4 : les deux colonnes occupent toute la largeur, et une bande perdue de 1 mm reste au pied', () => {
|
||||
const toutes = cellules('A4');
|
||||
assert.equal(Math.max(...toutes.map((c) => c.x + c.largeur)), PAPIERS.A4.largeur);
|
||||
assert.equal(PAPIERS.A4.hauteur - Math.max(...toutes.map((c) => c.y + c.hauteur)), 1);
|
||||
});
|
||||
|
||||
test('Lettre : deux cellules de 148 × 105 à plat, en un bloc de 148 × 210 centré sur la feuille', () => {
|
||||
const toutes = cellules('Lettre');
|
||||
assert.deepEqual(toutes, [
|
||||
{ x: 33.95, y: 34.7, largeur: 148, hauteur: 105, aPlat: true },
|
||||
{ x: 33.95, y: 139.7, largeur: 148, hauteur: 105, aPlat: true },
|
||||
]);
|
||||
const { largeur, hauteur } = PAPIERS.Lettre;
|
||||
const gauche = toutes[0].x;
|
||||
const droite = largeur - (toutes[0].x + toutes[0].largeur);
|
||||
const haut = toutes[0].y;
|
||||
const bas = hauteur - (toutes[1].y + toutes[1].hauteur);
|
||||
assert.ok(proche(gauche, droite) && proche(haut, bas), `${gauche} ${droite} ${haut} ${bas}`);
|
||||
});
|
||||
|
||||
test('chaque appel rend des cellules neuves ; un papier hors de PAPIERS lève TypeError', () => {
|
||||
const a = cellules('A4');
|
||||
a[0].x = 99;
|
||||
assert.equal(cellules('A4')[0].x, 0);
|
||||
for (const papier of ['A5', 'lettre', '', null, undefined, 'toString', '__proto__']) {
|
||||
assert.throws(() => cellules(papier), TypeError, String(papier));
|
||||
assert.throws(() => parPlanche(papier), TypeError, String(papier));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('imposition : la pagination (§ 11.2)', () => {
|
||||
test('parPlanche : 4 sur A4, 2 sur Lettre', () => {
|
||||
assert.equal(parPlanche('A4'), 4);
|
||||
assert.equal(parPlanche('Lettre'), 2);
|
||||
});
|
||||
|
||||
test('A4 : n = 1, 4, 5, 259, 261 donnent 1, 1, 2, 65, 66 planches ; Lettre : n = 1, 2, 3, 257 donnent 1, 1, 2, 129', () => {
|
||||
assert.deepEqual([1, 4, 5, 259, 261].map((n) => nombrePlanches(n, 'A4')), [1, 1, 2, 65, 66]);
|
||||
assert.deepEqual([1, 2, 3, 257].map((n) => nombrePlanches(n, 'Lettre')), [1, 1, 2, 129]);
|
||||
});
|
||||
|
||||
test('n = 0 ne donne aucune planche, sur les deux papiers ; un compte qui n’est pas un entier ≥ 0 lève RangeError', () => {
|
||||
assert.equal(nombrePlanches(0, 'A4'), 0);
|
||||
assert.equal(nombrePlanches(0, 'Lettre'), 0);
|
||||
for (const n of [-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, '3', null]) {
|
||||
assert.throws(() => nombrePlanches(n, 'A4'), RangeError, String(n));
|
||||
assert.throws(() => imposer(n, 'A4', 'feuille'), RangeError, String(n));
|
||||
}
|
||||
assert.throws(() => nombrePlanches(3, 'A5'), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('imposition : l’ordre des badges (§ 11.6)', () => {
|
||||
test('cinq badges sur A4 : feuille par feuille, puis en pile', () => {
|
||||
assert.deepEqual(imposer(5, 'A4', 'feuille'), [
|
||||
[0, 1, 2, 3],
|
||||
[4, null, null, null],
|
||||
]);
|
||||
assert.deepEqual(imposer(5, 'A4', 'pile'), [
|
||||
[0, 2, 4, null],
|
||||
[1, 3, null, null],
|
||||
]);
|
||||
assert.deepEqual(imposer(3, 'Lettre', 'pile'), [
|
||||
[0, 2],
|
||||
[1, null],
|
||||
]);
|
||||
assert.deepEqual(imposer(0, 'Lettre', 'feuille'), []);
|
||||
});
|
||||
|
||||
test('n de 1 à 40, deux papiers, deux impositions : chaque badge une fois ; en pile, empiler les piles 1…P redonne 1…n puis des blancs seulement', () => {
|
||||
for (const papier of NOMS_PAPIERS) {
|
||||
const P = parPlanche(papier);
|
||||
for (const imposition of IMPOSITIONS) {
|
||||
for (let n = 1; n <= 40; n += 1) {
|
||||
const planches = imposer(n, papier, imposition);
|
||||
const S = nombrePlanches(n, papier);
|
||||
const cas = `${papier} ${imposition} n=${n}`;
|
||||
assert.equal(planches.length, S, cas);
|
||||
assert.ok(planches.every((planche) => planche.length === P), cas);
|
||||
const places = planches.flat();
|
||||
assert.deepEqual(places.filter((b) => b !== null).sort((a, b) => a - b), [...Array(n).keys()], cas);
|
||||
// L'ordre de lecture de l'imposition : planche après planche pour
|
||||
// la feuille ; pour la pile, la pile de la cellule 1 sur toutes les
|
||||
// planches, puis celle de la cellule 2, et ainsi de suite.
|
||||
const lecture =
|
||||
imposition === 'feuille'
|
||||
? places
|
||||
: [...Array(P).keys()].flatMap((p) => planches.map((planche) => planche[p]));
|
||||
const blancs = lecture.slice(n);
|
||||
assert.deepEqual(lecture.slice(0, n), [...Array(n).keys()], cas);
|
||||
assert.ok(blancs.every((b) => b === null), cas);
|
||||
assert.equal(blancs.length, S * P - n, cas);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('une imposition inconnue, ou un papier inconnu, lève TypeError', () => {
|
||||
for (const imposition of ['', 'Feuille', 'pages', null]) {
|
||||
assert.throws(() => imposer(4, 'A4', imposition), TypeError, String(imposition));
|
||||
}
|
||||
assert.throws(() => imposer(4, 'A3', 'feuille'), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('imposition : les filets de coupe (§ 11.3)', () => {
|
||||
test('A4, planche pleine : trois axes — x = 105 de y 0 à 296, y = 148 et y = 296 de x 0 à 210', () => {
|
||||
assert.deepEqual(filets('A4', [true, true, true, true]), [
|
||||
{ x1: 105, y1: 0, x2: 105, y2: 296 },
|
||||
{ x1: 0, y1: 148, x2: 210, y2: 148 },
|
||||
{ x1: 0, y1: 296, x2: 210, y2: 296 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('Lettre, planche pleine : cinq axes, l’encadrement complet du bloc', () => {
|
||||
assert.deepEqual(filets('Lettre', [true, true]), [
|
||||
{ x1: 33.95, y1: 34.7, x2: 33.95, y2: 244.7 },
|
||||
{ x1: 181.95, y1: 34.7, x2: 181.95, y2: 244.7 },
|
||||
{ x1: 33.95, y1: 34.7, x2: 181.95, y2: 34.7 },
|
||||
{ x1: 33.95, y1: 139.7, x2: 181.95, y2: 139.7 },
|
||||
{ x1: 33.95, y1: 244.7, x2: 181.95, y2: 244.7 },
|
||||
]);
|
||||
});
|
||||
|
||||
test('une dernière planche partielle ne trace que les segments qui bordent ses cellules occupées', () => {
|
||||
const derniere = (n, papier) => imposer(n, papier, 'feuille').at(-1).map((badge) => badge !== null);
|
||||
assert.deepEqual(filets('A4', derniere(5, 'A4')), [
|
||||
{ x1: 105, y1: 0, x2: 105, y2: 148 },
|
||||
{ x1: 0, y1: 148, x2: 105, y2: 148 },
|
||||
]);
|
||||
assert.deepEqual(filets('A4', derniere(7, 'A4')), [
|
||||
{ x1: 105, y1: 0, x2: 105, y2: 296 },
|
||||
{ x1: 0, y1: 148, x2: 210, y2: 148 },
|
||||
{ x1: 0, y1: 296, x2: 105, y2: 296 },
|
||||
]);
|
||||
assert.deepEqual(filets('Lettre', derniere(3, 'Lettre')), [
|
||||
{ x1: 33.95, y1: 34.7, x2: 33.95, y2: 139.7 },
|
||||
{ x1: 181.95, y1: 34.7, x2: 181.95, y2: 139.7 },
|
||||
{ x1: 33.95, y1: 34.7, x2: 181.95, y2: 34.7 },
|
||||
{ x1: 33.95, y1: 139.7, x2: 181.95, y2: 139.7 },
|
||||
]);
|
||||
assert.deepEqual(filets('A4', [false, false, false, false]), []);
|
||||
});
|
||||
|
||||
test('pour chaque occupation, chaque bord d’une cellule occupée hors du bord de la feuille est sur un filet, et chaque filet ne court que le long de cellules occupées', () => {
|
||||
for (const papier of NOMS_PAPIERS) {
|
||||
const toutes = cellules(papier);
|
||||
const { largeur, hauteur } = PAPIERS[papier];
|
||||
for (let masque = 0; masque < 2 ** toutes.length; masque += 1) {
|
||||
const occupees = toutes.map((_, rang) => (masque & (1 << rang)) !== 0);
|
||||
const traces = filets(papier, occupees);
|
||||
const bords = toutes
|
||||
.filter((_, rang) => occupees[rang])
|
||||
.flatMap(({ x, y, largeur: l, hauteur: h }) => [
|
||||
{ x1: x, y1: y, x2: x + l, y2: y },
|
||||
{ x1: x, y1: y + h, x2: x + l, y2: y + h },
|
||||
{ x1: x, y1: y, x2: x, y2: y + h },
|
||||
{ x1: x + l, y1: y, x2: x + l, y2: y + h },
|
||||
])
|
||||
.filter((b) => !(b.x1 === b.x2 && (proche(b.x1, 0) || proche(b.x1, largeur))))
|
||||
.filter((b) => !(b.y1 === b.y2 && (proche(b.y1, 0) || proche(b.y1, hauteur))));
|
||||
const couvre = (f, b) =>
|
||||
(f.x1 === f.x2 && b.x1 === b.x2 && proche(f.x1, b.x1) && f.y1 <= b.y1 + 1e-6 && b.y2 <= f.y2 + 1e-6) ||
|
||||
(f.y1 === f.y2 && b.y1 === b.y2 && proche(f.y1, b.y1) && f.x1 <= b.x1 + 1e-6 && b.x2 <= f.x2 + 1e-6);
|
||||
const cas = `${papier} ${occupees}`;
|
||||
assert.ok(bords.every((b) => traces.some((f) => couvre(f, b))), cas);
|
||||
// Un filet est la réunion de bords de cellules occupées : sa longueur
|
||||
// ne dépasse pas celle des bords qu'il couvre.
|
||||
for (const f of traces) {
|
||||
const couverts = bords.filter((b) => couvre(f, b));
|
||||
const longueur = (s) => Math.abs(s.x2 - s.x1) + Math.abs(s.y2 - s.y1);
|
||||
const union = new Set(couverts.map((b) => `${b.x1},${b.y1},${b.x2},${b.y2}`));
|
||||
const somme = [...union].map((cle) => cle.split(',').map(Number)).reduce((s, [x1, y1, x2, y2]) => s + longueur({ x1, y1, x2, y2 }), 0);
|
||||
assert.ok(longueur(f) <= somme + 1e-6, `${cas} ${JSON.stringify(f)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('une occupation qui n’a pas une valeur par cellule lève RangeError ; une valeur qui n’est pas un booléen, TypeError', () => {
|
||||
assert.throws(() => filets('A4', [true, true]), RangeError);
|
||||
assert.throws(() => filets('Lettre', [true, true, true]), RangeError);
|
||||
assert.throws(() => filets('A4', null), RangeError);
|
||||
assert.throws(() => filets('A4', [1, 0, 0, 0]), TypeError);
|
||||
assert.throws(() => filets('Lettre', [true, 'oui']), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('imposition : le cadre utile (§ 11.3)', () => {
|
||||
test('la zone de silence retire ZONE_SILENCE_MM de chaque bord : 89 × 132 debout, 132 × 89 à plat', () => {
|
||||
const zone = constante('ZONE_SILENCE_MM');
|
||||
assert.equal(zone, 8);
|
||||
assert.deepEqual(cadreUtile(cellules('A4')[3]), { x: 113, y: 156, largeur: 89, hauteur: 132 });
|
||||
const aPlat = cadreUtile(cellules('Lettre')[1]);
|
||||
assert.ok(proche(aPlat.x, 41.95) && proche(aPlat.y, 147.7), JSON.stringify(aPlat));
|
||||
assert.deepEqual([aPlat.largeur, aPlat.hauteur], [132, 89]);
|
||||
});
|
||||
});
|
||||
BIN
src/polices/NotoSans-Bold.ttf
Normal file
BIN
src/polices/NotoSans-Bold.ttf
Normal file
Binary file not shown.
BIN
src/polices/NotoSans-Regular.ttf
Normal file
BIN
src/polices/NotoSans-Regular.ttf
Normal file
Binary file not shown.
15
src/polices/PROVENANCE
Normal file
15
src/polices/PROVENANCE
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# La police embarquée : Noto Sans, Regular et Bold, tirée telle quelle de la
|
||||
# publication amont, sans réduction ni transformation. La construction ne
|
||||
# télécharge rien : ces fichiers sont versionnés ici. test/polices.test.js
|
||||
# compare la taille et l'empreinte de chaque fichier à ce texte.
|
||||
version : 2.015
|
||||
projet : https://github.com/notofonts/latin-greek-cyrillic, étiquette NotoSans-v2.015
|
||||
source : https://github.com/notofonts/latin-greek-cyrillic/releases/download/NotoSans-v2.015/NotoSans-v2.015.zip
|
||||
archive : sha256 0c34df072a3fa7efbb7cbf34950e1f971a4447cffe365d3a359e2d4089b958f5
|
||||
variante : hinted (ttfautohint), la police complète, latin, grec et cyrillique
|
||||
licence : SIL Open Font License 1.1, aucun nom de police réservé
|
||||
fichier NotoSans-Regular.ttf : NotoSans/hinted/ttf/NotoSans-Regular.ttf, 621572 octets, sha256 478c558ea716033cd60c03438f628dfa75694dcf6b5f6d505a2f05fd2b4f3823
|
||||
fichier NotoSans-Bold.ttf : NotoSans/hinted/ttf/NotoSans-Bold.ttf, 631484 octets, sha256 1df075a380fc7cb898acf64c1f7b3b4dd780de3caa860178bf929de35817a913
|
||||
fichier NotoSans-OFL.txt : OFL.txt, 4396 octets, sha256 cee9892f9f0cc8fe882c9e9537ee6a89621d86ee7ceaf70b02e2b2b1c25c061a
|
||||
# NotoSans-OFL.txt vit dans public/licences/, son seul emplacement : Vite le
|
||||
# copie tel quel dans www/licences/, à côté de TIERS.txt.
|
||||
52
src/polices/chargement.js
Normal file
52
src/polices/chargement.js
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le chargement de la police embarquée, Noto Sans Regular et Bold, dont la
|
||||
// production de la planche (src/pdf) embarque les octets (§ 11.1). Chaque
|
||||
// graisse est un module à part, regulier.js et gras.js, que chargerPolices
|
||||
// importe par deux import() différés : la construction en fait deux
|
||||
// morceaux, que la page ne télécharge ni ne précharge au lancement.
|
||||
//
|
||||
// « ?inline » rend sous Vite le fichier en URL data: base64 : la police
|
||||
// voyage dans le morceau, et se lit sans requête, sous file:// où la
|
||||
// coquille charge la page comme sous http://. chargerPolices la décode en
|
||||
// Uint8Array. Ce module n'est importé que par src/main.js et par le montage
|
||||
// des épreuves du navigateur, qui le passent à App (Dependances.polices) ;
|
||||
// les épreuves node lisent les mêmes fichiers par le disque
|
||||
// (test/polices.js), node ne connaissant pas « ?inline ».
|
||||
|
||||
// Une URL data: en base64 : un type, des paramètres éventuels, « ;base64, »,
|
||||
// puis l'alphabet base64 et son remplissage.
|
||||
const DONNEES_BASE64 = /^data:[^,;]*(?:;[^,;=]+=[^,;]*)*;base64,([A-Za-z0-9+/]*={0,2})$/;
|
||||
|
||||
/**
|
||||
* Les octets d'une URL data: en base64.
|
||||
*
|
||||
* @param {string} texte
|
||||
* @returns {Uint8Array} un tableau neuf à chaque appel
|
||||
* @throws {TypeError} tout texte qui n'est pas une URL data: en base64 — une
|
||||
* adresse d'actif, par exemple, qu'une autre configuration de Vite
|
||||
* rendrait à la place des données
|
||||
*/
|
||||
export function decoderDonnees(texte) {
|
||||
const lu = typeof texte === 'string' ? DONNEES_BASE64.exec(texte) : null;
|
||||
if (lu === null) {
|
||||
const debut = typeof texte === 'string' ? JSON.stringify(texte.slice(0, 40)) : String(texte);
|
||||
throw new TypeError(`decoderDonnees : URL data: en base64 attendue, reçu ${debut}`);
|
||||
}
|
||||
const binaire = atob(lu[1]);
|
||||
const octets = new Uint8Array(binaire.length);
|
||||
for (let i = 0; i < binaire.length; i += 1) octets[i] = binaire.charCodeAt(i);
|
||||
return octets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Les octets des deux graisses de la police embarquée.
|
||||
*
|
||||
* @returns {Promise<{regulier: Uint8Array, gras: Uint8Array}>} des tableaux
|
||||
* neufs à chaque appel : un appelant peut écrire dans les siens
|
||||
*/
|
||||
export async function chargerPolices() {
|
||||
const [{ default: regulier }, { default: gras }] = await Promise.all([import('./regulier.js'), import('./gras.js')]);
|
||||
return { regulier: decoderDonnees(regulier), gras: decoderDonnees(gras) };
|
||||
}
|
||||
49
src/polices/chargement.navigateur.test.js
Normal file
49
src/polices/chargement.navigateur.test.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Le chargement de la police dans la page, sous Vite comme dans la
|
||||
// construction (§ 14.15) : chargerPolices rend les octets des deux fichiers,
|
||||
// décodés de l'URL data: que « ?inline » incruste, chacun de la taille et
|
||||
// de l'empreinte que PROVENANCE déclare. Les octets nourrissent fontkit tels
|
||||
// quels, et la chaîne témoin y garde la longueur calculée sous node.
|
||||
import { describe, expect, test } from 'vitest';
|
||||
import provenance from './PROVENANCE?raw';
|
||||
import { chargerPolices, decoderDonnees } from './chargement.js';
|
||||
import { CHAINE_TEMOIN, lireProvenance, longueurTemoin } from '../../test/temoin.js';
|
||||
|
||||
const hex = (tampon) => [...new Uint8Array(tampon)].map((o) => o.toString(16).padStart(2, '0')).join('');
|
||||
|
||||
describe('polices : le chargement dans la page', () => {
|
||||
test('chargerPolices rend deux Uint8Array, de la taille et de l’empreinte SHA-256 que PROVENANCE déclare', async () => {
|
||||
const { fichiers } = lireProvenance(provenance);
|
||||
const polices = await chargerPolices();
|
||||
for (const [graisse, nom] of [['regulier', 'NotoSans-Regular.ttf'], ['gras', 'NotoSans-Bold.ttf']]) {
|
||||
const octets = polices[graisse];
|
||||
const declare = fichiers.find((f) => f.nom === nom);
|
||||
expect(octets, graisse).toBeInstanceOf(Uint8Array);
|
||||
expect(octets.length, graisse).toBe(declare.octets);
|
||||
expect(hex(await crypto.subtle.digest('SHA-256', octets)), graisse).toBe(declare.sha256);
|
||||
}
|
||||
});
|
||||
|
||||
test('deux appels rendent des tableaux distincts, de même contenu : un appelant qui écrit dans le sien ne touche pas l’autre', async () => {
|
||||
const a = await chargerPolices();
|
||||
const b = await chargerPolices();
|
||||
expect(a.regulier).not.toBe(b.regulier);
|
||||
expect(a.regulier).toEqual(b.regulier);
|
||||
});
|
||||
|
||||
test('les octets chargés se lisent par fontkit : la chaîne témoin y a la longueur que les avances donnent', async () => {
|
||||
const { regulier, gras } = await chargerPolices();
|
||||
expect(longueurTemoin(regulier, 16, CHAINE_TEMOIN)).toBeGreaterThan(0);
|
||||
expect(longueurTemoin(gras, 16)).toBeGreaterThan(longueurTemoin(regulier, 16));
|
||||
});
|
||||
|
||||
test('decoderDonnees décode une URL data: en base64, et refuse toute autre chaîne sans la rendre telle quelle', () => {
|
||||
expect([...decoderDonnees('data:font/ttf;base64,AAEC/w==')]).toEqual([0, 1, 2, 255]);
|
||||
expect([...decoderDonnees('data:application/octet-stream;base64,')]).toEqual([]);
|
||||
for (const refusee of ['./assets/NotoSans-Regular.ttf', 'data:font/ttf,AAEC', 'AAEC/w==', 42, null]) {
|
||||
expect(() => decoderDonnees(refusee), String(refusee)).toThrow(TypeError);
|
||||
}
|
||||
});
|
||||
});
|
||||
9
src/polices/gras.js
Normal file
9
src/polices/gras.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La graisse Bold de la police embarquée, un module par graisse : chacune
|
||||
// devient son propre morceau différé, que chargerPolices (chargement.js)
|
||||
// importe à la demande. « ?inline » rend sous Vite le fichier en URL data:,
|
||||
// incrustée dans le morceau : la page la lit sans requête, sous file://
|
||||
// comme sous http://.
|
||||
export { default } from './NotoSans-Bold.ttf?inline';
|
||||
9
src/polices/regulier.js
Normal file
9
src/polices/regulier.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La graisse Regular de la police embarquée, un module par graisse : chacune
|
||||
// devient son propre morceau différé, que chargerPolices (chargement.js)
|
||||
// importe à la demande. « ?inline » rend sous Vite le fichier en URL data:,
|
||||
// incrustée dans le morceau : la page la lit sans requête, sous file://
|
||||
// comme sous http://.
|
||||
export { default } from './NotoSans-Regular.ttf?inline';
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
// fin de ligne finale. L'écriture en est différée (§ 8.2, § 8.5) : un
|
||||
// écrivain regroupe les demandes d'un délai borné, si bien qu'un zoom
|
||||
// continu écrit une fois par délai, jamais à chaque image.
|
||||
import { PAPIERS } from '../geometrie/papiers.js';
|
||||
import { clesRangees } from './document.js';
|
||||
import { ErreurStockage } from './erreurs.js';
|
||||
|
||||
|
|
@ -31,9 +32,6 @@ export const FICHIER_REGLAGES = 'reglages_locaux.json';
|
|||
// Format d'un fichier que ce code crée.
|
||||
const FORMAT_REGLAGES = 1;
|
||||
|
||||
// Les papiers d'une planche (§ 11.2).
|
||||
const PAPIERS = ['A4', 'Lettre'];
|
||||
|
||||
const MARQUE_ORDRE_OCTETS = '\u{FEFF}';
|
||||
|
||||
/**
|
||||
|
|
@ -41,7 +39,7 @@ const MARQUE_ORDRE_OCTETS = '\u{FEFF}';
|
|||
*
|
||||
* @typedef {Object} ReglagesLocaux
|
||||
* @property {number} format entier ≥ 1, gardé tel que lu
|
||||
* @property {null|'A4'|'Lettre'} papier
|
||||
* @property {null|import('../geometrie/papiers.js').Papier} papier une clé de PAPIERS
|
||||
* @property {Array<{evenement: string, k: number, tx: number, ty: number}>} cadrages
|
||||
* un par événement, triés par identifiant ; evenement non vide, k
|
||||
* nombre fini > 0, tx et ty nombres finis. Toute autre clé, à tout
|
||||
|
|
@ -52,6 +50,13 @@ const MARQUE_ORDRE_OCTETS = '\u{FEFF}';
|
|||
const comparerTextes = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
||||
const parEvenement = (a, b) => comparerTextes(a.evenement, b.evenement);
|
||||
|
||||
// Le domaine du papier : les clés de PAPIERS (src/geometrie/papiers.js),
|
||||
// rangées par comparerTextes — le message d'un refus les nomme dans cet
|
||||
// ordre, qui ne dépend que de leur ensemble. Une clé héritée
|
||||
// d'Object.prototype n'en est pas une.
|
||||
const NOMS_PAPIERS = Object.keys(PAPIERS).sort(comparerTextes);
|
||||
const estPapier = (papier) => typeof papier === 'string' && Object.hasOwn(PAPIERS, papier);
|
||||
|
||||
// Vrai pour un objet qui n'est ni null ni une liste.
|
||||
const estObjet = (valeur) => typeof valeur === 'object' && valeur !== null && !Array.isArray(valeur);
|
||||
|
||||
|
|
@ -77,7 +82,7 @@ function fauteDeReglages(reglages) {
|
|||
if (!estObjet(reglages)) return '';
|
||||
const { format, papier, cadrages } = reglages;
|
||||
if (!Number.isSafeInteger(format) || format < 1) return 'format';
|
||||
if (papier !== null && !PAPIERS.includes(papier)) return 'papier';
|
||||
if (papier !== null && !estPapier(papier)) return 'papier';
|
||||
if (!Array.isArray(cadrages)) return 'cadrages';
|
||||
const vus = new Set();
|
||||
for (let rang = 0; rang < cadrages.length; rang += 1) {
|
||||
|
|
@ -114,7 +119,7 @@ const illisibles = (raison) => ({
|
|||
* fichier illisible rend les défauts et l'avertissement REGLAGES_ILLISIBLES
|
||||
* {chemin, raison} : raison JSON pour un texte qui ne se lit pas, vide
|
||||
* compris ; FORME pour une valeur connue hors de sa règle — un format qui
|
||||
* n'est pas un entier ≥ 1, un papier hors de null, A4 et Lettre, un cadrage
|
||||
* n'est pas un entier ≥ 1, un papier hors de null et des clés de PAPIERS, un cadrage
|
||||
* fautif ou en double ; le code d'une lecture que le système de fichiers
|
||||
* refuse autrement que par ABSENT. Une marque d'ordre d'octets en tête est
|
||||
* ignorée. Les réglages lus gardent leurs clés inconnues et leur format ;
|
||||
|
|
@ -180,13 +185,13 @@ export function avecCadrage(reglages, evenement, { k, tx, ty }) {
|
|||
* Les réglages reçus, le papier remplacé ; ceux reçus ne changent pas.
|
||||
*
|
||||
* @param {ReglagesLocaux} reglages
|
||||
* @param {null|'A4'|'Lettre'} papier
|
||||
* @param {null|import('../geometrie/papiers.js').Papier} papier
|
||||
* @returns {ReglagesLocaux}
|
||||
* @throws {TypeError} un autre papier
|
||||
*/
|
||||
export function avecPapier(reglages, papier) {
|
||||
if (papier !== null && !PAPIERS.includes(papier)) {
|
||||
throw new TypeError(`avecPapier : papier null, A4 ou Lettre attendu, reçu ${JSON.stringify(papier)}`);
|
||||
if (papier !== null && !estPapier(papier)) {
|
||||
throw new TypeError(`avecPapier : papier null, ${NOMS_PAPIERS.join(' ou ')} attendu, reçu ${JSON.stringify(papier)}`);
|
||||
}
|
||||
return { ...reglages, papier };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { PanneSimulee, creerFichiersSimules } from '../../test/fichiers_simules.js';
|
||||
import { describe, test } from '../../test/lanceur.js';
|
||||
import { PAPIERS } from '../geometrie/papiers.js';
|
||||
import { ErreurStockage } from './erreurs.js';
|
||||
import {
|
||||
FICHIER_REGLAGES,
|
||||
|
|
@ -225,6 +226,23 @@ describe('réglages locaux : la forme canonique et l’écriture', () => {
|
|||
for (const [nom, appel] of refus) assert.throws(appel, TypeError, nom);
|
||||
});
|
||||
|
||||
test('le domaine du papier est celui de PAPIERS (src/geometrie/papiers.js) : chaque papier défini se pose, s’écrit et se relit ; une clé héritée ne passe pas', async () => {
|
||||
const definis = Object.keys(PAPIERS);
|
||||
assert.ok(definis.length > 0);
|
||||
for (const papier of definis) {
|
||||
const fs = disque();
|
||||
await ecrireReglages(fs, DOC, avecPapier(DEFAUTS, papier));
|
||||
assert.deepEqual(await lireReglages(fs, DOC), { reglages: { ...DEFAUTS, papier }, avertissement: null }, papier);
|
||||
}
|
||||
for (const papier of ['toString', '__proto__', 'constructor', 'hasOwnProperty', 'a4', 'Letter', 0]) {
|
||||
assert.throws(() => avecPapier(DEFAUTS, papier), TypeError, String(papier));
|
||||
assert.throws(() => serialiserReglages({ ...DEFAUTS, papier }), TypeError, String(papier));
|
||||
const lu = await lireReglages(disque(`{"cadrages":[],"format":1,"papier":${JSON.stringify(papier)}}\n`), DOC);
|
||||
assert.deepEqual(lu.avertissement, illisibles('FORME'), String(papier));
|
||||
}
|
||||
assert.throws(() => avecPapier(DEFAUTS, 'A3'), new RegExp(`papier null, ${definis.join(' ou ')} attendu`));
|
||||
});
|
||||
|
||||
test("ecrireReglages écrit le texte canonique par une seule écriture atomique à la racine, sans créer un dossier de travail qui manque", async () => {
|
||||
const fs = disque();
|
||||
const ecritures = [];
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@
|
|||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// Gardes de l'arborescence, lues dans le texte des sources : le moteur, la
|
||||
// géométrie du plan, le générateur de démonstrations, le stockage et
|
||||
// l'analyseur CSV n'appellent aucune source non reproductible (§ 14.7), et ne
|
||||
// géométrie du plan, le générateur de démonstrations, le stockage,
|
||||
// l'analyseur CSV et la production PDF n'appellent aucune source non
|
||||
// reproductible (§ 14.7), et ne
|
||||
// parcourent les clés d'un objet que par exception nommée (§ 15.5, point 4) ;
|
||||
// le graphe d'imports de chaque couche, l'interface et ses composants compris,
|
||||
// ne rejoint que ce que le § 13.4 lui permet, et ni le moteur, ni la
|
||||
|
|
@ -150,13 +151,22 @@ const PARCOURS_DE_CLES_ADMIS = [
|
|||
"les clés propres d'un objet, rangées sur-le-champ par comparerTextes, un ordre total des chaînes ; " +
|
||||
'le résultat ne dépend que de leur ensemble',
|
||||
},
|
||||
{
|
||||
fichier: join('src', 'stockage', 'reglages_locaux.js'),
|
||||
motif: /Object\s*\.\s*keys\s*\(\s*PAPIERS\s*\)\s*\.\s*sort\s*\(\s*comparerTextes\s*\)/,
|
||||
raison:
|
||||
'le domaine du papier, tiré de src/geometrie/papiers.js, sa seule définition : les clés de PAPIERS, ' +
|
||||
"rangées sur-le-champ par comparerTextes ; le message d'un refus ne dépend que de leur ensemble",
|
||||
},
|
||||
];
|
||||
|
||||
// Arbres dont les modules ne lisent aucune source non reproductible (§ 14.7) :
|
||||
// le moteur, la géométrie du plan et le générateur de démonstrations, puis le
|
||||
// stockage et l'analyseur CSV, qui reçoivent l'horloge et l'aléa de
|
||||
// l'application, en paramètre.
|
||||
const ARBRES_DETERMINISTES = ['src/moteur', 'src/geometrie', 'src/demo', 'src/stockage', 'src/csv'];
|
||||
// l'application, en paramètre, et la production PDF, dont la date du
|
||||
// document entre en paramètre : deux productions du même modèle rendent les
|
||||
// mêmes octets.
|
||||
const ARBRES_DETERMINISTES = ['src/moteur', 'src/geometrie', 'src/demo', 'src/stockage', 'src/csv', 'src/pdf'];
|
||||
|
||||
// Vrai quand le motif se lit dans le texte à partir de l'indice donné, et non
|
||||
// plus loin : une copie collante du motif, ses drapeaux g et y retirés.
|
||||
|
|
@ -216,6 +226,9 @@ const MODULES_VIDES = Object.freeze({
|
|||
'src/demo/a.js': '',
|
||||
'src/stockage/a.js': '',
|
||||
'src/csv/a.js': '',
|
||||
'src/pdf/a.js': '',
|
||||
'src/impression/a.js': '',
|
||||
'src/polices/a.js': '',
|
||||
});
|
||||
|
||||
// Les fichiers donnés, sans ceux de l'arbre nommé.
|
||||
|
|
@ -227,7 +240,7 @@ const sansArbre = (fichiers, arbre) =>
|
|||
const releverSansException = (racine) => releverDeterminisme(racine, []);
|
||||
|
||||
describe('arborescence : déterminisme (§ 14.7)', () => {
|
||||
test("ni src/moteur, ni src/geometrie, ni src/demo, ni src/stockage, ni src/csv ne lisent une source non reproductible, ni ne parcourent les clés d'un objet hors des exceptions nommées", () => {
|
||||
test("ni src/moteur, ni src/geometrie, ni src/demo, ni src/stockage, ni src/csv, ni src/pdf ne lisent une source non reproductible, ni ne parcourent les clés d'un objet hors des exceptions nommées", () => {
|
||||
assert.deepEqual(releverDeterminisme(RACINE), []);
|
||||
});
|
||||
|
||||
|
|
@ -332,7 +345,7 @@ describe('arborescence : déterminisme (§ 14.7)', () => {
|
|||
'src/demo/tirage.cjs:1 Date.now',
|
||||
'src/demo/tirage.mjs:1 Math.random',
|
||||
]);
|
||||
for (const arbre of ['src/moteur', 'src/geometrie', 'src/demo', 'src/stockage', 'src/csv']) {
|
||||
for (const arbre of ARBRES_DETERMINISTES) {
|
||||
const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' };
|
||||
assert.throws(
|
||||
() => avecArbre(sansModule, releverSansException),
|
||||
|
|
@ -360,6 +373,24 @@ describe('arborescence : déterminisme (§ 14.7)', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
test('src/pdf se relève comme le moteur : une date de document lue ou un parcours de clés y tombent, à toute profondeur, hors épreuves', () => {
|
||||
const fichiers = {
|
||||
...MODULES_VIDES,
|
||||
'src/pdf/planche.js': [
|
||||
'export const date = () => new Date();',
|
||||
'export const ordre = (o) => Object.entries(o);',
|
||||
'export const mesure = () => Math.ceil(2.5) + Date.UTC(2000, 0, 1);',
|
||||
].join('\n'),
|
||||
'src/pdf/sous/filigrane.js': 'export const angle = () => Math.random() * 90;\n',
|
||||
'src/pdf/planche.test.js': 'export const t = Date.now() + Object.keys(o).length;\n',
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverSansException), [
|
||||
'src/pdf/planche.js:1 new Date',
|
||||
'src/pdf/planche.js:2 Object.entries',
|
||||
'src/pdf/sous/filigrane.js:1 Math.random',
|
||||
]);
|
||||
});
|
||||
|
||||
test('src/stockage et src/csv se relèvent comme le moteur et le générateur, à toute profondeur, hors épreuves', () => {
|
||||
const fichiers = {
|
||||
...MODULES_VIDES,
|
||||
|
|
@ -527,59 +558,92 @@ const PAQUETS_DE_PLATEFORME = /^(?:svelte|electron|@capacitor\/.+)$/;
|
|||
// lisent pas la version engendrée (§ 14.7, § 18.5), à aucune profondeur :
|
||||
// une démonstration ou un placement qui en dépendrait changerait à chaque
|
||||
// livraison.
|
||||
//
|
||||
// Les trois couches des sorties n'importent, hors d'elles, que ce que leur
|
||||
// liste seulement nomme : tout autre fichier du projet est refusé, sans être
|
||||
// parcouru, et les fichiers nommés sont des frontières que leur propre
|
||||
// garde tient. La production PDF (src/pdf) n'importe que pdf-lib, fontkit,
|
||||
// le registre des constantes et les papiers, et ne touche pas le navigateur :
|
||||
// elle tourne sous node, dans le cycle court (§ 11.1). L'impression
|
||||
// (src/impression), frontière de plateforme, n'importe que les papiers ; la
|
||||
// police (src/polices) n'importe rien. Ni l'une ni l'autre ne s'importe
|
||||
// depuis une autre couche : src/main.js seul les câble (IMPORTATEURS_RESERVES).
|
||||
const VERSION_ENGENDREE = 'src/version.genere.js';
|
||||
const SORTIES = ['src/pdf', 'src/impression', 'src/polices'];
|
||||
const COUCHES = [
|
||||
{
|
||||
arbres: ['src/moteur'],
|
||||
requis: ['src/moteur'],
|
||||
exterieures: ['src/interface', 'src/application', 'src/stockage', VERSION_ENGENDREE],
|
||||
exterieures: ['src/interface', 'src/application', 'src/stockage', ...SORTIES, VERSION_ENGENDREE],
|
||||
paquets: PAQUETS_DE_PLATEFORME,
|
||||
navigateur: [],
|
||||
},
|
||||
{
|
||||
arbres: ['src/geometrie'],
|
||||
requis: ['src/geometrie'],
|
||||
exterieures: ['src/interface', 'src/application', 'src/stockage', 'src/moteur', 'src/csv', 'src/demo', VERSION_ENGENDREE],
|
||||
exterieures: ['src/interface', 'src/application', 'src/stockage', 'src/moteur', 'src/csv', 'src/demo', ...SORTIES, VERSION_ENGENDREE],
|
||||
paquets: PAQUETS_DE_PLATEFORME,
|
||||
navigateur: [],
|
||||
},
|
||||
{
|
||||
arbres: ['src/demo'],
|
||||
requis: ['src/demo'],
|
||||
exterieures: ['src/interface', 'src/application', VERSION_ENGENDREE],
|
||||
exterieures: ['src/interface', 'src/application', ...SORTIES, VERSION_ENGENDREE],
|
||||
paquets: PAQUETS_DE_PLATEFORME,
|
||||
navigateur: [],
|
||||
},
|
||||
{
|
||||
arbres: ['src/stockage'],
|
||||
requis: ['src/stockage'],
|
||||
exterieures: ['src/interface', 'src/application', 'src/csv'],
|
||||
exterieures: ['src/interface', 'src/application', 'src/csv', ...SORTIES],
|
||||
paquets: PAQUETS_DE_PLATEFORME,
|
||||
navigateur: ['src/stockage/fichiers_web.js', 'src/stockage/fichiers_electron.js'],
|
||||
},
|
||||
{
|
||||
arbres: ['src/csv'],
|
||||
requis: ['src/csv'],
|
||||
exterieures: ['src/interface', 'src/application'],
|
||||
exterieures: ['src/interface', 'src/application', ...SORTIES],
|
||||
paquets: PAQUETS_DE_PLATEFORME,
|
||||
navigateur: [],
|
||||
},
|
||||
{
|
||||
arbres: ['src/application'],
|
||||
requis: [],
|
||||
exterieures: ['src/interface'],
|
||||
exterieures: ['src/interface', 'src/impression', 'src/polices'],
|
||||
frontieres: ['src/pdf'],
|
||||
paquets: /^svelte$/,
|
||||
navigateur: null,
|
||||
},
|
||||
{
|
||||
arbres: ['src/interface'],
|
||||
requis: ['src/interface'],
|
||||
exterieures: ['src/stockage', 'src/csv', 'src/moteur', 'src/demo', 'test'],
|
||||
exterieures: ['src/stockage', 'src/csv', 'src/moteur', 'src/demo', 'test', ...SORTIES],
|
||||
frontieres: ['src/application', 'src/geometrie'],
|
||||
paquets: /^(?!svelte$)/,
|
||||
navigateur: null,
|
||||
composants: true,
|
||||
},
|
||||
{
|
||||
arbres: ['src/pdf'],
|
||||
requis: ['src/pdf'],
|
||||
seulement: ['src/geometrie/constantes.js', 'src/geometrie/papiers.js'],
|
||||
paquets: /^(?!(?:pdf-lib|@pdf-lib\/fontkit)$)/,
|
||||
navigateur: [],
|
||||
},
|
||||
{
|
||||
arbres: ['src/impression'],
|
||||
requis: ['src/impression'],
|
||||
seulement: ['src/geometrie/papiers.js'],
|
||||
paquets: /^/,
|
||||
navigateur: null,
|
||||
},
|
||||
{
|
||||
arbres: ['src/polices'],
|
||||
requis: ['src/polices'],
|
||||
seulement: [],
|
||||
paquets: /^/,
|
||||
navigateur: null,
|
||||
},
|
||||
];
|
||||
|
||||
// Un accès au navigateur est un nom de document, de window, de navigator, de
|
||||
|
|
@ -726,6 +790,8 @@ const composantsDe = (dossier) => fichiersDe(dossier).filter((fichier) => extnam
|
|||
const texteDesChargements = (fichier, texte) => (extname(fichier) === '.svelte' ? scriptsDe(texte) : texte);
|
||||
|
||||
// Relevé des refus de la frontière d'une couche dans son graphe d'imports.
|
||||
// Une couche à liste seulement refuse tout fichier du projet hors de ses
|
||||
// arbres et de cette liste, et ne parcourt pas les fichiers qu'elle nomme.
|
||||
// Le parcours part des modules de ses arbres, à toute profondeur, épreuves
|
||||
// exclues, et de leurs composants Svelte pour une couche à composants. Il
|
||||
// suit chaque chemin relatif, ou absolu depuis la racine, vers un module
|
||||
|
|
@ -742,7 +808,10 @@ const texteDesChargements = (fichier, texte) => (extname(fichier) === '.svelte'
|
|||
// refus », où l'accès est la suite des fichiers qui mène d'un fichier de
|
||||
// départ à celui qui refuse. Lève quand un arbre requis ne fournit aucun
|
||||
// module, ni composant pour une couche à composants.
|
||||
function releverCouche(racine, { arbres, requis, exterieures, frontieres = [], paquets, navigateur, composants = false }) {
|
||||
function releverCouche(
|
||||
racine,
|
||||
{ arbres, requis, exterieures = [], frontieres = [], seulement = null, paquets, navigateur, composants = false },
|
||||
) {
|
||||
const departs = (arbre) => [
|
||||
...modulesDe(join(racine, arbre)),
|
||||
...(composants ? composantsDe(join(racine, arbre)).filter((f) => !EPREUVE.test(f)) : []),
|
||||
|
|
@ -754,8 +823,13 @@ function releverCouche(racine, { arbres, requis, exterieures, frontieres = [], p
|
|||
const chemins = arbresDonnes.map((arbre) => join(racine, arbre));
|
||||
return (chemin) => chemins.some((c) => chemin === c || chemin.startsWith(c + sep));
|
||||
};
|
||||
const exterieur = dans(exterieures);
|
||||
const frontiere = dans(frontieres);
|
||||
const propre = dans(arbres);
|
||||
const nomme = dans(seulement ?? []);
|
||||
const exterieure = dans(exterieures);
|
||||
const frontiereAdmise = dans(frontieres);
|
||||
const horsListe = (cible) => seulement !== null && !propre(cible) && !nomme(cible);
|
||||
const exterieur = (cible) => exterieure(cible) || horsListe(cible);
|
||||
const frontiere = (cible) => frontiereAdmise(cible) || (seulement !== null && nomme(cible));
|
||||
const parcourable = (chemin) => EXTENSIONS_MODULE.has(extname(chemin)) || (composants && extname(chemin) === '.svelte');
|
||||
const admis = new Set((navigateur ?? []).map((fichier) => join(racine, fichier)));
|
||||
const aParcourir = arbres.flatMap(departs);
|
||||
|
|
@ -1414,6 +1488,455 @@ describe('arborescence : dépendances déclarées', () => {
|
|||
});
|
||||
});
|
||||
|
||||
// Les paquets de développement, liste fermée : ceux de l'outillage, puis
|
||||
// ceux des sorties — pdf-lib et fontkit, que la page incorpore pour la
|
||||
// planche, pdfjs-dist, outil d'épreuve qui relit le PDF (§ 11.1, § 17,
|
||||
// point 2). Le projet n'a aucune dépendance d'exécution : Vite incorpore ce
|
||||
// que la page importe. Les paquets des sorties sont épinglés à une version
|
||||
// exacte, et ni eux ni ce qu'ils tirent ne portent de script d'installation.
|
||||
const DEPENDANCES_DE_DEVELOPPEMENT = [
|
||||
'@capacitor/cli',
|
||||
'@capacitor/core',
|
||||
'@pdf-lib/fontkit',
|
||||
'@sveltejs/vite-plugin-svelte',
|
||||
'@vitest/browser-playwright',
|
||||
'@vitest/coverage-v8',
|
||||
'electron',
|
||||
'electron-builder',
|
||||
'fast-check',
|
||||
'pdf-lib',
|
||||
'pdfjs-dist',
|
||||
'playwright',
|
||||
'svelte',
|
||||
'vite',
|
||||
'vitest',
|
||||
];
|
||||
const PAQUETS_EPINGLES = ['pdf-lib', '@pdf-lib/fontkit', 'pdfjs-dist'];
|
||||
const SCRIPTS_ADMIS = { 'electron-winstaller': false };
|
||||
const VERSION_EXACTE = /^\d+\.\d+\.\d+$/;
|
||||
|
||||
// Le chemin, dans les paquets du verrou, du paquet nom que tire l'entrée
|
||||
// située en depuis : node_modules imbriqués d'abord, en remontant, comme
|
||||
// Node le résout ; null quand le verrou ne le porte pas.
|
||||
function cheminDansLeVerrou(paquets, depuis, nom) {
|
||||
for (let base = depuis; ; ) {
|
||||
const chemin = `${base === '' ? '' : `${base}/`}node_modules/${nom}`;
|
||||
if (Object.hasOwn(paquets, chemin)) return chemin;
|
||||
if (base === '') return null;
|
||||
const coupe = base.lastIndexOf('/node_modules/');
|
||||
base = coupe === -1 ? '' : base.slice(0, coupe);
|
||||
}
|
||||
}
|
||||
|
||||
// Relevé des paquets déclarés : chaque paquet de devDependencies hors de la
|
||||
// liste fermée, « devDependencies nom : hors de la liste » ; chaque paquet
|
||||
// de la liste qui manque, « devDependencies nom : absent » ; chaque paquet de
|
||||
// dependencies ; chaque paquet épinglé dont la version n'est pas exacte ;
|
||||
// allowScripts qui diffère de SCRIPTS_ADMIS ; puis chaque entrée du verrou
|
||||
// que tirent les paquets épinglés, à toute profondeur, dépendances
|
||||
// facultatives comprises, et qui porte un script d'installation.
|
||||
function releverPaquetsDeclares(racine, liste = DEPENDANCES_DE_DEVELOPPEMENT) {
|
||||
const paquet = JSON.parse(readFileSync(join(racine, 'package.json'), 'utf8'));
|
||||
const declares = Object.keys(paquet.devDependencies ?? {}).sort();
|
||||
const refus = [
|
||||
...declares.filter((nom) => !liste.includes(nom)).map((nom) => `devDependencies ${nom} : hors de la liste`),
|
||||
...liste.filter((nom) => !declares.includes(nom)).map((nom) => `devDependencies ${nom} : absent`),
|
||||
...Object.keys(paquet.dependencies ?? {}).sort().map((nom) => `dependencies ${nom}`),
|
||||
...PAQUETS_EPINGLES.filter((nom) => nom in (paquet.devDependencies ?? {}))
|
||||
.filter((nom) => !VERSION_EXACTE.test(paquet.devDependencies[nom]))
|
||||
.map((nom) => `devDependencies ${nom} : ${paquet.devDependencies[nom]} n'est pas une version exacte`),
|
||||
];
|
||||
if (JSON.stringify(paquet.allowScripts ?? {}) !== JSON.stringify(SCRIPTS_ADMIS)) {
|
||||
refus.push(`allowScripts : ${JSON.stringify(paquet.allowScripts ?? {})}`);
|
||||
}
|
||||
const { packages: paquets = {} } = JSON.parse(readFileSync(join(racine, 'package-lock.json'), 'utf8'));
|
||||
const vus = new Set();
|
||||
const aParcourir = PAQUETS_EPINGLES.map((nom) => cheminDansLeVerrou(paquets, '', nom)).filter((c) => c !== null);
|
||||
for (let i = 0; i < aParcourir.length; i += 1) {
|
||||
const chemin = aParcourir[i];
|
||||
if (vus.has(chemin)) continue;
|
||||
vus.add(chemin);
|
||||
const entree = paquets[chemin];
|
||||
if (entree.hasInstallScript) refus.push(`${chemin} : script d'installation`);
|
||||
for (const nom of Object.keys({ ...entree.dependencies, ...entree.optionalDependencies }).sort()) {
|
||||
const cible = cheminDansLeVerrou(paquets, chemin, nom);
|
||||
if (cible !== null) aParcourir.push(cible);
|
||||
}
|
||||
}
|
||||
assert.ok(vus.size > 0, 'aucun paquet épinglé dans le verrou');
|
||||
return refus;
|
||||
}
|
||||
|
||||
// Les paquets réservés à une place : chacun ne se charge que sous les arbres
|
||||
// admis, relatifs à la racine. pdf-lib et fontkit dessinent la planche dans
|
||||
// src/pdf, seule couche de la page qui les importe ; les épreuves les
|
||||
// relisent. pdfjs-dist, lecteur qui relit le PDF comme un lecteur réel, ne
|
||||
// sert qu'aux épreuves et aux commandes de Vitest, sous test/ : un module
|
||||
// livré qui l'importerait l'emporterait dans la page.
|
||||
const PAQUETS_RESERVES = [
|
||||
{ paquets: ['pdf-lib', '@pdf-lib/fontkit'], admis: ['src/pdf', 'test'] },
|
||||
{ paquets: ['pdfjs-dist'], admis: ['test'] },
|
||||
];
|
||||
|
||||
// Relevé « fichier:ligne spécificateur » des chargements d'un paquet
|
||||
// réservé hors de ses arbres admis, dans le périmètre des dépendances
|
||||
// (sourcesDuProjet), par fichier puis par ligne. Un chargement écrit dans
|
||||
// une chaîne, donnée d'épreuve, n'en est pas un. Lève quand le périmètre ne
|
||||
// porte aucun fichier.
|
||||
function releverPaquetsReserves(racine, reserves = PAQUETS_RESERVES) {
|
||||
const fichiers = sourcesDuProjet(racine);
|
||||
assert.ok(fichiers.length > 0, 'aucun fichier source');
|
||||
return fichiers.flatMap((fichier) => {
|
||||
const nom = enBarres(racine, fichier);
|
||||
const texte = readFileSync(fichier, 'utf8');
|
||||
const code = codeSansChaines(fichier, texte);
|
||||
return chargementsLitteraux(texte)
|
||||
.filter(({ indice, specificateur }) => estDuCode(code, indice) && estNu(specificateur))
|
||||
.filter(({ specificateur }) => {
|
||||
const reserve = reserves.find(({ paquets }) => paquets.includes(nomDePaquet(specificateur)));
|
||||
return reserve !== undefined && !reserve.admis.some((arbre) => nom.startsWith(`${arbre}/`));
|
||||
})
|
||||
.map(({ indice, specificateur }) => `${nom}:${ligneDe(texte, indice)} ${specificateur}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Les couches que seuls des importateurs nommés chargent, hors de leurs
|
||||
// propres fichiers : la police, que src/main.js et le montage des épreuves
|
||||
// passent à App ; l'impression, frontière de plateforme que src/main.js
|
||||
// choisit (§ 13.4).
|
||||
const IMPORTATEURS_RESERVES = [
|
||||
{ arbre: 'src/polices', importateurs: ['src/main.js', 'test/navigateur/montage.js'] },
|
||||
{ arbre: 'src/impression', importateurs: ['src/main.js'] },
|
||||
];
|
||||
|
||||
// Relevé « fichier:ligne spécificateur » des chargements d'un fichier d'un
|
||||
// arbre réservé par un fichier qui n'est ni de cet arbre ni l'un de ses
|
||||
// importateurs, dans le périmètre des dépendances ; le suffixe de Vite se
|
||||
// retire, et un type que la JSDoc importe compte. Lève quand le périmètre ne
|
||||
// porte aucun fichier.
|
||||
function releverImportateursReserves(racine, reserves = IMPORTATEURS_RESERVES) {
|
||||
const fichiers = sourcesDuProjet(racine);
|
||||
assert.ok(fichiers.length > 0, 'aucun fichier source');
|
||||
return fichiers.flatMap((fichier) => {
|
||||
const nom = enBarres(racine, fichier);
|
||||
const texte = readFileSync(fichier, 'utf8');
|
||||
const code = codeSansChaines(fichier, texte);
|
||||
return chargementsLitteraux(texte)
|
||||
.filter(({ indice }) => estDuCode(code, indice))
|
||||
.filter(({ specificateur }) => {
|
||||
const cible = cibleDe(racine, fichier, specificateur.replace(/\?.*$/, ''));
|
||||
if (cible === null) return false;
|
||||
const vise = enBarres(racine, cible);
|
||||
return reserves.some(
|
||||
({ arbre, importateurs }) =>
|
||||
vise.startsWith(`${arbre}/`) && !nom.startsWith(`${arbre}/`) && !importateurs.includes(nom),
|
||||
);
|
||||
})
|
||||
.map(({ indice, specificateur }) => `${nom}:${ligneDe(texte, indice)} ${specificateur}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Le nom d'un papier, que seul src/geometrie/papiers.js définit (§ 13.2) :
|
||||
// le stockage, l'application, l'imposition et l'impression tirent leur
|
||||
// domaine de PAPIERS. Le relevé lit « Lettre », mot entier, dans le code
|
||||
// des modules et des composants de src/ — chaînes, gabarits, noms et
|
||||
// balisage compris, commentaires exclus —, hors de papiers.js, des tables
|
||||
// de libellés, qui portent le texte affiché, et des épreuves.
|
||||
const PORTEURS_DES_NOMS_DE_PAPIER = ['src/geometrie/papiers.js'];
|
||||
const NOM_DE_PAPIER = /\bLettre\b/g;
|
||||
|
||||
// Le texte d'un code JavaScript où chaque commentaire devient blanc, fins de
|
||||
// ligne gardées, chaînes intactes : les commentaires se repèrent dans le
|
||||
// texte sans chaînes (sansChaines), où une chaîne n'en ouvre aucun.
|
||||
function sansCommentaires(texte) {
|
||||
const blancs = texte.split('');
|
||||
for (const { 0: commentaire, index } of sansChaines(texte).matchAll(/\/\/[^\n]*|\/\*[\s\S]*?(?:\*\/|$)/g)) {
|
||||
for (let i = index; i < index + commentaire.length; i += 1) if (blancs[i] !== '\n') blancs[i] = ' ';
|
||||
}
|
||||
return blancs.join('');
|
||||
}
|
||||
|
||||
// Le texte d'un module sans ses commentaires ; d'un composant Svelte, sans
|
||||
// ses commentaires HTML ni ceux de ses blocs de script.
|
||||
function texteSansCommentaires(fichier, texte) {
|
||||
if (extname(fichier) !== '.svelte') return sansCommentaires(texte);
|
||||
let sortie = texte.replace(/<!--[\s\S]*?(?:-->|$)/g, (commentaire) => commentaire.replace(/[^\n]/g, ' '));
|
||||
for (const { debut, fin } of blocsScript(sortie)) {
|
||||
sortie = sortie.slice(0, debut) + sansCommentaires(sortie.slice(debut, fin)) + sortie.slice(fin);
|
||||
}
|
||||
return sortie;
|
||||
}
|
||||
|
||||
// Relevé « fichier:ligne Lettre » des noms de papier hors de leurs porteurs,
|
||||
// par fichier puis par ligne ; puis « porteur sans objet : fichier » pour un
|
||||
// porteur qui ne nomme plus aucun papier. Lève quand src/ ne porte aucun
|
||||
// module.
|
||||
function releverNomsDePapier(racine, porteurs = PORTEURS_DES_NOMS_DE_PAPIER) {
|
||||
const fichiers = fichiersDe(join(racine, 'src'))
|
||||
.filter((f) => (EXTENSIONS_MODULE.has(extname(f)) || extname(f) === '.svelte') && !EPREUVE.test(f))
|
||||
.sort();
|
||||
assert.ok(fichiers.length > 0, 'src ne fournit aucun module');
|
||||
const servis = new Set();
|
||||
const releve = fichiers.flatMap((fichier) => {
|
||||
const nom = enBarres(racine, fichier);
|
||||
if (TABLE_DE_LIBELLES.test(nom)) return [];
|
||||
const texte = readFileSync(fichier, 'utf8');
|
||||
const trouves = [...texteSansCommentaires(fichier, texte).matchAll(NOM_DE_PAPIER)];
|
||||
if (porteurs.includes(nom)) {
|
||||
if (trouves.length > 0) servis.add(nom);
|
||||
return [];
|
||||
}
|
||||
return trouves.map(({ index }) => `${nom}:${ligneDe(texte, index)} Lettre`);
|
||||
});
|
||||
const sansObjet = porteurs.filter((p) => !servis.has(p)).map((p) => `porteur sans objet : ${p}`);
|
||||
return [...releve, ...sansObjet];
|
||||
}
|
||||
|
||||
describe('arborescence : les sorties — PDF, impression, police (§ 11.1, § 13.4, § 17 point 2)', () => {
|
||||
test('les paquets de développement sont ceux de la liste fermée, les trois paquets des sorties épinglés exacts, sans script d’installation ; aucune dépendance d’exécution', () => {
|
||||
assert.deepEqual(releverPaquetsDeclares(RACINE), []);
|
||||
});
|
||||
|
||||
test('la garde des paquets déclarés relève un paquet de plus, un paquet manquant, une version non exacte, une dépendance d’exécution, allowScripts changé et un script d’installation tiré par un paquet épinglé', () => {
|
||||
const verrou = {
|
||||
packages: {
|
||||
'': {},
|
||||
'node_modules/pdf-lib': { dependencies: { pako: '^1.0.11', tslib: '^1.11.1' } },
|
||||
'node_modules/pdf-lib/node_modules/tslib': { hasInstallScript: true },
|
||||
'node_modules/tslib': {},
|
||||
'node_modules/pako': {},
|
||||
'node_modules/pdfjs-dist': { optionalDependencies: { canevas: '1.0.0' } },
|
||||
'node_modules/canevas': { hasInstallScript: true },
|
||||
'node_modules/sans-rapport': { hasInstallScript: true },
|
||||
},
|
||||
};
|
||||
const fichiers = {
|
||||
'package.json': JSON.stringify({
|
||||
dependencies: { 'pdf-lib': '1.17.1' },
|
||||
devDependencies: { vite: '^8.3.2', 'pdf-lib': '^1.17.1', 'pdfjs-dist': '6.4.299', 'outil-de-trop': '1.0.0' },
|
||||
allowScripts: { 'electron-winstaller': false, canevas: true },
|
||||
}),
|
||||
'package-lock.json': JSON.stringify(verrou),
|
||||
};
|
||||
assert.deepEqual(
|
||||
avecArbre(fichiers, (racine) => releverPaquetsDeclares(racine, ['vite', 'pdf-lib', 'pdfjs-dist', 'svelte'])),
|
||||
[
|
||||
'devDependencies outil-de-trop : hors de la liste',
|
||||
'devDependencies svelte : absent',
|
||||
'dependencies pdf-lib',
|
||||
"devDependencies pdf-lib : ^1.17.1 n'est pas une version exacte",
|
||||
'allowScripts : {"electron-winstaller":false,"canevas":true}',
|
||||
"node_modules/pdf-lib/node_modules/tslib : script d'installation",
|
||||
"node_modules/canevas : script d'installation",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('pdf-lib et fontkit ne se chargent que sous src/pdf et test/, pdfjs-dist que sous test/', () => {
|
||||
assert.deepEqual(releverPaquetsReserves(RACINE), []);
|
||||
});
|
||||
|
||||
test('la garde des paquets réservés relève src/interface ou src/application qui importent pdf-lib, et un module livré qui importe pdfjs-dist ; une chaîne n’est pas un chargement', () => {
|
||||
const fichiers = {
|
||||
'src/interface/Badges.svelte': "<script>\n import { PDFDocument } from 'pdf-lib';\n</script>\n",
|
||||
'src/application/planche.js': [
|
||||
"import fontkit from '@pdf-lib/fontkit';",
|
||||
"const lecteur = await import('pdfjs-dist/legacy/build/pdf.mjs');",
|
||||
"const exemple = \"import 'pdf-lib'\";",
|
||||
].join('\n'),
|
||||
'src/pdf/planche.js': "import { PDFDocument } from 'pdf-lib';\nimport fontkit from '@pdf-lib/fontkit';\n",
|
||||
'src/pdf/planche.test.js': "import { getDocument } from 'pdfjs-dist';\n",
|
||||
'scripts/documentation/tramer.js': "import { getDocument } from 'pdfjs-dist';\n",
|
||||
'electron/impression.js': "const { PDFDocument } = require('pdf-lib');\n",
|
||||
'test/pdf.js': "import { getDocument } from 'pdfjs-dist/legacy/build/pdf.mjs';\nimport { PDFDocument } from 'pdf-lib';\n",
|
||||
'vite.config.js': "import { defineConfig } from 'vite';\n",
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverPaquetsReserves), [
|
||||
'electron/impression.js:1 pdf-lib',
|
||||
'scripts/documentation/tramer.js:1 pdfjs-dist',
|
||||
'src/application/planche.js:1 @pdf-lib/fontkit',
|
||||
'src/application/planche.js:2 pdfjs-dist/legacy/build/pdf.mjs',
|
||||
'src/interface/Badges.svelte:2 pdf-lib',
|
||||
'src/pdf/planche.test.js:1 pdfjs-dist',
|
||||
]);
|
||||
});
|
||||
|
||||
test('src/polices ne se charge que par src/main.js et le montage des épreuves, src/impression que par src/main.js', () => {
|
||||
assert.deepEqual(releverImportateursReserves(RACINE), []);
|
||||
});
|
||||
|
||||
test('la garde des importateurs relève une autre couche, une épreuve ou un script qui chargent la police ou l’impression, type JSDoc compris ; leurs propres fichiers et les importateurs nommés passent', () => {
|
||||
const fichiers = {
|
||||
'src/main.js': [
|
||||
"import { chargerPolices } from './polices/chargement.js';",
|
||||
"import { creerImprimanteWeb } from './impression/imprimante_web.js';",
|
||||
].join('\n'),
|
||||
'test/navigateur/montage.js': [
|
||||
"import { chargerPolices } from '../../src/polices/chargement.js';",
|
||||
"import { creerImprimanteWeb } from '../../src/impression/imprimante_web.js';",
|
||||
].join('\n'),
|
||||
'src/polices/chargement.js': "const r = () => import('./regulier.js');\n",
|
||||
'src/polices/regulier.js': "export { default } from './NotoSans-Regular.ttf?inline';\n",
|
||||
'src/polices/chargement.navigateur.test.js': "import { chargerPolices } from './chargement.js';\n",
|
||||
'src/interface/App.svelte': [
|
||||
'<script>',
|
||||
" /** @type {import('../impression/imprimante_web.js').Imprimante} */",
|
||||
' let imprimante;',
|
||||
'</script>',
|
||||
].join('\n'),
|
||||
'src/application/planche_seance.js': "const polices = () => import('../polices/chargement.js');\n",
|
||||
'scripts/banc/police.js': "import regulier from '../../src/polices/NotoSans-Regular.ttf?inline';\n",
|
||||
'test/aide.js': "const texte = \"import x from '../src/polices/chargement.js'\";\n",
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverImportateursReserves), [
|
||||
'scripts/banc/police.js:1 ../../src/polices/NotoSans-Regular.ttf?inline',
|
||||
'src/application/planche_seance.js:1 ../polices/chargement.js',
|
||||
'src/interface/App.svelte:2 ../impression/imprimante_web.js',
|
||||
'test/navigateur/montage.js:2 ../../src/impression/imprimante_web.js',
|
||||
]);
|
||||
});
|
||||
|
||||
test('hors de src/geometrie/papiers.js, des tables de libellés et des épreuves, aucun code de src/ ne nomme le papier Lettre', () => {
|
||||
assert.deepEqual(releverNomsDePapier(RACINE), []);
|
||||
});
|
||||
|
||||
test('la garde des noms de papier relève chaîne, gabarit, nom et balisage, à toute profondeur ; commentaires, mots voisins, libellés et épreuves passent ; un porteur muet est sans objet', () => {
|
||||
const fichiers = {
|
||||
'src/geometrie/papiers.js': "export const PAPIERS = { A4: {}, Lettre: {} };\n",
|
||||
'src/stockage/reglages_locaux.js': [
|
||||
"const PAPIERS = ['A4', 'Lettre'];",
|
||||
'// le papier Lettre se lit ici dans un commentaire',
|
||||
'/** @property {null|\'A4\'|\'Lettre\'} papier */',
|
||||
'const message = `papier ${a} ou Lettre`;',
|
||||
'export const lettres = 1, Lettres = 2, uneLettre = 3;',
|
||||
].join('\n'),
|
||||
'src/impression/sous/papier.js': 'export const lettre = PAPIERS.Lettre;\n',
|
||||
'src/interface/Papier.svelte': [
|
||||
'<!-- Lettre, dans un commentaire HTML -->',
|
||||
'<script>',
|
||||
' // Lettre, dans un commentaire de script',
|
||||
" const choix = 'Lettre';",
|
||||
'</script>',
|
||||
'<p>Lettre</p>',
|
||||
].join('\n'),
|
||||
'src/interface/libelles/papier.js': "export const LIBELLES = { lettre: 'Lettre' };\n",
|
||||
'src/application/planche.test.js': "const papier = 'Lettre';\n",
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverNomsDePapier), [
|
||||
'src/impression/sous/papier.js:1 Lettre',
|
||||
'src/interface/Papier.svelte:4 Lettre',
|
||||
'src/interface/Papier.svelte:6 Lettre',
|
||||
'src/stockage/reglages_locaux.js:1 Lettre',
|
||||
'src/stockage/reglages_locaux.js:4 Lettre',
|
||||
]);
|
||||
const muet = { 'src/geometrie/papiers.js': 'export const PAPIERS = {};\n' };
|
||||
assert.deepEqual(avecArbre(muet, releverNomsDePapier), ['porteur sans objet : src/geometrie/papiers.js']);
|
||||
});
|
||||
|
||||
test('src/pdf n’importe que ses modules, pdf-lib, fontkit, constantes.js et papiers.js, et ne touche pas le navigateur', () => {
|
||||
const fichiers = {
|
||||
...MODULES_VIDES,
|
||||
'src/pdf/planche.js': [
|
||||
"import { PDFDocument } from 'pdf-lib';",
|
||||
"import fontkit from '@pdf-lib/fontkit';",
|
||||
"import { constante } from '../geometrie/constantes.js';",
|
||||
"import { PAPIERS } from '../geometrie/papiers.js';",
|
||||
"import { mettreEnPage } from './mise_en_page.js';",
|
||||
"import { modelePlanche } from '../application/planche.js';",
|
||||
"import { englobant } from '../geometrie/tables.js';",
|
||||
"import { getDocument } from 'pdfjs-dist';",
|
||||
"import { mount } from 'svelte';",
|
||||
"import { readFileSync } from 'node:fs';",
|
||||
'export const corps = () => document.body;',
|
||||
"import { VERSION } from '../version.genere.js';",
|
||||
].join('\n'),
|
||||
'src/pdf/mise_en_page.js': "export const mettreEnPage = () => window.devicePixelRatio;\n",
|
||||
'src/pdf/planche.test.js': "import { modelePlanche } from '../application/planche.js';\nexport const c = document.body;\n",
|
||||
'src/application/planche.js': 'export const modelePlanche = () => null;\n',
|
||||
'src/geometrie/tables.js': 'export const englobant = () => null;\n',
|
||||
'src/geometrie/constantes.js': 'export const constante = () => 8;\n',
|
||||
'src/geometrie/papiers.js': 'export const PAPIERS = {};\n',
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverFrontiere), [
|
||||
'src/pdf/mise_en_page.js:1 window.',
|
||||
'src/pdf/planche.js:6 import ../application/planche.js',
|
||||
'src/pdf/planche.js:7 import ../geometrie/tables.js',
|
||||
'src/pdf/planche.js:8 import pdfjs-dist',
|
||||
'src/pdf/planche.js:9 import svelte',
|
||||
'src/pdf/planche.js:10 import node:fs',
|
||||
'src/pdf/planche.js:11 document.',
|
||||
'src/pdf/planche.js:12 import ../version.genere.js',
|
||||
]);
|
||||
});
|
||||
|
||||
test('src/impression n’importe que papiers.js et aucun paquet, et touche le navigateur ; src/polices n’importe que ses fichiers', () => {
|
||||
const fichiers = {
|
||||
...MODULES_VIDES,
|
||||
'src/impression/imprimante_web.js': [
|
||||
"import { PAPIERS } from '../geometrie/papiers.js';",
|
||||
"import { decouperPlan } from '../geometrie/pages.js';",
|
||||
"import { estBrouillon } from '../application/sorties.js';",
|
||||
"import { ipcRenderer } from 'electron';",
|
||||
'export const imprimer = () => window.print();',
|
||||
].join('\n'),
|
||||
'src/polices/chargement.js': [
|
||||
"export const regulier = () => import('./regulier.js');",
|
||||
"import { t } from '../interface/libelles.js';",
|
||||
"import { mount } from 'svelte';",
|
||||
"export const attendre = () => document.fonts.ready;",
|
||||
].join('\n'),
|
||||
'src/polices/regulier.js': "export { default } from './NotoSans-Regular.ttf?inline';\n",
|
||||
'src/geometrie/papiers.js': 'export const PAPIERS = {};\n',
|
||||
'src/geometrie/pages.js': 'export const decouperPlan = () => null;\n',
|
||||
'src/application/sorties.js': 'export const estBrouillon = () => true;\n',
|
||||
'src/interface/libelles.js': 'export const t = () => "";\n',
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverFrontiere), [
|
||||
'src/impression/imprimante_web.js:2 import ../geometrie/pages.js',
|
||||
'src/impression/imprimante_web.js:3 import ../application/sorties.js',
|
||||
'src/impression/imprimante_web.js:4 import electron',
|
||||
'src/polices/chargement.js:2 import ../interface/libelles.js',
|
||||
'src/polices/chargement.js:3 import svelte',
|
||||
]);
|
||||
});
|
||||
|
||||
test('ni l’interface ni une couche du cœur n’importent les sorties ; l’application importe src/pdf sans le parcourir, jamais l’impression ni la police', () => {
|
||||
const fichiers = {
|
||||
...MODULES_VIDES,
|
||||
'src/interface/Badges.svelte': [
|
||||
'<script>',
|
||||
" import { cellules } from '../pdf/imposition.js';",
|
||||
" import { PDFDocument } from 'pdf-lib';",
|
||||
'</script>',
|
||||
].join('\n'),
|
||||
'src/geometrie/vue.js': "import { cellules } from '../pdf/imposition.js';\n",
|
||||
'src/stockage/noms.js': "import { PAPIERS } from '../impression/papiers.js';\n",
|
||||
'src/application/planche.js': [
|
||||
"const production = () => import('../pdf/planche.js');",
|
||||
"import { chargerPolices } from '../polices/chargement.js';",
|
||||
"import { creerImprimanteWeb } from '../impression/imprimante_web.js';",
|
||||
].join('\n'),
|
||||
'src/pdf/planche.js': "import { PDFDocument } from 'pdf-lib';\n",
|
||||
'src/pdf/imposition.js': 'export const cellules = () => [];\n',
|
||||
};
|
||||
assert.deepEqual(avecArbre(fichiers, releverFrontiere), [
|
||||
'src/geometrie/vue.js:1 import ../pdf/imposition.js',
|
||||
'src/stockage/noms.js:1 import ../impression/papiers.js',
|
||||
'src/application/planche.js:2 import ../polices/chargement.js',
|
||||
'src/application/planche.js:3 import ../impression/imprimante_web.js',
|
||||
'src/interface/Badges.svelte:2 import ../pdf/imposition.js',
|
||||
'src/interface/Badges.svelte:3 import pdf-lib',
|
||||
]);
|
||||
});
|
||||
|
||||
test('un src/pdf, un src/impression ou un src/polices sans module fait échouer la garde des couches', () => {
|
||||
for (const arbre of SORTIES) {
|
||||
const sansModule = { ...sansArbre(MODULES_VIDES, arbre), [`${arbre}/a.test.js`]: '' };
|
||||
assert.throws(() => avecArbre(sansModule, releverFrontiere), new RegExp(`${arbre} ne fournit aucun module`), arbre);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Valeurs du contrat de données (types.js) : configuration.js les définit et
|
||||
// les exporte, tout autre fichier de src/moteur les importe, épreuves
|
||||
// comprises (§ 13.2). Une déclaration de l'un de ces noms ailleurs est une
|
||||
|
|
@ -3002,6 +3525,15 @@ const SCRIPTS_NOMMES = [
|
|||
{ fichier: 'scripts/windows/lancer.ps1', raison: 'lance le projet sous Windows' },
|
||||
];
|
||||
|
||||
// Les données de la police embarquée, à leur seule place : les deux
|
||||
// fichiers de police, que Vite incruste, et leur provenance, que lit
|
||||
// test/polices.test.js. Ce sont des données, qui ne calculent rien.
|
||||
const DONNEES_NOMMEES = [
|
||||
{ fichier: 'src/polices/NotoSans-Regular.ttf', raison: 'la police embarquée, graisse normale (§ 11.1, § 14.15)' },
|
||||
{ fichier: 'src/polices/NotoSans-Bold.ttf', raison: 'la police embarquée, graisse grasse (§ 11.1, § 14.15)' },
|
||||
{ fichier: 'src/polices/PROVENANCE', raison: 'version amont, adresse et empreinte de chaque fichier de la police' },
|
||||
];
|
||||
|
||||
// Fichiers que le disque porte sans qu'ils soient du projet : l'index de
|
||||
// dossier .DS_Store, l'échange de Vim (.x.js.swp, .x.js.swo…), le verrou
|
||||
// d'Emacs (.#x.js), une sauvegarde d'éditeur (x.js~, #x.js#), un reste de
|
||||
|
|
@ -3023,10 +3555,10 @@ const estArtefact = (nom) =>
|
|||
// Relevé des fichiers d'une autre langue sous src/, electron/ et scripts/, à
|
||||
// toute profondeur, artefacts d'éditeur et de fusion écartés : chacun
|
||||
// « chemin : extension », ou « chemin : sans extension », dans l'ordre des
|
||||
// chemins, hors des extensions admises et des scripts nommés ; puis
|
||||
// « exception sans objet : fichier » pour un script nommé absent. Lève quand
|
||||
// chemins, hors des extensions admises, des scripts et des données nommés ;
|
||||
// puis « exception sans objet : fichier » pour un fichier nommé absent. Lève quand
|
||||
// ces arbres ne portent aucun fichier.
|
||||
function releverLangages(racine, nommes = SCRIPTS_NOMMES) {
|
||||
function releverLangages(racine, nommes = [...SCRIPTS_NOMMES, ...DONNEES_NOMMEES]) {
|
||||
const fichiers = ARBRES_D_UNE_LANGUE.flatMap((arbre) => fichiersDe(join(racine, arbre)))
|
||||
.filter((fichier) => !fichier.split(sep).includes('node_modules') && !estArtefact(basename(fichier)))
|
||||
.map((fichier) => enBarres(racine, fichier))
|
||||
|
|
@ -3236,7 +3768,7 @@ describe('arborescence : un seul chemin de code pour les réservations (§ 5.2)'
|
|||
});
|
||||
|
||||
describe('arborescence : une seule langue (§ 13.2)', () => {
|
||||
test('sous src/, electron/ et scripts/, rien que du JavaScript, des composants Svelte, des feuilles de style, des données JSON et les scripts d’installation nommés', () => {
|
||||
test('sous src/, electron/ et scripts/, rien que du JavaScript, des composants Svelte, des feuilles de style, des données JSON, les scripts d’installation nommés et les données nommées de la police', () => {
|
||||
assert.deepEqual(releverLangages(RACINE), []);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -12,10 +12,18 @@
|
|||
// incruste en chaîne dans le module principal, n'apparaît pas dans les
|
||||
// moduleIds de celui-ci : un greffon posé par l'option worker.plugins relève
|
||||
// les modules et le code de ses morceaux, qui passent par les mêmes relevés.
|
||||
//
|
||||
// Les sorties (§ 11.1, § 14.15) : la police embarquée voyage dans deux
|
||||
// morceaux différés, un par graisse, incrustée octet pour octet ; pdf-lib et
|
||||
// fontkit, quand la page les importe, tiennent chacun leur morceau, sous le
|
||||
// seuil ; aucun de ces morceaux n'est chargé ni préchargé au lancement ; les
|
||||
// avis de licence sont copiés dans www/licences/, et chaque paquet des
|
||||
// morceaux construits y a le sien, sous la licence de son package.json.
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { extname, join, relative, resolve, sep } from 'node:path';
|
||||
import { basename, dirname, extname, join, relative, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { build } from 'vite';
|
||||
import { describe, test } from './lanceur.js';
|
||||
|
|
@ -406,3 +414,227 @@ describe('construction', () => {
|
|||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Le paquet de node_modules d'où vient un module construit : le dernier
|
||||
// segment node_modules de son chemin, et le répertoire qui porte son
|
||||
// package.json ; null pour un module du projet.
|
||||
function paquetDe(id) {
|
||||
const lu = /^(.*[\\/]node_modules[\\/])((?:@[^\\/]+[\\/])?[^\\/]+)/.exec(id.replace(/\?.*$/, ''));
|
||||
if (lu === null) return null;
|
||||
return { nom: lu[2].split(sep).join('/'), dossier: lu[1] + lu[2] };
|
||||
}
|
||||
|
||||
// Les sections « paquet : nom version » de TIERS.txt et leur ligne
|
||||
// « licence : … », dans l'ordre du texte.
|
||||
function sectionsDesTiers(texte) {
|
||||
return [...texte.matchAll(/^paquet : (\S+) (\S+)\nlicence : (.+)$/gm)].map(([, nom, version, licence]) => ({
|
||||
nom,
|
||||
version,
|
||||
licence,
|
||||
}));
|
||||
}
|
||||
|
||||
// Les paquets que vite.config.js range dans un groupe de morceaux, et le
|
||||
// groupe qui les prend : name et test de chaque groupe, dans leur ordre.
|
||||
const groupeDe = (groupes, id) =>
|
||||
groupes.find(({ test: motif }) => (typeof motif === 'function' ? motif(id) : new RegExp(motif).test(id)))?.name ?? null;
|
||||
|
||||
// Modules témoins des deux groupes de la production PDF, chemins comme
|
||||
// Rolldown les nomme, et un module qui n'est d'aucun groupe.
|
||||
const TEMOINS_DES_GROUPES = [
|
||||
['/projet/node_modules/@pdf-lib/fontkit/dist/fontkit.es.js', 'fontkit'],
|
||||
['/projet/node_modules/pdf-lib/es/api/PDFDocument.js', 'pdf-lib'],
|
||||
['/projet/node_modules/pako/lib/deflate.js', 'pdf-lib'],
|
||||
['/projet/node_modules/pdf-lib/node_modules/tslib/tslib.es6.js', 'pdf-lib'],
|
||||
['/projet/node_modules/@pdf-lib/standard-fonts/es/index.js', 'pdf-lib'],
|
||||
['/projet/node_modules/@pdf-lib/upng/UPNG.js', 'pdf-lib'],
|
||||
['/projet/node_modules/svelte/src/index-client.js', null],
|
||||
['/projet/src/pdf/imposition.js', null],
|
||||
];
|
||||
const DU_PDF = /[\\/]node_modules[\\/](?:pdf-lib|pako|tslib|@pdf-lib[\\/](?:fontkit|standard-fonts|upng))[\\/]/;
|
||||
|
||||
describe('construction : les sorties (§ 11.1, § 14.15)', () => {
|
||||
test('vite.config.js range fontkit dans un morceau, pdf-lib et ce qu’il tire dans un autre, et rien d’autre', async () => {
|
||||
const { default: vite } = await import(pathToFileURL(join(RACINE, 'vite.config.js')).href);
|
||||
const groupes = vite.build?.rolldownOptions?.output?.codeSplitting?.groups ?? [];
|
||||
assert.deepEqual(groupes.map(({ name }) => name), ['fontkit', 'pdf-lib']);
|
||||
assert.deepEqual(
|
||||
TEMOINS_DES_GROUPES.map(([id]) => [id, groupeDe(groupes, id)]),
|
||||
TEMOINS_DES_GROUPES,
|
||||
);
|
||||
});
|
||||
|
||||
test('la police voyage dans deux morceaux différés, octet pour octet ; aucun morceau des sorties n’est chargé ni préchargé au lancement ; les avis de licence sont dans www/licences/, et chaque paquet construit y a le sien', async () => {
|
||||
const sortie = mkdtempSync(join(tmpdir(), 'construction-sorties-'));
|
||||
const travailleur = releveDuTravailleur();
|
||||
try {
|
||||
const resultat = await construireLivraison(sortie, travailleur);
|
||||
const sorties = (Array.isArray(resultat) ? resultat : [resultat]).flatMap((r) => r.output);
|
||||
const morceaux = sorties.filter((s) => s.type === 'chunk');
|
||||
|
||||
// Une graisse, un morceau : chargé par import() différé, il porte la
|
||||
// police en URL data:, dont le décodage est le fichier de src/polices.
|
||||
const sha256 = (octets) => createHash('sha256').update(octets).digest('hex');
|
||||
const morceauxDePolice = [];
|
||||
for (const [module, fichier] of [['regulier.js', 'NotoSans-Regular.ttf'], ['gras.js', 'NotoSans-Bold.ttf']]) {
|
||||
const id = join(RACINE, 'src', 'polices', module);
|
||||
const porteurs = morceaux.filter((m) => m.moduleIds.includes(id));
|
||||
assert.equal(porteurs.length, 1, `${module} dans ${porteurs.length} morceaux`);
|
||||
const [morceau] = porteurs;
|
||||
assert.ok(morceau.isDynamicEntry && !morceau.isEntry, `${morceau.fileName} n'est pas un morceau différé`);
|
||||
const donnees = [...morceau.code.matchAll(/data:font\/ttf;base64,([A-Za-z0-9+/]+=*)/g)].map((m) => m[1]);
|
||||
assert.equal(donnees.length, 1, `${morceau.fileName} : ${donnees.length} polices incrustées`);
|
||||
assert.equal(
|
||||
sha256(Buffer.from(donnees[0], 'base64')),
|
||||
sha256(readFileSync(join(RACINE, 'src', 'polices', fichier))),
|
||||
fichier,
|
||||
);
|
||||
morceauxDePolice.push(morceau.fileName);
|
||||
}
|
||||
assert.notEqual(morceauxDePolice[0], morceauxDePolice[1]);
|
||||
|
||||
// Les morceaux des sorties : la police, et tout morceau qui porte un
|
||||
// module de pdf-lib, de fontkit ou de ce qu'ils tirent. Quand la page
|
||||
// les importe, fontkit et pdf-lib tiennent chacun un morceau à part.
|
||||
const deLaProduction = morceaux.filter((m) => m.moduleIds.some((id) => DU_PDF.test(id)));
|
||||
for (const morceau of deLaProduction) {
|
||||
const nomsDePaquets = new Set(morceau.moduleIds.map((id) => paquetDe(id)?.nom ?? 'projet'));
|
||||
const attendu = nomsDePaquets.has('@pdf-lib/fontkit') ? 'fontkit' : 'pdf-lib';
|
||||
assert.ok(basename(morceau.fileName).startsWith(`${attendu}-`), `${morceau.fileName} : ${[...nomsDePaquets]}`);
|
||||
assert.ok(!(nomsDePaquets.has('@pdf-lib/fontkit') && nomsDePaquets.has('pdf-lib')), morceau.fileName);
|
||||
}
|
||||
const desSorties = new Set([...morceauxDePolice, ...deLaProduction.map((m) => m.fileName)]);
|
||||
|
||||
// Au lancement, la page charge les scripts du HTML et les imports
|
||||
// statiques de son point d'entrée, et précharge les modulepreload.
|
||||
const entrees = morceaux.filter((m) => m.isEntry);
|
||||
assert.ok(entrees.length > 0, 'aucun point d’entrée');
|
||||
const statiques = new Set();
|
||||
const aSuivre = entrees.map((m) => m.fileName);
|
||||
while (aSuivre.length > 0) {
|
||||
const nom = aSuivre.pop();
|
||||
if (statiques.has(nom)) continue;
|
||||
statiques.add(nom);
|
||||
aSuivre.push(...(morceaux.find((m) => m.fileName === nom)?.imports ?? []));
|
||||
}
|
||||
const html = readFileSync(join(sortie, 'index.html'), 'utf8');
|
||||
const prechargements = [...html.matchAll(/<link\b[^>]*\brel=["']?modulepreload\b[^>]*>/gi)].map(
|
||||
([balise]) => /\shref\s*=\s*["']?\.?\/?([^"'\s>]+)/i.exec(balise)?.[1] ?? '',
|
||||
);
|
||||
const auLancement = [...statiques, ...prechargements, ...scriptsDe(html).map((s) => (s.src ?? '').replace(/^\.\//, ''))];
|
||||
assert.deepEqual(auLancement.filter((nom) => desSorties.has(nom)), []);
|
||||
|
||||
// Les avis : copiés tels quels de public/licences/.
|
||||
for (const avis of ['NotoSans-OFL.txt', 'TIERS.txt']) {
|
||||
assert.equal(
|
||||
readFileSync(join(sortie, 'licences', avis), 'utf8'),
|
||||
readFileSync(join(RACINE, 'public', 'licences', avis), 'utf8'),
|
||||
avis,
|
||||
);
|
||||
}
|
||||
|
||||
// Chaque paquet des morceaux construits, travailleur compris, a sa
|
||||
// section dans TIERS.txt, à la version et sous la licence de son
|
||||
// package.json.
|
||||
const tiers = sectionsDesTiers(readFileSync(join(RACINE, 'public', 'licences', 'TIERS.txt'), 'utf8'));
|
||||
const ids = [...morceaux, ...travailleur.morceaux].flatMap((m) => m.moduleIds);
|
||||
const construits = new Map();
|
||||
for (const id of ids) {
|
||||
const paquet = paquetDe(id);
|
||||
if (paquet !== null) construits.set(paquet.nom, paquet.dossier);
|
||||
}
|
||||
assert.ok(construits.has('svelte'), `svelte absent des ${construits.size} paquets construits`);
|
||||
const manquants = [...construits]
|
||||
.map(([nom, dossier]) => {
|
||||
const { version, license } = JSON.parse(readFileSync(join(dossier, 'package.json'), 'utf8'));
|
||||
return { nom, version, licence: license };
|
||||
})
|
||||
.filter(({ nom, version, licence }) => !tiers.some((s) => s.nom === nom && s.version === version && s.licence === licence))
|
||||
.map(({ nom, version, licence }) => `${nom} ${version} — ${licence}`);
|
||||
assert.deepEqual(manquants, []);
|
||||
|
||||
// Une police émise en fichier est l'une des deux, octet pour octet ; une
|
||||
// feuille qui en nomme une la trouve dans www/.
|
||||
const fichiers = readdirSync(sortie, { recursive: true, withFileTypes: true })
|
||||
.filter((e) => e.isFile())
|
||||
.map((e) => join(e.parentPath, e.name));
|
||||
const sources = new Set(
|
||||
['NotoSans-Regular.ttf', 'NotoSans-Bold.ttf'].map((f) => sha256(readFileSync(join(RACINE, 'src', 'polices', f)))),
|
||||
);
|
||||
assert.deepEqual(
|
||||
fichiers.filter((f) => extname(f) === '.ttf' && !sources.has(sha256(readFileSync(f)))).map((f) => relative(sortie, f)),
|
||||
[],
|
||||
);
|
||||
for (const feuille of fichiers.filter((f) => extname(f) === '.css')) {
|
||||
for (const adresse of adressesChargees('css', readFileSync(feuille, 'utf8')).filter((a) => /\.ttf$/i.test(a))) {
|
||||
const cible = resolve(dirname(feuille), adresse);
|
||||
assert.ok(fichiers.includes(cible), `${relative(sortie, feuille)} : ${adresse} absent de la construction`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
rmSync(sortie, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('chaque section de TIERS.txt nomme un paquet du verrou, à sa version, sous la licence que porte son package.json', () => {
|
||||
const { packages: paquets } = JSON.parse(readFileSync(join(RACINE, 'package-lock.json'), 'utf8'));
|
||||
const tiers = sectionsDesTiers(readFileSync(join(RACINE, 'public', 'licences', 'TIERS.txt'), 'utf8'));
|
||||
assert.ok(tiers.length > 0, 'TIERS.txt ne porte aucune section de paquet');
|
||||
const fautes = tiers
|
||||
.filter(({ nom, version, licence }) => {
|
||||
const entrees = Object.entries(paquets).filter(
|
||||
([chemin, entree]) => chemin.endsWith(`node_modules/${nom}`) && entree.version === version,
|
||||
);
|
||||
return !entrees.some(([, entree]) => entree.license === licence);
|
||||
})
|
||||
.map(({ nom, version, licence }) => `${nom} ${version} — ${licence}`);
|
||||
assert.deepEqual(fautes, []);
|
||||
// Les paquets de la production PDF ont leur avis, même quand aucune
|
||||
// page ne les importe encore.
|
||||
for (const nom of ['pdf-lib', '@pdf-lib/fontkit', '@pdf-lib/standard-fonts', '@pdf-lib/upng', 'pako', 'tslib']) {
|
||||
assert.ok(tiers.some((s) => s.nom === nom), `${nom} sans avis`);
|
||||
}
|
||||
});
|
||||
|
||||
// La construction minifiée retire les commentaires ordinaires : l'avis
|
||||
// qu'un code incorporé porte en tête n'arrive dans www/ que par TIERS.txt.
|
||||
// Chaque ligne « Copyright … » d'un commentaire de la construction publiée
|
||||
// de fontkit y figure donc telle quelle, et une section « incorporé » qui
|
||||
// nomme Apache porte le texte entier de l'Apache License 2.0. Une ligne
|
||||
// « Copyright » est le texte du titulaire : aucune incise française n'y
|
||||
// entre, elle va dans l'en-tête de la section.
|
||||
test('TIERS.txt porte mot pour mot les avis que les commentaires de fontkit incorporent, et le texte de leur licence', () => {
|
||||
const tiers = readFileSync(join(RACINE, 'public', 'licences', 'TIERS.txt'), 'utf8');
|
||||
const lignes = new Set(tiers.split('\n').map((l) => l.trim()));
|
||||
const dossier = join(RACINE, 'node_modules', '@pdf-lib', 'fontkit');
|
||||
const { module: entree } = JSON.parse(readFileSync(join(dossier, 'package.json'), 'utf8'));
|
||||
const code = readFileSync(join(dossier, entree), 'utf8');
|
||||
const avis = [
|
||||
...code.matchAll(/^\s*(?:\/\/|\/\*|\*)\s*(Copyright\b.*\S)\s*$/gm),
|
||||
].map(([, ligne]) => ligne);
|
||||
assert.ok(avis.length > 0, `aucun avis dans ${entree}`);
|
||||
assert.deepEqual([...new Set(avis)].filter((ligne) => !lignes.has(ligne)), []);
|
||||
|
||||
const incorporees = tiers.split(/^=+$/m).filter((section) => /^\s*incorporé à /.test(section));
|
||||
assert.ok(incorporees.length > 0, 'aucune section « incorporé »');
|
||||
const TERMES_APACHE = [
|
||||
'Version 2.0, January 2004',
|
||||
'TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION',
|
||||
...['Definitions', 'Grant of Copyright License', 'Grant of Patent License', 'Redistribution', 'Submission of Contributions', 'Trademarks', 'Disclaimer of Warranty', 'Limitation of Liability', 'Accepting Warranty or Additional Liability'].map(
|
||||
(titre, i) => `${i + 1}. ${titre}.`,
|
||||
),
|
||||
'END OF TERMS AND CONDITIONS',
|
||||
];
|
||||
const sansTexteApache = incorporees
|
||||
.filter((section) => /Apache/.test(section))
|
||||
.filter((section) => TERMES_APACHE.some((terme) => !section.includes(terme)))
|
||||
.map((section) => section.trim().split('\n')[0]);
|
||||
assert.deepEqual(sansTexteApache, []);
|
||||
if (/Licensed under the Apache License/.test(code)) {
|
||||
assert.ok(incorporees.some((section) => /Apache/.test(section)), 'du code Apache-2.0 sans section qui le nomme');
|
||||
}
|
||||
|
||||
const incises = tiers.split('\n').filter((l) => /^Copyright\b/.test(l) && /[àâçéèêëîïôûùüœ]/.test(l));
|
||||
assert.deepEqual(incises, []);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,12 +17,16 @@
|
|||
// de fichier l'avertit à chaque série.
|
||||
//
|
||||
// Les démonstrations sont celles que src/main.js passe à App,
|
||||
// demonstrationsLivrees() : une seule composition, celle de src/demo.
|
||||
// demonstrationsLivrees() : une seule composition, celle de src/demo. La
|
||||
// police est chargée comme src/main.js la charge, par chargerPolices : sous
|
||||
// le serveur de Vite des épreuves, « ?inline » rend les mêmes octets que
|
||||
// dans la construction.
|
||||
import { mount, unmount } from 'svelte';
|
||||
import { creerExecuteurDirect } from '../../src/application/generation.js';
|
||||
import { creerHorlogePilotee } from '../../src/application/horloge.js';
|
||||
import { demonstrationsLivrees } from '../../src/demo/livrees.js';
|
||||
import App from '../../src/interface/App.svelte';
|
||||
import { chargerPolices } from '../../src/polices/chargement.js';
|
||||
import { FORMAT } from '../../src/stockage/document.js';
|
||||
import { VERSION } from '../../src/version.genere.js';
|
||||
import { creerFichiersSimules } from '../fichiers_simules.js';
|
||||
|
|
@ -102,6 +106,8 @@ export function tracer(fs) {
|
|||
* @param {string} [options.provenance]
|
||||
* @param {number} [options.formatCourant]
|
||||
* @param {Array<{cle: string, nom: string, texte: string}>} [options.demonstrations]
|
||||
* @param {() => Promise<{regulier: Uint8Array, gras: Uint8Array}>} [options.polices]
|
||||
* sinon chargerPolices, celui de src/main.js
|
||||
* @returns {Promise<Object>} { app, racine, fs, disque, trace, horloge,
|
||||
* presse, seance, facade, ui, erreurs(), demonter() } — seance est un
|
||||
* accesseur de app.seance() ; presse.copies, les textes copiés ;
|
||||
|
|
@ -119,6 +125,7 @@ export async function monterApp({
|
|||
provenance = 'test',
|
||||
formatCourant,
|
||||
demonstrations,
|
||||
polices = chargerPolices,
|
||||
} = {}) {
|
||||
await installerCollecteur();
|
||||
montages += 1;
|
||||
|
|
@ -166,6 +173,7 @@ export async function monterApp({
|
|||
formatCourant: formatCourant ?? FORMAT,
|
||||
produitVersion: VERSION.affichee,
|
||||
versionWindows: VERSION.windows,
|
||||
polices,
|
||||
...(theme === null ? {} : { themeInitial: theme }),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
31
test/polices.js
Normal file
31
test/polices.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La police embarquée lue sous node : les épreuves y lisent les fichiers de
|
||||
// src/polices par le disque, là où la page les reçoit de Vite par « ?inline »
|
||||
// (src/polices/chargement.js), syntaxe que node ne connaît pas (§ 14.8).
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const DOSSIER = new URL('../src/polices/', import.meta.url);
|
||||
|
||||
/** Les fichiers de la police, par graisse. */
|
||||
export const FICHIERS_POLICES = Object.freeze({
|
||||
regulier: 'NotoSans-Regular.ttf',
|
||||
gras: 'NotoSans-Bold.ttf',
|
||||
});
|
||||
|
||||
/** Le chemin absolu d'un fichier de src/polices. */
|
||||
export const cheminPolice = (nom) => fileURLToPath(new URL(nom, DOSSIER));
|
||||
|
||||
/**
|
||||
* Les octets des deux graisses, lus du disque à chaque appel.
|
||||
*
|
||||
* @returns {{regulier: Uint8Array, gras: Uint8Array}}
|
||||
*/
|
||||
export function lirePolices() {
|
||||
return {
|
||||
regulier: new Uint8Array(readFileSync(cheminPolice(FICHIERS_POLICES.regulier))),
|
||||
gras: new Uint8Array(readFileSync(cheminPolice(FICHIERS_POLICES.gras))),
|
||||
};
|
||||
}
|
||||
132
test/polices.test.js
Normal file
132
test/polices.test.js
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La police embarquée (§ 11.1, § 14.15, § 19.4) : les deux fichiers de
|
||||
// src/polices sont ceux que PROVENANCE déclare, octet pour octet ; chacun
|
||||
// couvre le latin étendu du français et ses ligatures, lu par fontkit ;
|
||||
// regulier.js et gras.js nomment ces fichiers ; la licence voyage à côté, à
|
||||
// son seul emplacement, public/licences/. La longueur de la chaîne témoin se
|
||||
// calcule des avances de la police, sans crénage (contrainte 26).
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
import { describe, test } from './lanceur.js';
|
||||
import { cheminPolice, FICHIERS_POLICES, lirePolices } from './polices.js';
|
||||
import { CHAINE_TEMOIN, lireProvenance, longueurTemoin } from './temoin.js';
|
||||
|
||||
const RACINE = fileURLToPath(new URL('..', import.meta.url));
|
||||
const lire = (chemin) => readFileSync(`${RACINE}${chemin}`);
|
||||
const sha256 = (octets) => createHash('sha256').update(octets).digest('hex');
|
||||
const provenance = () => lireProvenance(readFileSync(cheminPolice('PROVENANCE'), 'utf8'));
|
||||
|
||||
// Les blocs du latin que la police couvre en entier, bornes comprises, et
|
||||
// les signes du français hors de ces blocs.
|
||||
const BLOCS = [
|
||||
['Latin-1', 0xa0, 0xff],
|
||||
['Latin étendu A', 0x100, 0x17f],
|
||||
['Latin étendu B', 0x180, 0x24f],
|
||||
['Latin étendu additionnel', 0x1e00, 0x1eff],
|
||||
];
|
||||
const SIGNES = 'œŒæÆ’«»–—…fi';
|
||||
|
||||
// Les points de code à couvrir, dans l'ordre des blocs puis des signes.
|
||||
function pointsACouvrir() {
|
||||
const points = [];
|
||||
for (const [, debut, fin] of BLOCS) for (let point = debut; point <= fin; point += 1) points.push(point);
|
||||
for (const signe of SIGNES) points.push(signe.codePointAt(0));
|
||||
return points;
|
||||
}
|
||||
|
||||
describe('polices : les fichiers (§ 11.1, § 14.15)', () => {
|
||||
test('PROVENANCE déclare la version amont et les deux fichiers, dont la taille et l’empreinte sont celles du disque', () => {
|
||||
const { version, fichiers } = provenance();
|
||||
assert.match(version ?? '', /^\d+\.\d+$/);
|
||||
const polices = lirePolices();
|
||||
const attendus = [
|
||||
[FICHIERS_POLICES.regulier, polices.regulier],
|
||||
[FICHIERS_POLICES.gras, polices.gras],
|
||||
];
|
||||
for (const [nom, octets] of attendus) {
|
||||
const entree = fichiers.find((f) => f.nom === nom);
|
||||
assert.ok(entree !== undefined, `${nom} absent de PROVENANCE`);
|
||||
assert.equal(octets.length, entree.octets, nom);
|
||||
assert.equal(sha256(octets), entree.sha256, nom);
|
||||
}
|
||||
});
|
||||
|
||||
test('chaque graisse est Noto Sans de la version déclarée, Regular puis Bold', () => {
|
||||
const { version } = provenance();
|
||||
const polices = lirePolices();
|
||||
for (const [graisse, sousFamille] of [['regulier', 'Regular'], ['gras', 'Bold']]) {
|
||||
const police = fontkit.create(polices[graisse]);
|
||||
assert.equal(police.familyName, 'Noto Sans', graisse);
|
||||
assert.equal(police.subfamilyName, sousFamille, graisse);
|
||||
assert.ok(police.version.startsWith(`Version ${version};`), `${graisse} : ${police.version}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('chaque graisse couvre Latin-1, les latins étendus A, B et additionnel, et « œŒæÆ’«»–—…fi »', () => {
|
||||
const points = pointsACouvrir();
|
||||
assert.equal(points.length, 96 + 128 + 208 + 256 + 11);
|
||||
const polices = lirePolices();
|
||||
for (const graisse of ['regulier', 'gras']) {
|
||||
const police = fontkit.create(polices[graisse]);
|
||||
const manquants = points
|
||||
.filter((point) => police.glyphForCodePoint(point).id === 0)
|
||||
.map((point) => `U+${point.toString(16).toUpperCase().padStart(4, '0')}`);
|
||||
assert.deepEqual(manquants, [], graisse);
|
||||
}
|
||||
});
|
||||
|
||||
test('regulier.js et gras.js réexportent chacun leur fichier, incrusté par « ?inline »', () => {
|
||||
for (const [module, nom] of [['regulier.js', FICHIERS_POLICES.regulier], ['gras.js', FICHIERS_POLICES.gras]]) {
|
||||
const texte = readFileSync(cheminPolice(module), 'utf8');
|
||||
// « fro[m] » : la garde des chargements (test/chargements.js) ne lit
|
||||
// pas ce motif comme un import.
|
||||
const reexports = [...texte.matchAll(/^export \{ default \} fro[m] '\.\/([^'?]+)\?inline';$/gm)].map((m) => m[1]);
|
||||
assert.deepEqual(reexports, [nom], module);
|
||||
}
|
||||
});
|
||||
|
||||
test('la licence de la police est public/licences/NotoSans-OFL.txt, l’OFL.txt de l’archive amont, et TIERS.txt la nomme', () => {
|
||||
const { fichiers } = provenance();
|
||||
const ofl = fichiers.find((f) => f.nom === 'NotoSans-OFL.txt');
|
||||
assert.ok(ofl !== undefined, 'NotoSans-OFL.txt absent de PROVENANCE');
|
||||
assert.equal(ofl.amont, 'OFL.txt');
|
||||
const texte = lire('public/licences/NotoSans-OFL.txt');
|
||||
assert.equal(sha256(texte), ofl.sha256);
|
||||
assert.match(texte.toString('utf8'), /SIL OPEN FONT LICENSE Version 1\.1/);
|
||||
assert.match(lire('public/licences/TIERS.txt').toString('utf8'), /^Noto Sans .* — OFL-1\.1 — NotoSans-OFL\.txt$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe('polices : la chaîne témoin (§ 19.4, contrainte 26)', () => {
|
||||
test('la chaîne témoin est celle du contrat', () => {
|
||||
assert.equal(CHAINE_TEMOIN, 'Ôjgq Æ W — Benoît Œuvre fi');
|
||||
});
|
||||
|
||||
test('sa longueur est la somme des avances, sans crénage ni ligature : la mise en forme de fontkit sans « kern » ni « liga » la redonne', () => {
|
||||
const polices = lirePolices();
|
||||
for (const graisse of ['regulier', 'gras']) {
|
||||
const police = fontkit.create(polices[graisse]);
|
||||
const brute = police.layout(CHAINE_TEMOIN, { kern: false, liga: false }).advanceWidth;
|
||||
const avecCrenage = police.layout(CHAINE_TEMOIN, { liga: false }).advanceWidth;
|
||||
assert.ok(Math.abs(longueurTemoin(polices[graisse], police.unitsPerEm) - brute) < 1e-9, graisse);
|
||||
// Le crénage change la longueur : la somme ne le confond pas avec elle.
|
||||
assert.notEqual(brute, avecCrenage, graisse);
|
||||
}
|
||||
});
|
||||
|
||||
test('la longueur croît avec le corps, en proportion ; le gras est plus long ; un corps hors domaine lève', () => {
|
||||
const { regulier, gras } = lirePolices();
|
||||
const a16 = longueurTemoin(regulier, 16);
|
||||
assert.ok(Math.abs(longueurTemoin(regulier, 32) - 2 * a16) < 1e-9);
|
||||
assert.ok(longueurTemoin(gras, 16) > a16);
|
||||
assert.ok(longueurTemoin(regulier, 16, 'W') > longueurTemoin(regulier, 16, 'i'));
|
||||
for (const corps of [0, -1, Number.NaN, Number.POSITIVE_INFINITY, '16']) {
|
||||
assert.throws(() => longueurTemoin(regulier, corps), RangeError, String(corps));
|
||||
}
|
||||
});
|
||||
});
|
||||
64
test/temoin.js
Normal file
64
test/temoin.js
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
// La police embarquée vue des épreuves, sous node comme dans le navigateur :
|
||||
// ce module ne lit aucun fichier. Il porte la chaîne témoin du § 19.4 et sa
|
||||
// longueur attendue, calculée des avances de la police par fontkit — jamais
|
||||
// saisie (contrainte 26) —, et la lecture du fichier src/polices/PROVENANCE,
|
||||
// dont le texte lui arrive de l'appelant : readFileSync sous node, « ?raw »
|
||||
// sous Vite.
|
||||
import fontkit from '@pdf-lib/fontkit';
|
||||
|
||||
/** La chaîne témoin : une capitale accentuée haute, des jambages, une
|
||||
* ligature (« fi »), un tiret cadratin, les capitales larges, des
|
||||
* diacritiques. Sa longueur rendue trahit une police de repli. */
|
||||
export const CHAINE_TEMOIN = 'Ôjgq Æ W — Benoît Œuvre fi';
|
||||
|
||||
/**
|
||||
* La longueur de texte, en px, au corps corpsPx, de la police dont octets
|
||||
* sont le fichier : la somme des avances des glyphes de chaque point de
|
||||
* code, sans crénage ni ligature : la mise en forme de fontkit sans ses
|
||||
* traits « kern » et « liga » la redonne. Un rendu qui la compare doit les
|
||||
* couper lui aussi (font-kerning: none, font-variant-ligatures: none) :
|
||||
* dans Noto Sans Bold, la ligature « fi » avance d'une unité de moins que
|
||||
* « f » puis « i ».
|
||||
*
|
||||
* @param {Uint8Array} octets un fichier TrueType ou OpenType
|
||||
* @param {number} corpsPx nombre fini > 0
|
||||
* @param {string} [texte] CHAINE_TEMOIN par défaut
|
||||
* @returns {number}
|
||||
* @throws {RangeError} un corps qui n'est pas un nombre fini > 0
|
||||
*/
|
||||
export function longueurTemoin(octets, corpsPx, texte = CHAINE_TEMOIN) {
|
||||
if (!Number.isFinite(corpsPx) || corpsPx <= 0) {
|
||||
throw new RangeError(`longueurTemoin : corps fini > 0 attendu, reçu ${String(corpsPx)}`);
|
||||
}
|
||||
const police = fontkit.create(octets);
|
||||
let unites = 0;
|
||||
for (const caractere of texte) unites += police.glyphForCodePoint(caractere.codePointAt(0)).advanceWidth;
|
||||
return (unites / police.unitsPerEm) * corpsPx;
|
||||
}
|
||||
|
||||
// Une ligne « fichier <nom> : <chemin amont>, <n> octets, sha256 <hex> » de
|
||||
// PROVENANCE, et la ligne « clé : valeur » de l'en-tête.
|
||||
const LIGNE_FICHIER = /^fichier (\S+) : (\S+), (\d+) octets, sha256 ([0-9a-f]{64})$/gm;
|
||||
const LIGNE_CLE = (cle) => new RegExp(`^${cle} : (.+)$`, 'm');
|
||||
|
||||
/**
|
||||
* Ce que src/polices/PROVENANCE déclare : la version amont, et pour chaque
|
||||
* fichier versionné — les deux polices, la licence — sa taille et son
|
||||
* empreinte SHA-256, dans l'ordre du texte.
|
||||
*
|
||||
* @param {string} texte le contenu de PROVENANCE
|
||||
* @returns {{version: string|null, fichiers: Array<{nom: string, amont: string, octets: number, sha256: string}>}}
|
||||
*/
|
||||
export function lireProvenance(texte) {
|
||||
const version = LIGNE_CLE('version').exec(texte)?.[1] ?? null;
|
||||
const fichiers = [...texte.matchAll(LIGNE_FICHIER)].map(([, nom, amont, octets, sha256]) => ({
|
||||
nom,
|
||||
amont,
|
||||
octets: Number(octets),
|
||||
sha256,
|
||||
}));
|
||||
return { version, fichiers };
|
||||
}
|
||||
|
|
@ -11,6 +11,13 @@
|
|||
// lancement, et un seul morceau porte l'application entière, travailleur de
|
||||
// génération incrusté compris. Le seuil n'avertit donc que d'une croissance
|
||||
// anormale du paquet ; test/construction.long.test.js tombe au-delà.
|
||||
//
|
||||
// La production de la planche se charge à part, par un import différé, et
|
||||
// jamais au lancement. pdf-lib et fontkit y pèsent ensemble plus d'un
|
||||
// morceau : deux groupes les séparent, fontkit seul d'un côté, pdf-lib et ce
|
||||
// qu'il tire — pako, tslib, les polices standard, upng — de l'autre. La
|
||||
// police embarquée, elle, forme deux morceaux différés sans groupe : un par
|
||||
// graisse, puisque chacune a son module (src/polices).
|
||||
import { defineConfig } from 'vite';
|
||||
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
|
|
@ -21,5 +28,18 @@ export default defineConfig({
|
|||
outDir: 'www',
|
||||
emptyOutDir: true,
|
||||
chunkSizeWarningLimit: 1000,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: {
|
||||
groups: [
|
||||
{ name: 'fontkit', test: /[\\/]node_modules[\\/]@pdf-lib[\\/]fontkit[\\/]/ },
|
||||
{
|
||||
name: 'pdf-lib',
|
||||
test: /[\\/]node_modules[\\/](?:pdf-lib|pako|tslib|@pdf-lib[\\/](?:standard-fonts|upng))[\\/]/,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue