[READ-ONLY] Mirror of https://github.com/jmrplens/PyOctaveBand. [Python3] Octave-Band and Fractional Octave-Band filter. For signal in time domain. jmrplens.github.io/PyOctaveBand/
acoustics audio filter frequency frequency-analysis frequency-domain octave python3 signal time-domain
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(site): inline PNG previews of the .report() fiches via a ReportPreview component (#239)

Rasterize the first page of every example .report() PDF to a committed PNG
preview so the documentation shows the fiche inline, not only as a download.

scripts/generate_reports.py now writes a <name>.png next to each <name>.pdf
(pypdfium2, no system Poppler; oxipng level-6 lossless recompress, matching the
figures pipeline). Like the PDFs, the previews are not byte-checked in CI (the
embedded vector plot drifts ~1 ULP across CPUs); tests/test_generate_reports.py
adds a check that a valid PNG is produced for every example.

ReportPreview.astro embeds a preview as a clickable thumbnail that opens the
PDF, mirroring the Video convention: a short accessible name (aria-label) plus a
hidden long description (aria-describedby), a decorative image, and a visible
localized download link. The field guide (EN/ES) and the GitHub docs now show
both ISO 717 fiches inline with a documented ReportMetadata field table.

authored by

José M. Requena Plens and committed by
GitHub
(Jul 20, 2026, 4:25 AM +0200) 9fc1c86e 32e23b5f

+332 -15
.github/reports/iso717_airborne_example.png

This is a binary file and will not be displayed.

.github/reports/iso717_impact_example.png

This is a binary file and will not be displayed.

+26 -4
docs/insulation-field.md
··· 379 379 ``` 380 380 381 381 Rendered examples of both fiches, regenerated with `make reports`, are kept in 382 - the repository: the airborne 383 - [`iso717_airborne_example.pdf`](https://github.com/jmrplens/phonometry/raw/main/.github/reports/iso717_airborne_example.pdf) 384 - and the impact 385 - [`iso717_impact_example.pdf`](https://github.com/jmrplens/phonometry/raw/main/.github/reports/iso717_impact_example.pdf). 382 + the repository. Click either preview to open the PDF: 383 + 384 + [![Airborne ISO 717-1 example report: metadata header, one-third-octave R table beside the measured-versus-shifted-reference plot, boxed Rw (C; Ctr) and a PASS verdict](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso717_airborne_example.png)](https://github.com/jmrplens/phonometry/raw/main/.github/reports/iso717_airborne_example.pdf) 385 + 386 + *Airborne rating fiche (`WeightedRatingResult.report`), Rw (C; Ctr).* 387 + 388 + [![Impact ISO 717-2 example report: the same accredited layout for the normalized impact level Ln, boxed Ln,w (CI) and a FAIL verdict](https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports/iso717_impact_example.png)](https://github.com/jmrplens/phonometry/raw/main/.github/reports/iso717_impact_example.pdf) 389 + 390 + *Impact rating fiche (`ImpactRatingResult.report`), Ln,w (CI).* 391 + 392 + #### Report metadata (`ReportMetadata`) 393 + 394 + Every field is optional and only the supplied ones are rendered, so the same 395 + object drives a full accredited fiche and a lightweight prediction fiche. The 396 + numeric fields are validated on construction by physical range. 397 + 398 + | Field | Type | Rendered as | 399 + | --- | --- | --- | 400 + | `specimen`, `client`, `mounted_by`, `manufacturer` | `str` | Header identity of the tested element and who it was tested for / mounted by | 401 + | `area`, `mass_per_area` | `float > 0` | Sample area *S* (m²) and measured mass per unit area (kg/m²) | 402 + | `source_volume`, `receiving_volume` | `float > 0` | Room volumes (m³) | 403 + | `temperature`, `relative_humidity`, `pressure` | `float` | Single representative climate: air temperature (°C, any sign), relative humidity (0–100 %), ambient pressure (kPa, > 0) | 404 + | `source_temperature`, `source_relative_humidity`, `receiving_temperature`, `receiving_relative_humidity` | `float` | Per-room climate when source and receiving rooms are reported separately (same ranges as above) | 405 + | `test_room`, `mounting`, `measurement_standard`, `test_date` | `str` | Facility, mounting condition, the measurement standard (forms the standard-basis line) and the test date | 406 + | `laboratory`, `operator`, `report_id`, `notes` | `str` | Footer: institute, operator signature line, report number and free-form remarks | 407 + | `requirement` | `float > 0` | Target single number; adds the verdict row (airborne passes at or above it, impact at or below it) | 386 408 387 409 ### Field façade insulation (ISO 16283-3) 388 410
+1
requirements-dev.txt
··· 11 11 pystoi>=0.4.1 # test-only external cross-check for STOI/ESTOI (reproduces the authors' MATLAB); the library reimplements from the papers and never imports it at runtime 12 12 svglib>=1.5 13 13 pypdf>=4 # one-page assertion in the ISO 717 report tests 14 + pypdfium2>=4 # rasterizes the .report() PDFs to committed PNG previews (scripts/generate_reports.py); no system Poppler needed
+71 -7
scripts/generate_reports.py
··· 5 5 a small set of representative results with example metadata and writes their 6 6 ``.report()`` fiche to ``.github/reports/`` so the repository always carries an 7 7 up-to-date rendered example of every report kind, which the documentation links 8 - to. Run it with ``make reports`` or ``python scripts/generate_reports.py``. 8 + to. Alongside every ``<name>.pdf`` it rasterizes the first page to a 9 + ``<name>.png`` preview (via :mod:`pypdfium2`, no system Poppler needed) so the 10 + website and the GitHub docs can show the fiche inline without a PDF viewer or a 11 + build-time rasterizer. Run it with ``make reports`` or 12 + ``python scripts/generate_reports.py``. 9 13 10 - The PDFs are NOT byte-checked in CI: the embedded plot is vector geometry whose 11 - floating-point coordinates differ by ~1 ULP across CPUs, so a byte comparison 12 - would be as flaky as the figure pipeline was before its tolerance check (which 13 - cannot apply to a binary PDF). They are regenerated by the maintainer instead; 14 + Neither the PDFs nor the PNG previews are byte-checked in CI: the embedded plot 15 + is vector geometry whose floating-point coordinates differ by ~1 ULP across 16 + CPUs (and the raster inherits that), so a byte comparison would be as flaky as 17 + the figure pipeline was before its tolerance check (which cannot apply to a 18 + binary PDF). They are regenerated by the maintainer instead; 14 19 ``tests/test_generate_reports.py`` only asserts the generator still produces a 15 - valid one-page PDF for each example. 20 + valid one-page PDF and a valid PNG preview for each example. 16 21 """ 17 22 18 23 from __future__ import annotations ··· 30 35 _DEFAULT_DIR = os.path.normpath( 31 36 os.path.join(os.path.dirname(__file__), "..", ".github", "reports") 32 37 ) 38 + 39 + #: Pixel width of the rendered PNG preview. A4 portrait at this width is ~1415 40 + #: px tall: crisp on the docs pages (shown at ~80 % column width) yet small 41 + #: enough to commit. The height follows from the page aspect ratio. 42 + _PREVIEW_WIDTH_PX = 1000 33 43 34 44 _RATING_FREQS = np.array( 35 45 [100, 125, 160, 200, 250, 315, 400, 500, 630, 800, ··· 108 118 ] 109 119 110 120 121 + def preview_path_for(pdf_path: str) -> str: 122 + """Return the PNG-preview path that pairs with ``pdf_path`` (``.pdf`` -> ``.png``).""" 123 + return os.path.splitext(pdf_path)[0] + ".png" 124 + 125 + 126 + def _optimize_png(path: str) -> None: 127 + """Losslessly recompress ``path`` in place with ``oxipng`` when available. 128 + 129 + Mirrors :func:`scripts.generate_graphs._optimize_png`: the transform never 130 + changes a pixel, so it only shrinks the committed file; a missing optimizer 131 + degrades to a larger-but-identical PNG rather than an error. 132 + """ 133 + try: 134 + import oxipng 135 + except ImportError: 136 + print(f"[reports] pyoxipng not installed; {os.path.basename(path)} " 137 + "saved unoptimized") 138 + return 139 + # Match scripts/generate_graphs._optimize_png: level 6 is an exhaustive 140 + # filter/deflate search whose result is fixed by the input (not the 141 + # thread count), so the output is deterministic without paying for zopfli. 142 + oxipng.optimize(path, level=6, strip=oxipng.StripChunks.safe()) 143 + 144 + 145 + def _write_preview(pdf_path: str) -> str: 146 + """Rasterize the first page of ``pdf_path`` to a PNG preview beside it. 147 + 148 + The preview is what the documentation embeds inline; it is not byte-checked 149 + (the raster inherits the vector plot's ~1 ULP cross-CPU drift), so it is 150 + regenerated by the maintainer like the figures. 151 + """ 152 + import pypdfium2 as pdfium 153 + 154 + pdf = pdfium.PdfDocument(pdf_path) 155 + try: 156 + page = pdf[0] 157 + width_pt, _ = page.get_size() 158 + scale = _PREVIEW_WIDTH_PX / width_pt 159 + image = page.render(scale=scale).to_pil() 160 + png_path = preview_path_for(pdf_path) 161 + # RGB (no alpha): the fiche is opaque white; a smaller, simpler PNG. 162 + image.convert("RGB").save(png_path, format="PNG", optimize=True) 163 + finally: 164 + pdf.close() 165 + _optimize_png(png_path) 166 + return png_path 167 + 168 + 111 169 def generate_reports(output_dir: str) -> List[str]: 112 - """Write every example fiche into ``output_dir``; return the paths written.""" 170 + """Write every example fiche (PDF + PNG preview) into ``output_dir``. 171 + 172 + Returns the PDF paths written; each has a paired ``.png`` preview next to it 173 + (see :func:`preview_path_for`). 174 + """ 113 175 os.makedirs(output_dir, exist_ok=True) 114 176 written: List[str] = [] 115 177 for factory in _EXAMPLES: 116 178 result, metadata, name = factory() 117 179 path = os.path.join(output_dir, name) 118 180 result.report(path, metadata=metadata) # type: ignore[attr-defined] 181 + _write_preview(path) 119 182 written.append(path) 120 183 return written 121 184 ··· 127 190 args = parser.parse_args() 128 191 for path in generate_reports(os.path.abspath(args.output_dir)): 129 192 print(f"wrote {path}") 193 + print(f"wrote {preview_path_for(path)}") 130 194 131 195 132 196 if __name__ == "__main__":
+139
site/src/components/ReportPreview.astro
··· 1 + --- 2 + /** 3 + * Accessible inline preview of a normative `.report()` PDF fiche. 4 + * 5 + * The fiches are generated by scripts/generate_reports.py, which writes a 6 + * `<name>.pdf` and rasterizes its first page to a committed `<name>.png` 7 + * preview (both under .github/reports, served from raw.githubusercontent). 8 + * This component embeds that PNG as a clickable thumbnail that opens the PDF, 9 + * mirroring the Video convention: the linked preview carries a short accessible 10 + * name (aria-label) plus a hidden long description (aria-describedby), the img 11 + * itself is decorative (empty alt, since the link is fully labelled), and a 12 + * separate visible localized download link is always offered. An optional 13 + * caption sits below. 14 + * 15 + * The fiche is an opaque white A4 page, so — unlike the theme-aware figures — 16 + * a single PNG serves both themes; a light frame keeps it legible on the dark 17 + * ground. The PNG is NOT byte-checked in CI (its embedded vector plot differs 18 + * by ~1 ULP across CPUs); the maintainer regenerates it with `make reports`. 19 + */ 20 + interface Props { 21 + /** Fiche basename in .github/reports, e.g. "iso717_airborne_example". */ 22 + name: string; 23 + /** Short accessible name for the linked preview (the report kind). */ 24 + title: string; 25 + /** Textual alternative: what the fiche shows, for screen readers. */ 26 + description: string; 27 + /** Intrinsic pixel size of the PNG, to reserve layout space (A4 ~1000x1415). */ 28 + width?: number; 29 + /** Intrinsic pixel size of the PNG, to reserve layout space. */ 30 + height?: number; 31 + /** Authored display width, e.g. "70%". Full column on phones. */ 32 + displayWidth?: string; 33 + /** Optional visible caption below the preview. */ 34 + caption?: string; 35 + /** 36 + * Override for the generated element id (aria-describedby target). Only 37 + * needed when the same fiche is embedded more than once on a page. 38 + */ 39 + id?: string; 40 + } 41 + 42 + const REPORTS = 43 + 'https://raw.githubusercontent.com/jmrplens/phonometry/main/.github/reports'; 44 + 45 + const { 46 + name, 47 + title, 48 + description, 49 + width = 1000, 50 + height = 1415, 51 + displayWidth = '70%', 52 + caption, 53 + id, 54 + } = Astro.props; 55 + 56 + if (!title?.trim()) { 57 + throw new Error(`ReportPreview requires a non-empty title (name: ${name})`); 58 + } 59 + if (!description?.trim()) { 60 + throw new Error(`ReportPreview requires a non-empty description (name: ${name})`); 61 + } 62 + 63 + const t = Astro.locals.t; 64 + const pdf = `${REPORTS}/${name}.pdf`; 65 + const png = `${REPORTS}/${name}.png`; 66 + const descId = `${(id ?? name).replaceAll('_', '-')}-desc`; 67 + --- 68 + 69 + <figure class="report-figure"> 70 + <a 71 + class="report-link" 72 + href={pdf} 73 + aria-label={title} 74 + aria-describedby={descId} 75 + rel="noopener" 76 + style={`width:${displayWidth}`} 77 + > 78 + <img src={png} alt="" width={width} height={height} loading="lazy" /> 79 + </a> 80 + <p class="sr-only" id={descId}>{description}</p> 81 + <p class="report-download"> 82 + <a href={pdf} download rel="noopener">{t('phonometry.report.download')}</a> 83 + </p> 84 + {caption && <figcaption>{caption}</figcaption>} 85 + </figure> 86 + 87 + <style> 88 + .report-figure { 89 + margin-inline: 0; 90 + text-align: center; 91 + } 92 + /* The white A4 fiche gets a light frame so it reads on the dark theme, a 93 + subtle shadow to lift it off the page, and rounded corners. The link is 94 + the sizing element (inline width tier); the image fills it. */ 95 + .report-link { 96 + display: inline-block; 97 + max-width: 100%; 98 + border: 1px solid var(--sl-color-gray-5); 99 + border-radius: 0.5rem; 100 + background: #fff; 101 + padding: 0.5rem; 102 + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.12); 103 + line-height: 0; 104 + } 105 + .report-link:hover { 106 + border-color: var(--sl-color-accent); 107 + } 108 + .report-link:focus-visible { 109 + outline: 2px solid var(--sl-color-accent); 110 + outline-offset: 2px; 111 + } 112 + .report-link img { 113 + width: 100%; 114 + height: auto; 115 + border-radius: 0.25rem; 116 + } 117 + .report-download { 118 + font-size: var(--sl-text-sm); 119 + margin-top: 0.5rem !important; 120 + } 121 + figcaption { 122 + font-size: var(--sl-text-sm); 123 + color: var(--sl-color-gray-2); 124 + margin-top: 0.5rem; 125 + } 126 + /* Starlight has no global visually-hidden utility outside its own 127 + components, so the description carries its own (matches Video.astro). */ 128 + .sr-only { 129 + position: absolute; 130 + width: 1px; 131 + height: 1px; 132 + margin: -1px; 133 + padding: 0; 134 + overflow: hidden; 135 + clip-path: inset(50%); 136 + white-space: nowrap; 137 + border: 0; 138 + } 139 + </style>
+1
site/src/content.config.ts
··· 109 109 schema: i18nSchema({ 110 110 extend: z.object({ 111 111 'phonometry.references.title': z.string().optional(), 112 + 'phonometry.report.download': z.string().optional(), 112 113 'phonometry.video.download': z.string().optional(), 113 114 'phonometry.video.fallback': z.string().optional(), 114 115 }),
+36
site/src/content/docs/es/guides/insulation-field.mdx
··· 68 68 --- 69 69 70 70 import ThemeImage from '../../../../components/ThemeImage.astro'; 71 + import ReportPreview from '../../../../components/ReportPreview.astro'; 71 72 72 73 Esta guía continúa desde la [guía de Acústica de salas](/phonometry/es/guides/room-acoustics/): 73 74 la misma respuesta al impulso, medida a ambos lados de un cerramiento, da su ··· 453 454 55.0, 56.0, 57.0, 58.0, 55.0, 52.0, 49.0, 46.0] 454 455 building.weighted_impact_rating(l_nt).report("Lnw_ficha.pdf") # Ln,w (CI) 455 456 ``` 457 + 458 + Ambas fichas de ejemplo se regeneran con `make reports` y se conservan 459 + renderizadas en el repositorio; pulsa cualquier vista previa para abrir el PDF. 460 + 461 + <ReportPreview 462 + name="iso717_airborne_example" 463 + title="Informe de ejemplo aéreo ISO 717-1 (PDF)" 464 + description="Ficha de aislamiento a ruido aéreo de una página: una cabecera de metadatos (cliente, muestra, superficie de la probeta, volúmenes de las salas, temperatura y humedad por sala, presión ambiente y montaje), la tabla de R en tercios de octava junto a la curva medida frente a la referencia desplazada, el resultado de un solo número Rw (C; Ctr) en caja y una fila de veredicto CUMPLE frente al requisito de 30 dB." 465 + caption="Ficha de índice aéreo (WeightedRatingResult.report), Rw (C; Ctr)." 466 + /> 467 + 468 + <ReportPreview 469 + name="iso717_impact_example" 470 + title="Informe de ejemplo de impactos ISO 717-2 (PDF)" 471 + description="Ficha de aislamiento a ruido de impactos con el mismo formato acreditado para el nivel de presión de ruido de impactos normalizado Ln: la cabecera de metadatos, la tabla de Ln en tercios de octava junto a la curva medida frente a la referencia desplazada con la lectura a 500 Hz, el resultado de un solo número Ln,w (CI) en caja y una fila de veredicto NO CUMPLE frente al requisito de 53 dB (un nivel de impacto más bajo es mejor)." 472 + caption="Ficha de índice de impactos (ImpactRatingResult.report), Ln,w (CI)." 473 + /> 474 + 475 + #### Metadatos del informe (`ReportMetadata`) 476 + 477 + Todos los campos son opcionales y solo se renderizan los que se aportan, de modo 478 + que el mismo objeto genera una ficha acreditada completa y una ficha ligera de 479 + predicción. Los campos numéricos se validan al construir el objeto según su 480 + rango físico. 481 + 482 + | Campo | Tipo | Se renderiza como | 483 + | --- | --- | --- | 484 + | `specimen`, `client`, `mounted_by`, `manufacturer` | `str` | Identidad de cabecera del elemento ensayado y para quién / quién lo montó | 485 + | `area`, `mass_per_area` | `float > 0` | Superficie de la probeta $S$ (m²) y masa por unidad de superficie medida (kg/m²) | 486 + | `source_volume`, `receiving_volume` | `float > 0` | Volúmenes de las salas (m³) | 487 + | `temperature`, `relative_humidity`, `pressure` | `float` | Clima representativo único: temperatura del aire (°C, cualquier signo), humedad relativa (0–100 %), presión ambiente (kPa, > 0) | 488 + | `source_temperature`, `source_relative_humidity`, `receiving_temperature`, `receiving_relative_humidity` | `float` | Clima por sala cuando la sala emisora y la receptora se informan por separado (mismos rangos que arriba) | 489 + | `test_room`, `mounting`, `measurement_standard`, `test_date` | `str` | Instalación, condición de montaje, norma de medición (forma la línea de base normativa) y fecha de ensayo | 490 + | `laboratory`, `operator`, `report_id`, `notes` | `str` | Pie: institución, línea de firma del operador, número de informe y observaciones libres | 491 + | `requirement` | `float > 0` | Valor objetivo de un solo número; añade la fila de veredicto (el aéreo cumple si es mayor o igual, el de impactos si es menor o igual) | 456 492 457 493 ### Aislamiento a ruido aéreo de fachadas en campo (ISO 16283-3) 458 494
+35
site/src/content/docs/guides/insulation-field.mdx
··· 68 68 --- 69 69 70 70 import ThemeImage from '../../../components/ThemeImage.astro'; 71 + import ReportPreview from '../../../components/ReportPreview.astro'; 71 72 72 73 This guide continues from the [Room Acoustics guide](/phonometry/guides/room-acoustics/): 73 74 the same impulse response, measured either side of a partition, yields its sound ··· 445 446 55.0, 56.0, 57.0, 58.0, 55.0, 52.0, 49.0, 46.0] 446 447 building.weighted_impact_rating(l_nt).report("Lnw_fiche.pdf") # Ln,w (CI) 447 448 ``` 449 + 450 + Both example fiches are regenerated with `make reports` and kept rendered in the 451 + repository; click either preview to open the PDF. 452 + 453 + <ReportPreview 454 + name="iso717_airborne_example" 455 + title="Airborne ISO 717-1 example report (PDF)" 456 + description="One-page airborne sound insulation fiche: a metadata header (client, specimen, sample area, room volumes, per-room temperature and humidity, ambient pressure and mounting), the one-third-octave R table beside the measured-versus-shifted-reference plot, the boxed Rw (C; Ctr) single-number result and a PASS verdict against the 30 dB requirement." 457 + caption="Airborne rating fiche (WeightedRatingResult.report), Rw (C; Ctr)." 458 + /> 459 + 460 + <ReportPreview 461 + name="iso717_impact_example" 462 + title="Impact ISO 717-2 example report (PDF)" 463 + description="One-page impact sound insulation fiche in the same accredited layout for the normalized impact sound pressure level Ln: the metadata header, the one-third-octave Ln table beside the measured-versus-shifted-reference plot with the 500 Hz read-off, the boxed Ln,w (CI) single-number result and a FAIL verdict against the 53 dB requirement (a lower impact level is better)." 464 + caption="Impact rating fiche (ImpactRatingResult.report), Ln,w (CI)." 465 + /> 466 + 467 + #### Report metadata (`ReportMetadata`) 468 + 469 + Every field is optional and only the supplied ones are rendered, so the same 470 + object drives a full accredited fiche and a lightweight prediction fiche. The 471 + numeric fields are validated on construction by physical range. 472 + 473 + | Field | Type | Rendered as | 474 + | --- | --- | --- | 475 + | `specimen`, `client`, `mounted_by`, `manufacturer` | `str` | Header identity of the tested element and who it was tested for / mounted by | 476 + | `area`, `mass_per_area` | `float > 0` | Sample area $S$ (m²) and measured mass per unit area (kg/m²) | 477 + | `source_volume`, `receiving_volume` | `float > 0` | Room volumes (m³) | 478 + | `temperature`, `relative_humidity`, `pressure` | `float` | Single representative climate: air temperature (°C, any sign), relative humidity (0–100 %), ambient pressure (kPa, > 0) | 479 + | `source_temperature`, `source_relative_humidity`, `receiving_temperature`, `receiving_relative_humidity` | `float` | Per-room climate when source and receiving rooms are reported separately (same ranges as above) | 480 + | `test_room`, `mounting`, `measurement_standard`, `test_date` | `str` | Facility, mounting condition, the measurement standard (forms the standard-basis line) and the test date | 481 + | `laboratory`, `operator`, `report_id`, `notes` | `str` | Footer: institute, operator signature line, report number and free-form remarks | 482 + | `requirement` | `float > 0` | Target single number; adds the verdict row (airborne passes at or above it, impact at or below it) | 448 483 449 484 ### Field façade insulation (ISO 16283-3) 450 485
+1
site/src/content/i18n/en.json
··· 1 1 { 2 2 "phonometry.references.title": "References", 3 + "phonometry.report.download": "Download the report (PDF)", 3 4 "phonometry.video.download": "Download the animation (WebM)", 4 5 "phonometry.video.fallback": "Your browser cannot play embedded video. Download the animation instead:" 5 6 }
+1
site/src/content/i18n/es.json
··· 1 1 { 2 2 "phonometry.references.title": "Referencias", 3 + "phonometry.report.download": "Descargar el informe (PDF)", 3 4 "phonometry.video.download": "Descargar la animación (WebM)", 4 5 "phonometry.video.fallback": "Tu navegador no puede reproducir vídeo incrustado. Descarga la animación:" 5 6 }
+1
site/src/translations.d.ts
··· 4 4 declare namespace StarlightApp { 5 5 interface I18n { 6 6 'phonometry.references.title': string; 7 + 'phonometry.report.download': string; 7 8 'phonometry.video.download': string; 8 9 'phonometry.video.fallback': string; 9 10 }
+20 -4
tests/test_generate_reports.py
··· 1 1 # Copyright (c) 2026. Jose M. Requena-Plens 2 2 """The example-report generator keeps producing valid one-page fiches. 3 3 4 - The rendered PDFs under ``.github/reports/`` are not byte-checked (their vector 5 - plot differs by ~1 ULP across CPUs); this test only guards that 6 - ``scripts/generate_reports.py`` still runs and writes a valid single-page PDF 7 - for every registered example. 4 + The rendered PDFs (and their PNG previews) under ``.github/reports/`` are not 5 + byte-checked (their vector plot differs by ~1 ULP across CPUs, and the raster 6 + inherits it); this test only guards that ``scripts/generate_reports.py`` still 7 + runs and writes a valid single-page PDF plus a valid PNG preview for every 8 + registered example. 8 9 """ 9 10 10 11 from __future__ import annotations ··· 17 18 pytest.importorskip("reportlab") 18 19 pytest.importorskip("svglib") 19 20 pytest.importorskip("pypdf") 21 + pytest.importorskip("pypdfium2") 20 22 21 23 _SCRIPT = ( 22 24 pathlib.Path(__file__).resolve().parents[1] / "scripts" / "generate_reports.py" ··· 31 33 return module 32 34 33 35 36 + _PNG_MAGIC = b"\x89PNG\r\n\x1a\n" 37 + 38 + 34 39 def test_generate_reports_writes_one_page_pdfs(tmp_path) -> None: 35 40 from pypdf import PdfReader 36 41 ··· 43 48 with open(p, "rb") as handle: 44 49 assert handle.read(4) == b"%PDF" 45 50 assert len(PdfReader(str(p)).pages) == 1 51 + 52 + 53 + def test_generate_reports_writes_png_previews(tmp_path) -> None: 54 + module = _load_generator() 55 + written = module.generate_reports(str(tmp_path)) 56 + for path in written: 57 + preview = pathlib.Path(module.preview_path_for(path)) 58 + assert preview.suffix == ".png" 59 + assert preview.is_file() and preview.stat().st_size > 0 60 + with open(preview, "rb") as handle: 61 + assert handle.read(8) == _PNG_MAGIC