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
132 lines
6.5 KiB
JavaScript
132 lines
6.5 KiB
JavaScript
// © 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));
|
||
}
|
||
});
|
||
});
|