diff --git a/.github/workflows/danfse-visual.yml b/.github/workflows/danfse-visual.yml index d543789..9356eb1 100644 --- a/.github/workflows/danfse-visual.yml +++ b/.github/workflows/danfse-visual.yml @@ -9,6 +9,11 @@ on: - 'src/Danfse/**' - 'tests/fixtures/nfse_exemplo.xml' - 'tools/bin/render-danfse-visual-fixtures.php' + - 'tools/bin/verify-danfse-composer-package.php' + - 'tools/bin/verify-danfse-visual-fixtures.php' + - 'tools/src/DanfsePdfEvidence.php' + - 'tests/Unit/Danfse/DanfsePdfEvidenceTest.php' + - 'composer.json' - '.github/workflows/danfse-visual.yml' workflow_dispatch: @@ -17,7 +22,7 @@ permissions: jobs: pdf-layout: - name: Generate reference and long-description PDFs + name: Verify DANFSe PDFs (Dompdf 3) runs-on: ubuntu-latest steps: - name: Checkout @@ -35,31 +40,23 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Install dependencies + - name: Install dependencies (Dompdf 3.1.6 or newer in the 3.x series) run: composer install --prefer-dist --no-progress - name: Install PDF inspection tools run: sudo apt-get update -qq && sudo apt-get install -y -qq poppler-utils + - name: Verify bundled fonts and licenses in Composer distribution + run: php tools/bin/verify-danfse-composer-package.php + + - name: Run focused DANFSe regression tests + run: vendor/bin/phpunit tests/Unit/Danfse + - name: Generate synthetic PDFs for visual review run: php tools/bin/render-danfse-visual-fixtures.php "${{ runner.temp }}/danfse" - - name: Verify required fields and one-page A4 layout - shell: bash - run: | - for pdf in "${{ runner.temp }}/danfse/"*.pdf; do - pdftotext -layout "$pdf" - | grep -Fq "DATA CIENTIFICAÇÃO" - pdftotext -layout "$pdf" - | grep -Fq "IDENTIFICAÇÃO E ASSINATURA" - # Normalize PDF line wraps while still requiring the complete official disclosure. - pdftotext -layout "$pdf" - | tr -s '[:space:]' ' ' | grep -Fq "Totais aproximados dos Tributos" - pages=$(pdfinfo "$pdf" | awk '/^Pages:/ {print $2}') - test "$pages" -eq 1 - pdfinfo "$pdf" | grep -Fq 'Page size: 595.28 x 841.89 pts (A4)' - done - pdftotext -layout "${{ runner.temp }}/danfse/danfse-long-description.pdf" - | grep -Fq '...' - # A locally supplied TTF must be genuinely embedded (not silently - # replaced by Helvetica). The probe font is freely licensed. - pdffonts "${{ runner.temp }}/danfse/danfse-font-probe.pdf" | grep -Fq 'DejaVuSans' + - name: Verify PDF evidence (same command as local development) + run: php tools/bin/verify-danfse-visual-fixtures.php "${{ runner.temp }}/danfse" - name: Preserve render evidence if: always() diff --git a/LICENSES/OFL-1.1.txt b/LICENSES/OFL-1.1.txt new file mode 100644 index 0000000..aba73e8 --- /dev/null +++ b/LICENSES/OFL-1.1.txt @@ -0,0 +1,102 @@ +Digitized data copyright (c) 2010 Google Corporation + with Reserved Font Arimo, Tinos and Cousine. +Copyright (c) 2012 Red Hat, Inc. + with Reserved Font Name Liberation. + +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: +http://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. + diff --git a/REUSE.toml b/REUSE.toml index 699e971..3b8c546 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -38,3 +38,9 @@ path = [ precedence = "override" SPDX-FileCopyrightText = "Sistema Nacional NFS-e / Governo Federal" SPDX-License-Identifier = "CC-BY-ND-3.0" + +[[annotations]] +path = ["src/Danfse/Assets/fonts/*.ttf"] +precedence = "override" +SPDX-FileCopyrightText = "2010 Google Corporation; 2012 Red Hat, Inc." +SPDX-License-Identifier = "OFL-1.1" diff --git a/composer.json b/composer.json index 8c1369c..ca31ad0 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,7 @@ "ext-soap": "*", "bacon/bacon-qr-code": "^3.0", "csharpru/vault-php": "^4.4", - "dompdf/dompdf": "^2.0 || ^3.0", + "dompdf/dompdf": "^3.1.6", "psr/http-client": "^1.0", "psr/http-factory": "^1.0" }, @@ -71,7 +71,14 @@ "ci:php": "@php tools/bin/ci-php.php validate", "domains:download": "@php tools/bin/domains.php download", "domains:verify-indicators": "@php tools/bin/domains.php verify-indicators", - "domains:contract-matrix": "@php tools/bin/domains.php contract-matrix" + "domains:contract-matrix": "@php tools/bin/domains.php contract-matrix", + "danfse:visual": [ + "@danfse:package", + "@php tools/bin/render-danfse-visual-fixtures.php build/danfse-visual", + "@php tools/bin/verify-danfse-visual-fixtures.php build/danfse-visual" + ], + "danfse:verify": "@php tools/bin/verify-danfse-visual-fixtures.php build/danfse-visual", + "danfse:package": "@php tools/bin/verify-danfse-composer-package.php" }, "minimum-stability": "stable", "prefer-stable": true, diff --git a/docs/danfse-v2-rendering.md b/docs/danfse-v2-rendering.md index e08ebb4..ef6b2c3 100644 --- a/docs/danfse-v2-rendering.md +++ b/docs/danfse-v2-rendering.md @@ -29,6 +29,29 @@ The stable contract covered by tests is: - optional contact complement and IBGE/CEP follow the authorized XML without inventing missing data; - the reference footer never fabricates an acknowledgement date or human signature. +## Supported PDF engine + +The library requires **Dompdf 3.1.6 or later within the 3.x series** +(`dompdf/dompdf: ^3.1.6`), rather than the older 2.x major. +The minimum includes security fixes released in Dompdf 3.1.6; +Composer dependency resolution must retain security-advisory blocking. +The library does not attempt to provide compatibility with Dompdf 2. + +## Bundled Liberation Sans typography + +By default the renderer embeds unmodified Liberation Sans 2.1.5 Regular and +Bold under SIL Open Font License 1.1 (OFL-1.1). Both TrueType files, their +SHA-256 provenance and the OFL terms are bundled with the Composer package. +The library uses only local resources, a private process-local font metrics +cache and distinct namespaced families; no OS font or remote download is needed. +A missing/unreadable/altered mandatory font raises an artifact error rather +than silently selecting Helvetica. + +**Explicit normative divergence:** NT008 v1.02 (14 July 2026), item 2.4, +names Arial and Microsoft Sans Serif. Liberation Sans is an engineering +substitute with compatible metrics, not literal compliance with those named +families. There is no official approval of this substitution being asserted. + ## Locally licensed typography NT 008 requires **Arial** for labels and **Microsoft Sans Serif** for values. @@ -48,9 +71,13 @@ registration from changing the default renderer's typography. An A4 PDF should be inspected with `pdffonts receipt.pdf`; exact typography is demonstrated only when the correct typefaces are present and embedded. -The default compatibility path may still fall back to Helvetica and must not -be claimed to prove complete NT 008 font compliance. CI exercises local-font -embedding with Dompdf's freely licensed DejaVuSans **only as a test surrogate**. +The packaged default path embeds Liberation Sans, not Helvetica. Even when the +font is embedded, it does **not** meet the exact typeface names of NT008. +CI also exercises custom-font embedding with DejaVuSans **only as a test +surrogate**. A caller holding valid licenses can still supply Arial and +Microsoft Sans Serif via the unchanged `DanfseFonts` API. That API has no +separate bold face for value text; highlighted bold values explicitly use the +caller-provided Arial Bold file, avoiding an undocumented Helvetica fallback. The project does not guarantee byte-identical PDF output. PDF metadata, font metrics and QR SVG serialization can change when rendering dependencies are diff --git a/src/Danfse/Assets/fonts/LiberationSans-Bold.ttf b/src/Danfse/Assets/fonts/LiberationSans-Bold.ttf new file mode 100644 index 0000000..dc5d57f Binary files /dev/null and b/src/Danfse/Assets/fonts/LiberationSans-Bold.ttf differ diff --git a/src/Danfse/Assets/fonts/LiberationSans-Regular.ttf b/src/Danfse/Assets/fonts/LiberationSans-Regular.ttf new file mode 100644 index 0000000..e633985 Binary files /dev/null and b/src/Danfse/Assets/fonts/LiberationSans-Regular.ttf differ diff --git a/src/Danfse/Assets/fonts/NOTICE.md b/src/Danfse/Assets/fonts/NOTICE.md new file mode 100644 index 0000000..477c865 --- /dev/null +++ b/src/Danfse/Assets/fonts/NOTICE.md @@ -0,0 +1,35 @@ + + +# Bundled Liberation Sans font assets + +The bundled TTF name tables identify Liberation Sans version 2.1.5 +and SIL OFL 1.1. Their exact SHA-256 fingerprints are recorded below. +The official upstream binary release is available at: +https://github.com/liberationfonts/liberation-fonts/releases/tag/2.1.5 + +MacPorts independently records SHA-256 +`7191c669bf38899f73a2094ed00f7b800553364f90e2637010a69c0e268f25d0` +for the complete upstream `liberation-fonts-ttf-2.1.5.tar.gz` archive: +https://github.com/macports/macports-ports/blob/master/x11/liberation-fonts/Portfile + +The above *archive* checksum has not been directly compared with the +bundled font binaries in this repository; do not mistake the recorded +individual-file hashes and internal metadata for proof of byte-for-byte +correspondence with the upstream archive. + +| File | SHA-256 | +| --- | --- | +| `LiberationSans-Regular.ttf` | `76d04c18ea243f426b7de1f3ad208e927008f961dc5945e5aad352d0dfde8ee8` | +| `LiberationSans-Bold.ttf` | `788abee4c806d660e8aee46689dd8540cd4bb98da03dcc9d171ce3efd99a9173` | + +Font copyright: Google Corporation (2010) and Red Hat, Inc. (2012). +Licensed under **SIL Open Font License 1.1 (SPDX: OFL-1.1)**, with +reserved font names **Arimo, Tinos, Cousine** and **Liberation**. +See `LICENSES/OFL-1.1.txt`, distributed with the Composer package. + +The PDF documents are locally generated without runtime font downloads. +The fonts are not Microsoft font files. NT008 v1.02 explicitly names +Arial and Microsoft Sans Serif; substitution is not nominal compliance. diff --git a/src/Danfse/Config/BundledDanfseFonts.php b/src/Danfse/Config/BundledDanfseFonts.php new file mode 100644 index 0000000..b58f6eb --- /dev/null +++ b/src/Danfse/Config/BundledDanfseFonts.php @@ -0,0 +1,83 @@ + '76d04c18ea243f426b7de1f3ad208e927008f961dc5945e5aad352d0dfde8ee8', + 'LiberationSans-Bold.ttf' => '788abee4c806d660e8aee46689dd8540cd4bb98da03dcc9d171ce3efd99a9173', + ]; + + private static ?DanfseFonts $cached = null; + + public static function get(): DanfseFonts + { + $fontDir = __DIR__ . '/../Assets/fonts'; + foreach (self::FILE_SHA256 as $filename => $checksum) { + self::assertValidFile($fontDir . '/' . $filename, $checksum); + } + + if (self::$cached !== null) { + if (!is_dir(self::$cached->fontCacheDirectory) || !is_writable(self::$cached->fontCacheDirectory)) { + throw self::error('Default font cache became unavailable.'); + } + + return self::$cached; + } + + $cache = sys_get_temp_dir() . '/nfse-danfse-liberation-' . bin2hex(random_bytes(12)); + if (!mkdir($cache, 0o700) || !is_writable($cache)) { + throw self::error('Unable to create a private Dompdf font cache.'); + } + + // Dompdf writes font metrics during first use. Keep the cache private + // per PHP process and clean it at shutdown; never write to vendor/. + register_shutdown_function(static function () use ($cache): void { + foreach (glob($cache . '/*') ?: [] as $file) { + if (is_file($file) && !is_link($file)) { + @unlink($file); + } + } + @rmdir($cache); + }); + + return self::$cached = new DanfseFonts( + $fontDir . '/LiberationSans-Regular.ttf', + $fontDir . '/LiberationSans-Bold.ttf', + $fontDir . '/LiberationSans-Regular.ttf', + $cache, + ); + } + + /** + * @internal Integrity check shared by production and negative unit tests. + */ + public static function assertValidFile(string $path, string $sha256): void + { + if (!is_file($path) || !is_readable($path)) { + throw self::error('Bundled Liberation Sans font missing or unreadable: ' . basename($path)); + } + + $actual = hash_file('sha256', $path); + if (!is_string($actual) || !hash_equals($sha256, $actual)) { + throw self::error('Bundled Liberation Sans font failed integrity check: ' . basename($path)); + } + } + + private static function error(string $message): ArtifactException + { + return new ArtifactException($message, NfseErrorCode::ArtifactRetrievalFailed); + } +} diff --git a/src/Danfse/Config/DanfseFonts.php b/src/Danfse/Config/DanfseFonts.php index bb244b5..8eda03a 100644 --- a/src/Danfse/Config/DanfseFonts.php +++ b/src/Danfse/Config/DanfseFonts.php @@ -55,20 +55,27 @@ public function directories(): array */ public function fontFaceCss(): string { - // Dompdf 2.x/3.x caches FontMetrics::getFont() lookups statically + // Dompdf 3.x caches FontMetrics::getFont() lookups statically // by CSS family name, across renderer instances in one PHP process. // A namespaced family avoids poisoning the next default document. $label = $this->labelFamily(); $value = $this->valueFamily(); - return implode("\n", [ + $faces = [ '@font-face { font-family: "' . $label . '"; font-style: normal; font-weight: 400; src: url("' . self::fileUri($this->arialRegular) . '") format("truetype"); }', '@font-face { font-family: "' . $label . '"; font-style: normal; font-weight: 700; src: url("' . self::fileUri($this->arialBold) . '") format("truetype"); }', '@font-face { font-family: "' . $value . '"; font-style: normal; font-weight: 400; src: url("' . self::fileUri($this->microsoftSansSerif) . '") format("truetype"); }', - ]); + ]; + // The public custom-font contract supplies a bold label face, but no + // separate bold value face. Explicitly register the available bold + // file for highlighted values instead of permitting Helvetica fallback. + $faces[] = '@font-face { font-family: "' . $value . '"; font-style: normal; font-weight: 700; src: url("' . + self::fileUri($this->arialBold) . '") format("truetype"); }'; + + return implode("\n", $faces); } public function fontOverrideCss(): string diff --git a/src/Danfse/DanfseGenerator.php b/src/Danfse/DanfseGenerator.php index bd8ba8e..d55320d 100644 --- a/src/Danfse/DanfseGenerator.php +++ b/src/Danfse/DanfseGenerator.php @@ -9,6 +9,7 @@ use Dompdf\Dompdf; use Dompdf\Options; +use LibreCodeCoop\NfsePHP\Danfse\Config\BundledDanfseFonts; use LibreCodeCoop\NfsePHP\Danfse\Config\DanfseConfig; use LibreCodeCoop\NfsePHP\Exception\ArtifactException; use LibreCodeCoop\NfsePHP\Exception\NfseErrorCode; @@ -66,6 +67,7 @@ private function renderOnePage(string $xml): array } $this->assertAuthorizedNfse($data); + $fonts = $this->config->fonts ?? BundledDanfseFonts::get(); $template = new DanfseTemplate(); // [service-description limit, additional free-text limit]. @@ -86,20 +88,17 @@ private function renderOnePage(string $xml): array $options = new Options(); $options->set('isHtml5ParserEnabled', true); $options->set('isRemoteEnabled', false); - $options->set('defaultFont', 'Helvetica'); - if ($this->config->fonts !== null) { - // Permit only the caller-provided, validated font - // directories, not arbitrary filesystem access. - $options->setChroot(array_values(array_unique(array_merge( - $options->getChroot(), - $this->config->fonts->directories(), - )))); - // Isolate dynamic font metrics from Dompdf's bundled - // cache. Without this, later default-font documents can - // silently inherit a different typeface and overflow A4. - $options->setFontDir($this->config->fonts->fontCacheDirectory); - $options->setFontCache($this->config->fonts->fontCacheDirectory); - } + $options->set('defaultFont', $fonts->labelFamily()); + // Resolve both default and caller-supplied fonts from local + // files. Font directories are restricted by Dompdf chroot. + $options->setChroot(array_values(array_unique(array_merge( + $options->getChroot(), + $fonts->directories(), + )))); + // Dompdf 3 can cache font lookups between documents in one + // PHP process; isolate metrics and family names per font set. + $options->setFontDir($fonts->fontCacheDirectory); + $options->setFontCache($fonts->fontCacheDirectory); $dompdf = new Dompdf($options); $dompdf->loadHtml($html, 'UTF-8'); diff --git a/src/Danfse/DanfseTemplate.php b/src/Danfse/DanfseTemplate.php index ab657e5..7fd55c9 100644 --- a/src/Danfse/DanfseTemplate.php +++ b/src/Danfse/DanfseTemplate.php @@ -11,6 +11,7 @@ use BaconQrCode\Renderer\ImageRenderer; use BaconQrCode\Renderer\RendererStyle\RendererStyle; use BaconQrCode\Writer; +use LibreCodeCoop\NfsePHP\Danfse\Config\BundledDanfseFonts; use LibreCodeCoop\NfsePHP\Danfse\Config\DanfseConfig; use LibreCodeCoop\NfsePHP\Danfse\Data\Municipios; use LibreCodeCoop\NfsePHP\Danfse\Enum\Ambiente; @@ -61,8 +62,9 @@ public function render( // This is an explicitly supplied, externally verified state; it // cannot be inferred from an unchanged authorized NFS-e XML. $fiscalState = $config->fiscalState; - $fontFaces = $config->fonts?->fontFaceCss() ?? ''; - $fontOverrides = $config->fonts?->fontOverrideCss() ?? ''; + $fonts = $config->fonts ?? BundledDanfseFonts::get(); + $fontFaces = $fonts->fontFaceCss(); + $fontOverrides = $fonts->fontOverrideCss(); $ambiente = Ambiente::fromValue((string) $data['ambiente']); $isHomologacao = $ambiente->isHomologacao(); $qrCode = $this->generateQrCode((string) $data['chave_acesso'], $ambiente); diff --git a/tests/Unit/Danfse/DanfseGeneratorTest.php b/tests/Unit/Danfse/DanfseGeneratorTest.php index 8319a07..f1a7288 100644 --- a/tests/Unit/Danfse/DanfseGeneratorTest.php +++ b/tests/Unit/Danfse/DanfseGeneratorTest.php @@ -7,6 +7,7 @@ namespace LibreCodeCoop\NfsePHP\Tests\Unit\Danfse; +use LibreCodeCoop\NfsePHP\Danfse\Config\BundledDanfseFonts; use LibreCodeCoop\NfsePHP\Danfse\Config\DanfseConfig; use LibreCodeCoop\NfsePHP\Danfse\Config\DanfseFiscalState; use LibreCodeCoop\NfsePHP\Danfse\Config\DanfseFonts; @@ -31,6 +32,61 @@ protected function setUp(): void $this->xml = $xml; } + public function testBundledLiberationFontsAreAvailableAndRejectTampering(): void + { + $fontDir = dirname(__DIR__, 3) . '/src/Danfse/Assets/fonts'; + $regular = $fontDir . '/LiberationSans-Regular.ttf'; + $bold = $fontDir . '/LiberationSans-Bold.ttf'; + self::assertFileExists($regular); + self::assertFileExists($bold); + self::assertSame('76d04c18ea243f426b7de1f3ad208e927008f961dc5945e5aad352d0dfde8ee8', hash_file('sha256', $regular)); + self::assertSame('788abee4c806d660e8aee46689dd8540cd4bb98da03dcc9d171ce3efd99a9173', hash_file('sha256', $bold)); + self::assertFileExists(dirname(__DIR__, 3) . '/LICENSES/OFL-1.1.txt'); + + $fonts = BundledDanfseFonts::get(); + self::assertSame(realpath($regular), $fonts->arialRegular); + self::assertSame(realpath($bold), $fonts->arialBold); + + $this->expectException(ArtifactException::class); + BundledDanfseFonts::assertValidFile($regular, str_repeat('0', 64)); + } + + public function testBundledFontsAreEmbeddedByDefaultWithoutRemoteUrls(): void + { + $html = (new DanfseGenerator())->generateHtml($this->xml); + $fonts = BundledDanfseFonts::get(); + self::assertStringContainsString($fonts->labelFamily(), $html); + self::assertStringContainsString($fonts->valueFamily(), $html); + self::assertStringContainsString('LiberationSans-Regular.ttf', $html); + self::assertStringContainsString('LiberationSans-Bold.ttf', $html); + self::assertStringContainsString('font-weight: 700', $html); + self::assertStringNotContainsString('data:font/', $html); + self::assertStringNotContainsString('url("http', $html); + self::assertStringStartsWith('%PDF-', (new DanfseGenerator())->generateFromXml($this->xml)); + } + + public function testDefaultCustomDefaultAndDistinctCustomCachesRemainIsolated(): void + { + $path = dirname(__DIR__, 3) . '/vendor/dompdf/dompdf/lib/fonts'; + $regular = $path . '/DejaVuSans.ttf'; + $bold = $path . '/DejaVuSans-Bold.ttf'; + $firstCache = sys_get_temp_dir() . '/nfse-font-A-' . bin2hex(random_bytes(6)); + $secondCache = sys_get_temp_dir() . '/nfse-font-B-' . bin2hex(random_bytes(6)); + self::assertTrue(mkdir($firstCache, 0o700)); + self::assertTrue(mkdir($secondCache, 0o700)); + $a = new DanfseFonts($regular, $bold, $regular, $firstCache); + $b = new DanfseFonts($regular, $bold, $regular, $secondCache); + self::assertNotSame($a->labelFamily(), $b->labelFamily()); + + foreach ([null, $a, null, $b, $a, null] as $selected) { + $config = new DanfseConfig(fonts: $selected); + $html = (new DanfseGenerator($config))->generateHtml($this->xml); + $family = ($selected ?? BundledDanfseFonts::get())->labelFamily(); + self::assertStringContainsString($family, $html); + self::assertStringStartsWith('%PDF-', (new DanfseGenerator($config))->generateFromXml($this->xml)); + } + } + public function testGenerateFromXmlReturnsPdfBinary(): void { $pdf = (new DanfseGenerator())->generateFromXml($this->xml); @@ -68,6 +124,7 @@ public function testLocalFontFilesCanBeRegisteredWithoutRemoteLoading(): void $html = $generator->generateHtml($this->xml); self::assertStringContainsString('font-family: "' . $fonts->labelFamily() . '"', $html); self::assertStringContainsString('font-family: "' . $fonts->valueFamily() . '"', $html); + self::assertStringContainsString('font-family: "' . $fonts->valueFamily() . '"; font-style: normal; font-weight: 700;', $html); self::assertStringNotContainsString('@font-face { font-family: "Arial"', $html); self::assertStringContainsString('src: url("file://', $html); self::assertStringNotContainsString('data:font/', $html); diff --git a/tests/Unit/Danfse/DanfsePdfEvidenceTest.php b/tests/Unit/Danfse/DanfsePdfEvidenceTest.php new file mode 100644 index 0000000..3ca442c --- /dev/null +++ b/tests/Unit/Danfse/DanfsePdfEvidenceTest.php @@ -0,0 +1,89 @@ +generateFromXml($xml); file_put_contents($outputDir . '/danfse-font-probe.pdf', $probePdf); + + +// The default font must be embedded even without caller configuration. +// Interleave default/custom/default rendering to expose FontMetrics cache leaks. +file_put_contents($outputDir . '/danfse-after-custom.pdf', $generator->generateFromXml($xml)); diff --git a/tools/bin/verify-danfse-composer-package.php b/tools/bin/verify-danfse-composer-package.php new file mode 100644 index 0000000..aa5946e --- /dev/null +++ b/tools/bin/verify-danfse-composer-package.php @@ -0,0 +1,102 @@ + ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, $root); + if (!is_resource($process)) { + throw new RuntimeException('Could not start composer archive.'); + } + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exit = proc_close($process); + if ($exit !== 0) { + throw new RuntimeException('Composer archive failed (exit ' . $exit . '): ' . trim((string) $stderr)); + } + + $archives = glob($dir . '/*.tar') ?: []; + if (count($archives) !== 1) { + throw new RuntimeException('Composer must generate exactly one TAR package: ' . (string) $stdout); + } + + // Expected hashes originate from the independently verified 2.1.5 TTFs. + $required = [ + 'src/Danfse/Assets/fonts/LiberationSans-Regular.ttf' + => '76d04c18ea243f426b7de1f3ad208e927008f961dc5945e5aad352d0dfde8ee8', + 'src/Danfse/Assets/fonts/LiberationSans-Bold.ttf' + => '788abee4c806d660e8aee46689dd8540cd4bb98da03dcc9d171ce3efd99a9173', + 'src/Danfse/Assets/fonts/NOTICE.md' => null, + 'LICENSES/OFL-1.1.txt' => null, + 'REUSE.toml' => null, + 'src/Danfse/Config/BundledDanfseFonts.php' => null, + 'composer.json' => null, + ]; + $found = []; + foreach (new RecursiveIteratorIterator(new PharData($archives[0])) as $entry) { + if (!$entry instanceof PharFileInfo || !$entry->isFile()) { + continue; + } + + // PharData keys contain "phar:///". + // Match full archive-relative paths: suffix-only matches also + // incorrectly accept vendor-bin/domains/composer.json as the root. + $archivePrefix = 'phar://' . $archives[0] . '/'; + $fullPath = $entry->getPathname(); + if (!str_starts_with($fullPath, $archivePrefix)) { + throw new RuntimeException('Invalid Composer archive entry path: ' . $fullPath); + } + $entryPath = substr($fullPath, strlen($archivePrefix)); + foreach ($required as $relative => $checksum) { + if ($entryPath !== $relative) { + continue; + } + if (isset($found[$relative])) { + throw new RuntimeException('Duplicate package entry: ' . $relative); + } + if ($checksum !== null && !hash_equals($checksum, hash('sha256', $entry->getContent()))) { + throw new RuntimeException('Packaged font checksum mismatch: ' . $relative); + } + $found[$relative] = true; + } + } + + foreach (array_keys($required) as $relative) { + if (!isset($found[$relative])) { + throw new RuntimeException('Missing file in Composer package: ' . $relative); + } + } + fwrite(STDOUT, "Composer archive includes verified Liberation Sans TTFs, license, provenance and renderer.\n"); +} finally { + foreach (glob($dir . '/*') ?: [] as $file) { + if (is_file($file)) { + unlink($file); + } + } + rmdir($dir); +} diff --git a/tools/bin/verify-danfse-visual-fixtures.php b/tools/bin/verify-danfse-visual-fixtures.php new file mode 100644 index 0000000..d5fad3c --- /dev/null +++ b/tools/bin/verify-danfse-visual-fixtures.php @@ -0,0 +1,63 @@ + ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes); + if (!is_resource($process)) { + fwrite(STDERR, "$filename: unable to start {$command[0]}\n"); + $failed = true; + continue 2; + } + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $status = proc_close($process); + if ($status !== 0 || !is_string($stdout)) { + fwrite(STDERR, "$filename: {$command[0]} failed (exit $status)\n"); + $failed = true; + continue 2; + } + $outputs[] = $stdout; + } + + $errors = DanfsePdfEvidence::check($filename, $outputs[0], $outputs[1], $outputs[2]); + if ($errors !== []) { + $failed = true; + foreach ($errors as $error) { + fwrite(STDERR, "$filename: $error\n"); + } + } else { + fwrite(STDOUT, "$filename: A4/1 page, mandatory text and expected embedded fonts verified\n"); + } +} + +exit($failed ? 1 : 0); diff --git a/tools/src/DanfsePdfEvidence.php b/tools/src/DanfsePdfEvidence.php new file mode 100644 index 0000000..a68c031 --- /dev/null +++ b/tools/src/DanfsePdfEvidence.php @@ -0,0 +1,111 @@ + */ + public const REQUIRED_FILES = [ + 'danfse-reference.pdf', + 'danfse-long-description.pdf', + 'danfse-regime-zero.pdf', + 'danfse-regime-nine.pdf', + 'danfse-regime-absent.pdf', + 'danfse-immunity-zero.pdf', + 'danfse-benefit-zero.pdf', + 'danfse-no-iss-incidence.pdf', + 'danfse-font-probe.pdf', + 'danfse-after-custom.pdf', + ]; + + /** + * @return list Violated invariants, each with an actionable message. + */ + public static function check(string $filename, string $pdfInfo, string $pdfFonts, string $pdfText): array + { + $errors = []; + if (preg_match('/^Pages:\s*(\d+)\s*$/m', $pdfInfo, $match) !== 1 || $match[1] !== '1') { + $errors[] = 'Expected exactly one PDF page'; + } + + if (preg_match('/^Page size:\s*([\d.]+)\s+x\s+([\d.]+)\s+pts\s+\(A4\)/m', $pdfInfo, $match) !== 1 + || abs((float) $match[1] - 595.28) > 0.2 + || abs((float) $match[2] - 841.89) > 0.2 + ) { + $errors[] = 'Expected A4 portrait (595.28 x 841.89 pt)'; + } + + $text = preg_replace('/\s+/u', ' ', $pdfText); + if (!is_string($text)) { + $errors[] = 'Invalid UTF-8 text extraction'; + $text = ''; + } + foreach (['DATA CIENTIFICAÇÃO', 'IDENTIFICAÇÃO E ASSINATURA', 'Totais aproximados dos Tributos'] as $required) { + if (!str_contains($text, $required)) { + $errors[] = 'Missing mandatory visible text: ' . $required; + } + } + if ($filename === 'danfse-long-description.pdf' && !str_contains($text, '...')) { + $errors[] = 'Long service description must be visibly abbreviated'; + } + + $fontRows = []; + foreach (preg_split('/\R/', trim($pdfFonts)) ?: [] as $line) { + $line = trim($line); + if ($line === '' || str_starts_with($line, 'name ') || strspn($line, '- ') === strlen($line)) { + continue; + } + // pdffonts "type" and "encoding" contain spaces. The three flags + // (emb/sub/uni) are aligned from the RIGHT, not at $4 in awk. + if (preg_match('/^(\S+)\s+.+?\s+(yes|no)\s+(yes|no)\s+(yes|no)\s+\d+\s+\d+$/i', $line, $match) !== 1) { + $errors[] = 'Unrecognized pdffonts row'; + continue; + } + $fontRows[] = [ + 'name' => preg_replace('/^[A-Z]{6}\+/', '', $match[1]), + 'embedded' => strtolower($match[2]) === 'yes', + ]; + } + + if ($fontRows === []) { + $errors[] = 'No fonts reported by pdffonts'; + return $errors; + } + + $custom = $filename === 'danfse-font-probe.pdf'; + $family = $custom ? 'DejaVuSans' : 'LiberationSans'; + $seen = []; + foreach ($fontRows as $font) { + $name = $font['name']; + if (in_array($name, [$family, $family . '-Bold'], true)) { + $seen[$name] = ($seen[$name] ?? false) || $font['embedded']; + if (!$font['embedded']) { + $errors[] = 'Required font is not embedded: ' . $name; + } + } else { + // Neither bundled nor caller-provided fonts may silently fall + // back to an unlicensed/non-embedded PDF core font. + $errors[] = ($custom ? 'Unexpected custom font: ' : 'Unexpected default font: ') . $name; + } + } + foreach ([$family, $family . '-Bold'] as $required) { + if (!($seen[$required] ?? false)) { + $errors[] = 'Missing embedded font: ' . $required; + } + } + return $errors; + } +}