diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index e8cd5dc..23b21de 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -515,6 +515,33 @@ jobs: output/streamlit-verification/*.html output/streamlit-startup-controls/ retention-days: 14 + pandas-components: + name: pandas component report (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: | + python -m pip install --only-binary=:all: -r examples/pandas-components/requirements-check.txt + - name: Rebuild the source kit and inspect its actual saved PDFs + shell: bash + run: | + python tools/build_pandas_components.py + git diff --exit-code -- docs/assets/pandas-components/project.zip docs/assets/pandas-components/source.json + python tools/verify_pandas_components.py --out target/pandas-components + - uses: actions/upload-artifact@v4 + if: always() + with: + name: pandas-component-report-${{ matrix.os }} + path: target/pandas-components/ + retention-days: 14 matplotlib: name: Matplotlib report (${{ matrix.os }}) strategy: @@ -943,6 +970,14 @@ jobs: - name: Materialize the verified published-wheel CSS evidence run: python tools/prepare_css_corpus_site.py site - run: python tools/check_site.py site + - name: Verify pandas component downloads and both standalone HTML exports + run: python tools/check_pandas_components_browser.py --site site --out output/pandas-components-browser + - uses: actions/upload-artifact@v4 + if: always() + with: + name: pandas-components-browser-verification + path: output/pandas-components-browser/ + retention-days: 14 - name: Verify the experimental guide and its visible downloads run: python tools/check_component_preview_ui.py --site site --out output/component-preview-ui --browser chrome - uses: actions/upload-artifact@v4 @@ -1031,7 +1066,7 @@ jobs: path: site deploy: if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - needs: [build, accessible-showcase, showcase-authoring, component-notebook, page-band-notebook, complete-html, component-preview, bootstrap-preview, components, node-components, dotnet-components, aspnet, chinese, arabic, markdown, tagged-notice, rust-download, matplotlib, numbered-report, axum, lambda-starter, n8n-workflow, laravel-queue, laravel-platform, streamlit] + needs: [build, accessible-showcase, showcase-authoring, component-notebook, page-band-notebook, complete-html, component-preview, bootstrap-preview, components, node-components, dotnet-components, aspnet, chinese, arabic, markdown, tagged-notice, rust-download, matplotlib, pandas-components, numbered-report, axum, lambda-starter, n8n-workflow, laravel-queue, laravel-platform, streamlit] runs-on: ubuntu-latest permissions: pages: write diff --git a/docs/assets/pandas-components/a11y-source.json b/docs/assets/pandas-components/a11y-source.json new file mode 100644 index 0000000..180c135 --- /dev/null +++ b/docs/assets/pandas-components/a11y-source.json @@ -0,0 +1,9 @@ +{ + "diagnostics": [], + "error_count": 0, + "errors": [], + "mode": "raise", + "ok": true, + "warning_count": 0, + "warnings": [] +} diff --git a/docs/assets/pandas-components/html-exports.zip b/docs/assets/pandas-components/html-exports.zip new file mode 100644 index 0000000..dc103f9 Binary files /dev/null and b/docs/assets/pandas-components/html-exports.zip differ diff --git a/docs/assets/pandas-components/page-1.png b/docs/assets/pandas-components/page-1.png new file mode 100644 index 0000000..e66c54c Binary files /dev/null and b/docs/assets/pandas-components/page-1.png differ diff --git a/docs/assets/pandas-components/page-2.png b/docs/assets/pandas-components/page-2.png new file mode 100644 index 0000000..a42dc93 Binary files /dev/null and b/docs/assets/pandas-components/page-2.png differ diff --git a/docs/assets/pandas-components/page-3.png b/docs/assets/pandas-components/page-3.png new file mode 100644 index 0000000..bbdba05 Binary files /dev/null and b/docs/assets/pandas-components/page-3.png differ diff --git a/docs/assets/pandas-components/project.zip b/docs/assets/pandas-components/project.zip new file mode 100644 index 0000000..c3dcb8f Binary files /dev/null and b/docs/assets/pandas-components/project.zip differ diff --git a/docs/assets/pandas-components/render-report.json b/docs/assets/pandas-components/render-report.json new file mode 100644 index 0000000..5142673 --- /dev/null +++ b/docs/assets/pandas-components/render-report.json @@ -0,0 +1,18 @@ +{ + "schema": "fullbleed.pandas_components_example.v1", + "fullbleed": "2.6.0a9", + "pandas": "3.0.6", + "rows": 40, + "totals": { + "products": 40, + "units": 6040, + "cents": 15023625, + "categories": 4 + }, + "page_count": 3, + "pdf_sha256": "426ac79dde41026a9640acf7e93bc9229c67b2070fe3bc221ba26f66153953a8", + "font_sha256": "29160a80ff49ddcab2c97711247e08b1fab27a484a329ce8b813d820dc559031", + "profile": "tagged", + "timestamp": "2026-10-20T00:00:00Z", + "validation_scope": "Source structural checks and native layout checks; no PDF/UA conformance claim." +} diff --git a/docs/assets/pandas-components/report.pdf b/docs/assets/pandas-components/report.pdf new file mode 100644 index 0000000..46a230b Binary files /dev/null and b/docs/assets/pandas-components/report.pdf differ diff --git a/docs/assets/pandas-components/source.json b/docs/assets/pandas-components/source.json new file mode 100644 index 0000000..b0dacfa --- /dev/null +++ b/docs/assets/pandas-components/source.json @@ -0,0 +1,19 @@ +{ + "schema": "fullbleed.pandas_components_source.v1", + "fullbleed": "2.6.0a9", + "pandas": "3.0.6", + "zip_sha256": "d467cbf65b30ca2c62c63c063a5b0a41cde557ead5fa8ad34d0b269076c8ec1b", + "zip_bytes": 30386, + "files": { + "Inter-OFL.txt": "5b9321a4298cfeb6b34354164a1c3afc3db114569984c502b9b35d988fd58c57", + "LICENSE": "36a2115938f6e68cf0d87eccff7a6a3e57c17017e909cbd640d51aff1fa0c5a9", + "README.md": "63318aad3c51a6c83138909aff626190d0d984cec698b6888aa9b062b1bb3200", + "components.py": "af4f29fa8c111780f49f884dd389e03d8fc482aae1276c520484780dc1f69fa0", + "data.py": "000d71075dd56cd8f87056d3c996edc4702df0987266123f12bedd4c96aaed84", + "products.csv": "559901ddf863989e1498ac8a7fa725f2f77c7547232781c58b0e84b737b10265", + "report.py": "3a9cf616caa6dc8fd0f9894be00af1ce8c32cd795c3d281a82e0e82597ba7a73", + "requirements-check.txt": "4eadc7023aba8e289cb2e9b4f46eed8c350d9c55500f98c19d938010d46add8d", + "requirements.txt": "500618199fa41ef2d783c668ff566ef99af7273246a63fe15d73d55c134cc84c", + "verify.py": "f0cfa1d19a871b8604d178be66fe314d24af6831031ccdb63a3752cc2f138073" + } +} diff --git a/docs/assets/pandas-components/verification.json b/docs/assets/pandas-components/verification.json new file mode 100644 index 0000000..4f79ad1 --- /dev/null +++ b/docs/assets/pandas-components/verification.json @@ -0,0 +1,39 @@ +{ + "ok": true, + "cases": { + "sample": { + "pages": 3, + "sha256": "426ac79dde41026a9640acf7e93bc9229c67b2070fe3bc221ba26f66153953a8", + "rows": 40 + }, + "one": { + "pages": 1, + "sha256": "e428990c8874e3951a799060cb3ec33ff176f30358107929e10d88663deda826", + "rows": 1 + }, + "mixed": { + "pages": 1, + "sha256": "01dd255a3909aa03e4feec2b14f4f6daa4dd69996241a0038c54609e7fb164b1", + "rows": 3 + }, + "many": { + "pages": 7, + "sha256": "da4094a1c8d64abbb305ba4fe3e6d936f1c044d3f9b9213a57a5aaa4429c6d34", + "rows": 120 + } + }, + "invalid_inputs_rejected": [ + "empty", + "missing-column", + "duplicate", + "blank-product", + "missing-units", + "negative-units", + "fractional-cents", + "bad-date" + ], + "fresh_process_pdf_identical": true, + "input_frames_unchanged": true, + "html_styled_and_unstyled_checked": true, + "scope": "Saved-PDF text, row order, repeated headings, page totals, page bounds, independent rasterization; no PDF/UA or browser-pagination claim." +} diff --git a/docs/examples.md b/docs/examples.md index 8cd78b7..0c6a455 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -250,6 +250,11 @@ changing content, fonts, or row counts. ## Gridline product report from pandas +For a component-authored design using the same data, try the +[pandas component report](guides/pandas-components.md): three pages, reusable +metrics, finalized page totals, and styled or unstyled HTML export, pinned to +Fullbleed **2.6.0a9**. +
[![Gridline product report with summary metrics, orange SVG category bars, and a striped product ledger.](assets/pandas-report/report-1.png)](assets/pandas-report/report.pdf) diff --git a/docs/guides/pandas-components.md b/docs/guides/pandas-components.md new file mode 100644 index 0000000..7c60451 --- /dev/null +++ b/docs/guides/pandas-components.md @@ -0,0 +1,210 @@ +--- +title: Build pandas PDF reports with Python components +description: Turn a DataFrame into a paginated PDF with reusable Python components, typed styles, repeated table headers and page totals. Download the runnable example and both HTML export modes. +--- + +# pandas reports with Python components + +Keep the data preparation in pandas and build the report from reusable Python +functions. This example turns the same fictional Gridline CSV used in the +[HTML/CSS pandas guide](pandas-to-pdf.md) into a new three-page design: summary +cards, a striped product ledger, repeated headers, and finalized page totals. + +!!! warning "Pinned experimental API" + This project uses the published **Fullbleed 2.6.0a9** wheel and **pandas 3.0.6**. + The component API is experimental. Keep the exact pin while evaluating it; + the [HTML/CSS example](pandas-to-pdf.md) remains a separate stable-release path. + +[Download the PDF](../assets/pandas-components/report.pdf){ .md-button .md-button--primary download="gridline-components.pdf" } +[Get the runnable source](../assets/pandas-components/project.zip){ .md-button download="fullbleed-pandas-components.zip" } +[Download both HTML exports](../assets/pandas-components/html-exports.zip){ .md-button download="fullbleed-pandas-html.zip" } + +[![Gridline report with a forest-green title, three summary cards, an orange net-sales figure, and a striped product table.](../assets/pandas-components/page-1.png)](../assets/pandas-components/report.pdf) + +[Preview page 2](../assets/pandas-components/page-2.png) · +[Preview page 3](../assets/pandas-components/page-3.png) · +[Inspect the verification record](../assets/pandas-components/verification.json) · +[Source checksums](../assets/pandas-components/source.json) + +The previews come from the actual PDF. All data and the Gridline organization are +fictional. This is a component-authored design using the earlier example's data; +the two designs do not claim pixel parity. + +## Run the complete example + +Extract the source ZIP and open its `fullbleed-pandas-components` directory. Use +Python 3.11 or later: + +```sh +python -m venv .venv +# Windows: .venv\Scripts\activate +# macOS/Linux: source .venv/bin/activate +python -m pip install --only-binary=:all: -r requirements.txt +python report.py --previews +``` + +Open `output/report.pdf`. The command also writes styled HTML, unstyled HTML, +source structural diagnostics, pagination evidence, and optional PNG previews. +The published wheel includes the font and native renderer; PDF generation needs +no browser, system font, or remote asset. + +```sh +python report.py --csv products.csv --title "The autumn edit." --period "Autumn 2026" --out output/autumn +``` + +## Bridge a DataFrame to a table + +`data.py` validates the CSV and preserves its row order. `report.py` calls: + +```python +Ledger(frame.to_dict(orient="records")) +``` + +Each `Column` callback receives one record. It can return text or another +component. Here is the core of the table in `components.py`: + +```python +from fullbleed.ui.experimental import Column, DataTable, Strong, mm +from data import dollars, restock + +def Ledger(records): + return DataTable( + records, + caption="Product ledger · all values in USD", + columns=( + Column("SKU", lambda row: row["sku"], width=mm(23)), + Column("Product", lambda row: Strong(row["product"])), + Column("Category", lambda row: row["category"], width=mm(23)), + Column("Units", lambda row: f'{row["units"]:,}', align="right", width=mm(19)), + Column("Net sales", lambda row: dollars(row["net_sales_cents"]), align="right", width=mm(30)), + Column("Last restock", lambda row: restock(row["last_restock"]), width=mm(30)), + ), + class_name="ledger", + ) +``` + +The complete file adds header, caption, and cell styles with `.with_parts()`. +The table supplies semantic column headers and repeats them across PDF pages. +`Flow` lets the document paginate; short metric cards use `Stack` internally. + +## Give reusable components a styling API + +The metric is a Python function. Its two named parts let callers style the value +or label without reaching through its implementation: + +```python +from fullbleed.ui.experimental import ( + Box, Paragraph, Stack, component, pt, style, token, +) +from components import Small + +@component(parts=("value", "label")) +def Metric(value, label): + return Box( + Stack( + Paragraph(value, part="value", style=style( + font_size=pt(26), font_weight=700, line_height=1.15, + )), + Small(label, part="label"), + gap=pt(5), + ), + style=style(background=token("paper"), padding=pt(12)), + ) + +sales = Metric("$150,236.25", "net sales · USD").with_parts( + value=style(color=token("accent")), +) +``` + +`Theme` defines the palette and shared typography. `styled(Paragraph, ...)` +creates the reusable `Small` and `Kicker` factories. Styles are immutable values; +an override produces a new component value. + +### What does `class_name` do? + +`class_name="ledger"` attaches the class to the table. It applies no declarations +by itself. The document supplies the selector and its styles: + +```python +Document( + ..., # report components + styles={ + ".ledger tbody tr:nth-child(even)": style(background="#f5f7f2"), + ".folio": style(font_size=pt(8), color=token("muted")), + }, +) +``` + +Use a component's `style=` or `.with_style()` for one instance, `styled()` for a +reusable factory, and `.with_parts()` for its declared public parts. Use +`class_name` plus document selectors when a rule needs a selector, such as the +alternating rows above. + +## Repeat the brand and count the final pages + +The document owns its page geometry. Its header and footer contain the same +Python component values used in the body: + +```python +Page( + "A4", landscape=True, + margin=(mm(20), mm(16), mm(19), mm(16)), + header=PageBand(left=Brand(), right=Small(period)), + footer=PageBand( + left=Small("GRIDLINE / FICTIONAL DATA"), + right=PageNumbers(total=True, prefix="", separator=" / ", class_name="folio"), + ), +) +``` + +`PageNumbers` uses the finalized page count. The 40-row sample produces `1 / 3` +through `3 / 3`; the 120-row check produces seven pages. Review your own content +after changing widths, fonts, data, or page size. An oversized row or page band +can raise a structured layout error. + +## Export HTML with or without styles + +The same document can be used without PDF rendering: + +```python +document = build_report(frame) +styled_html = document.to_html(include_styles=True, a11y_mode="raise") +plain_html = document.to_html(include_styles=False, a11y_mode="raise") +``` + +`True` embeds the collected component CSS. `False` removes component CSS and +inline styles while retaining authored classes and semantic elements. The example's +export function also adds font loading and screen framing to the styled export; +keep its `fonts/` directory beside the HTML. These are static browser documents, +with a single header and footer. Browser pagination does not reproduce PDF pages. + +## Keep presentation and data rules separate + +The CSV contract is explicit: unique nonblank SKU, nonblank product and category, +nonnegative integer units, signed integer cents, and an ISO restock date or a +missing value. Additional columns are ignored. `Decimal` formats currency; +Python integers sum the amounts. Missing dates display “Not recorded”. Data +containing markup remains literal text, and building the report leaves the frame +unchanged. Normalize an existing DataFrame to this contract before authoring it. + +## Reproduce the checks + +```sh +python -m pip install --only-binary=:all: -r requirements-check.txt +python verify.py --out output/verification +``` + +The checks save actual PDFs for 40 rows, one row, mixed long/Unicode/literal-markup +values, and 120 rows. Independent readers check row order and completeness, +repeated headings, page totals, and glyph bounds, and rasterize every page. Eight +invalid input cases are rejected. A fresh Python process reproduces the sample's +exact PDF bytes using the fixed timestamp and bundled font. Both HTML modes and +unchanged input frames are also checked. + +The PDF uses the tagged profile and passes source structural checks. These checks +do not establish PDF/UA conformance or assistive-technology usability. For that +separate workflow, see the [accessible component showcase](accessible-showcase.md). + +Continue with the [current component preview](component-preview-9.md), +[reusable page-band notebook](../getting-started/page-band-notebook.md), or +[HTML/CSS pandas report](pandas-to-pdf.md). diff --git a/docs/guides/pandas-to-pdf.md b/docs/guides/pandas-to-pdf.md index ee5ecd7..4d0c7a6 100644 --- a/docs/guides/pandas-to-pdf.md +++ b/docs/guides/pandas-to-pdf.md @@ -16,6 +16,11 @@ The organization and data are fictional. For an interactive data app, try the [Streamlit report starter](streamlit-pdf.md) with CSV input, date/group filters and a prepared PDF download. +Prefer Python component authoring? The [component report starter](pandas-components.md) +uses this same CSV with reusable metrics, table columns, typed styles, repeated +page bands, and both HTML export modes. It is separately pinned to **2.6.0a9**, +the experimental component API. + [Download the two-page PDF](../assets/pandas-report/report.pdf){ .md-button .md-button--primary } [Get the complete source](../assets/pandas-report/source.zip){ .md-button } diff --git a/examples/pandas-components/Inter-OFL.txt b/examples/pandas-components/Inter-OFL.txt new file mode 100644 index 0000000..21f6aff --- /dev/null +++ b/examples/pandas-components/Inter-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter) + +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. diff --git a/examples/pandas-components/LICENSE b/examples/pandas-components/LICENSE new file mode 100644 index 0000000..4c99bbd --- /dev/null +++ b/examples/pandas-components/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Fullbleed Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/pandas-components/README.md b/examples/pandas-components/README.md new file mode 100644 index 0000000..8a15ace --- /dev/null +++ b/examples/pandas-components/README.md @@ -0,0 +1,81 @@ +# pandas reports with Python components + +A runnable, MIT-licensed example for **Fullbleed 2.6.0a9** and **pandas 3.0.6**. +The component API is experimental; keep the exact version pin while evaluating it. +Python 3.11 or later is required by this pandas version. No browser, system font, +network asset, or Rust build is needed to generate the PDF from the published wheel. + +```sh +python -m venv .venv +# Windows: .venv\Scripts\activate +# macOS/Linux: source .venv/bin/activate +python -m pip install --only-binary=:all: -r requirements.txt +python report.py --previews +python report.py --csv products.csv --title "The autumn edit." --period "Autumn 2026" --out output/autumn +``` + +Open `output/report.pdf`. The supplied fictional 40-row CSV renders as three +landscape A4 pages, with a repeated table header and finalized page totals. +`output/report.html` and `output/report-unstyled.html` demonstrate both HTML +export modes. Keep the `output/fonts/` folder beside the styled HTML. The browser +export is one continuous static document; it does not reproduce PDF pagination. +`--previews` also writes PNG previews of the finalized PDF. + +## Make it yours + +- Change the palette and shared typography in `components.py` (`THEME`). +- Reuse `Metric`, `Kicker`, and `Small` across documents. `styled()` makes a new + component factory; it does not change the original one. +- Override one metric's value with `.with_parts(value=style(color=token("accent")))`. + This public part is declared with `@component(parts=("value", "label"))` and + attached to the appropriate child using `part="value"`. +- A `class_name="ledger"` attaches a CSS class. Its declarations live in + `Document(styles={".ledger …": style(...)})`; a class name alone adds no styles. +- Change the `Column` callbacks in `Ledger` to format each record or return a + component such as `Strong(...)`. `frame.to_dict(orient="records")` is the bridge + between pandas and the table. The report does not modify the input frame. +- Use `document.to_html(include_styles=True)` to embed component CSS, or `False` + to omit CSS and inline styles while retaining authored classes and semantics. + Font loading and screen framing are added separately in `render()`. + +## Input contract + +`data.load_products()` validates these columns before authoring: + +| Column | Contract | +| --- | --- | +| `sku` | Unique, nonblank text | +| `product`, `category` | Nonblank text; markup is treated as text | +| `units` | Nonnegative integer | +| `net_sales_cents` | Signed integer cents; formatted with `Decimal`, never floating point | +| `last_restock` | `YYYY-MM-DD`, or missing (shown as “Not recorded”) | + +Extra columns are ignored. An empty CSV, duplicates, invalid dates, and missing +required values are rejected. Row order is preserved. Negative net sales represent +adjustments. All products, sales, and dates are fictional. For an existing +DataFrame, normalize it to this contract before calling `build_report(frame)`. + +`Flow` allows the report to paginate. `Stack` is used only inside small metrics. +Rows stay together when they fit; content too large for the page can raise a +structured layout error. Adjust columns, type size, or the document design for +your own unusually long labels and large figures. + +## Verification + +```sh +python -m pip install --only-binary=:all: -r requirements-check.txt +python verify.py --out output/verification +``` + +This saves and independently reads/rasterizes the sample, a one-row report, mixed +long/Unicode/literal-markup/negative/missing-date values, and 120 rows. It checks +row order and completeness, repeated headers, page totals, glyph page bounds, +both HTML modes, input immutability, invalid input rejection, and exact PDF bytes +from a fresh Python process. The tagged profile and source checks do **not** +establish PDF/UA conformance or assistive-technology usability. + +The fixed document timestamp makes the example reproducible; use the actual report +timestamp in a production application and keep it fixed when replaying a report. +The bundled Inter font is supplied by the installed Fullbleed wheel. Its SIL OFL +license is copied beside the exported font. The CSV is shared with the earlier +Fullbleed pandas HTML/CSS example, which remains available separately. diff --git a/examples/pandas-components/components.py b/examples/pandas-components/components.py new file mode 100644 index 0000000..b7a33c5 --- /dev/null +++ b/examples/pandas-components/components.py @@ -0,0 +1,52 @@ +# SPDX-License-Identifier: MIT +"""Reusable document components and a small theme, with no HTML templates.""" +from fullbleed.ui.experimental import ( + Box, Column, DataTable, Paragraph, Stack, Strong, Theme, + component, mm, pt, style, styled, token, +) +from data import dollars, restock + +THEME = Theme( + {"ink": "#173e35", "muted": "#52665d", "paper": "#edf2e9", "accent": "#a13e1d"}, + text=style(font_family="Inter", font_size=pt(9), line_height=1.4, color=token("ink")), +) +Kicker = styled(Paragraph, font_size=pt(7), font_weight=700, + letter_spacing=pt(1), text_transform="uppercase", color=token("accent")) +Small = styled(Paragraph, font_size=pt(7.5), color=token("muted")) + + +@component(parts=("value", "label")) +def Metric(value, label): + return Box( + Stack( + Paragraph(value, part="value", style=style(font_size=pt(26), font_weight=700, line_height=1.15)), + Small(label, part="label"), gap=pt(5), + ), + style=style(background=token("paper"), padding=pt(12)), + ) + + +@component +def Brand(): + return Paragraph("GRIDLINE / OPERATIONS", style=style( + font_size=pt(8), font_weight=700, letter_spacing=pt(1), color=token("ink"))) + + +def Ledger(records): + return DataTable( + records, + caption="Product ledger · all values in USD", + columns=( + Column("SKU", lambda row: row["sku"], width=mm(23)), + Column("Product", lambda row: Strong(row["product"])), + Column("Category", lambda row: row["category"], width=mm(23)), + Column("Units", lambda row: f'{row["units"]:,}', align="right", width=mm(19)), + Column("Net sales", lambda row: dollars(row["net_sales_cents"]), align="right", width=mm(30)), + Column("Last restock", lambda row: restock(row["last_restock"]), width=mm(30)), + ), + class_name="ledger", + ).with_parts( + caption=style(text_align="left", font_size=pt(10), font_weight=700, margin_bottom=pt(9)), + header=style(background=token("ink"), color="#ffffff", font_size=pt(7.5), padding=pt(7)), + cell=style(font_size=pt(8), padding=(pt(6), pt(7)), border_bottom="0.5pt solid #d9e2d7"), + ) diff --git a/examples/pandas-components/data.py b/examples/pandas-components/data.py new file mode 100644 index 0000000..3c806ee --- /dev/null +++ b/examples/pandas-components/data.py @@ -0,0 +1,49 @@ +# SPDX-License-Identifier: MIT +"""The example's CSV contract; pandas is an optional application dependency.""" +from decimal import Decimal +from pathlib import Path + +import pandas as pd + +COLUMNS = ("sku", "product", "category", "units", "net_sales_cents", "last_restock") + + +def load_products(path: str | Path) -> pd.DataFrame: + frame = pd.read_csv(path, dtype={ + "sku": "string", "product": "string", "category": "string", + "units": "Int64", "net_sales_cents": "Int64", "last_restock": "string", + }) + missing = set(COLUMNS) - set(frame.columns) + if missing: + raise ValueError(f"Missing columns: {', '.join(sorted(missing))}") + frame = frame.loc[:, list(COLUMNS)].copy() + if frame.empty: + raise ValueError("The CSV must contain at least one product") + if frame[list(COLUMNS[:-1])].isna().any().any(): + raise ValueError("Only last_restock may be missing") + for column in ("sku", "product", "category"): + frame[column] = frame[column].str.strip() + if frame[column].eq("").any(): + raise ValueError(f"{column} must not be blank") + if frame["sku"].duplicated().any(): + raise ValueError("Each SKU must be unique") + if frame["units"].lt(0).any(): + raise ValueError("units must be nonnegative") + frame["last_restock"] = pd.to_datetime(frame["last_restock"], format="%Y-%m-%d", errors="raise") + return frame + + +def dollars(cents: int) -> str: + amount = Decimal(int(cents)) / 100 + return f"-${abs(amount):,.2f}" if amount < 0 else f"${amount:,.2f}" + + +def restock(value) -> str: + return "Not recorded" if pd.isna(value) else value.strftime("%d %b %Y") + + +def totals(frame: pd.DataFrame) -> dict: + # Python ints avoid silent int64 overflow when summing pandas columns. + return {"products": len(frame), "units": sum(map(int, frame["units"])), + "cents": sum(map(int, frame["net_sales_cents"])), + "categories": frame["category"].nunique()} diff --git a/examples/pandas-components/products.csv b/examples/pandas-components/products.csv new file mode 100644 index 0000000..50ef6cf --- /dev/null +++ b/examples/pandas-components/products.csv @@ -0,0 +1,41 @@ +sku,product,category,units,net_sales_cents,last_restock +GL-001,Atlas notebook / Original,Desk,64,80000,2026-09-01 +GL-002,Spoke travel cup / Original,Travel,101,169175,2026-09-08 +GL-003,Cedar organizer / Original,Desk,138,289800,2026-09-15 +GL-004,Canvas tool roll / Original,Studio,175,441875,2026-09-22 +GL-005,Stoneware vessel / Original,Home,212,625400,2026-09-01 +GL-006,Pen & pencil set / Original,Desk,69,232875,2026-09-08 +GL-007,Trail packing cube / Original,Travel,106,402800,2026-09-15 +GL-008,Linen sketchbook / Original,Studio,143,178750,2026-09-22 +GL-009,Ridge catchall / Original,Home,180,301500, +GL-010,München desk mat / Original,Desk,217,455700,2026-09-08 +GL-011,Atlas notebook / Slate,Desk,74,186850,2026-09-15 +GL-012,Spoke travel cup / Slate,Travel,111,327450,2026-09-22 +GL-013,Cedar organizer / Slate,Desk,148,499500,2026-09-01 +GL-014,Canvas tool roll / Slate,Studio,185,703000,2026-09-08 +GL-015,Stoneware vessel / Slate,Home,222,277500,2026-09-15 +GL-016,Pen & pencil set / Slate,Desk,79,132325,2026-09-22 +GL-017,Trail packing cube / Slate,Travel,116,243600,2026-09-01 +GL-018,Linen sketchbook / Slate,Studio,153,386325,2026-09-08 +GL-019,Ridge catchall / Slate,Home,190,560500,2026-09-15 +GL-020,München desk mat / Slate,Desk,227,766125,2026-09-22 +GL-021,Atlas notebook / Clay,Desk,84,319200,2026-09-01 +GL-022,Spoke travel cup / Clay,Travel,121,151250,2026-09-08 +GL-023,Cedar organizer / Clay,Desk,158,264650,2026-09-15 +GL-024,Canvas tool roll / Clay,Studio,195,409500,2026-09-22 +GL-025,Stoneware vessel / Clay,Home,232,585800,2026-09-01 +GL-026,Pen & pencil set / Clay,Desk,89,262550, +GL-027,Trail packing cube / Clay,Travel,126,425250,2026-09-15 +GL-028,Linen sketchbook / Clay,Studio,163,619400,2026-09-22 +GL-029,Ridge catchall / Clay,Home,200,250000,2026-09-01 +GL-030,München desk mat / Clay,Desk,237,396975,2026-09-08 +GL-031,Atlas notebook / Moss,Desk,94,197400,2026-09-15 +GL-032,Spoke travel cup / Moss,Travel,131,330775,2026-09-22 +GL-033,Cedar organizer / Moss,Desk,168,495600,2026-09-01 +GL-034,Canvas tool roll / Moss,Studio,205,691875,2026-09-08 +GL-035,Stoneware vessel / Moss,Home,242,919600,2026-09-15 +GL-036,Pen & pencil set / Moss,Desk,99,123750,2026-09-22 +GL-037,Trail packing cube / Moss,Travel,136,227800,2026-09-01 +GL-038,Linen sketchbook / Moss,Studio,173,363300,2026-09-08 +GL-039,Ridge catchall / Moss,Home,210,530250,2026-09-15 +GL-040,München desk mat / Moss,Desk,67,197650,2026-09-22 diff --git a/examples/pandas-components/report.py b/examples/pandas-components/report.py new file mode 100644 index 0000000..5e982cb --- /dev/null +++ b/examples/pandas-components/report.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT +"""Render a pandas report with the experimental Fullbleed 2.6.0a9 components.""" +from __future__ import annotations + +import argparse +from hashlib import sha256 +from importlib import metadata, resources +import json +from pathlib import Path +import shutil + +import fullbleed +from fullbleed.ui.experimental import ( + Document, Flow, Grid, Heading, Page, PageBand, PageNumbers, + Paragraph, Renderer, Strong, mm, pt, style, token, +) +from components import Brand, Kicker, Ledger, Metric, Small, THEME +from data import dollars, load_products, totals + + +def build_report(frame, *, title="The product edit.", period="September 2026"): + """Convert a validated frame to a frozen document without mutating the frame.""" + summary = totals(frame) + return Document( + Flow( + Kicker(f"Monthly inventory / {period}"), + Heading(title, level=1, style=style(font_size=pt(34), line_height=1.08, letter_spacing=pt(-1))), + Paragraph("A clear view of the collection: product activity, net sales, and the latest restock dates."), + Grid( + Metric(f'{summary["products"]:,}', "products in the collection"), + Metric(f'{summary["units"]:,}', "units sold"), + Metric(dollars(summary["cents"]), "net sales · USD").with_parts(value=style(color=token("accent"))), + columns=3, gap=pt(10), + ), + Small(f'{summary["categories"]} categories · Input order is preserved · Fictional sample data'), + Ledger(frame.to_dict(orient="records")), + Small(Strong("Reading this report. "), "Net sales may include signed adjustments. Blank restock dates appear as “Not recorded”. Figures describe this input file, not a live inventory system."), + gap=pt(12), + ), + title=f"Gridline — {title} — {period}", lang="en-US", theme=THEME, + page=Page("A4", landscape=True, margin=(mm(20), mm(16), mm(19), mm(16)), + header=PageBand(left=Brand(), right=Small(period)), + footer=PageBand(left=Small("GRIDLINE / FICTIONAL DATA"), + right=PageNumbers(total=True, prefix="", separator=" / ", class_name="folio"))), + styles={ + ".ledger tbody tr:nth-child(even)": style(background="#f5f7f2"), + ".folio": style(font_size=pt(8), color=token("muted")), + }, + ) + + +def render(frame, out: Path, *, title="The product edit.", period="September 2026", previews=False): + out.mkdir(parents=True, exist_ok=True) + font = Path(str(resources.files("fullbleed_assets").joinpath("fonts/Inter-Variable.ttf"))) + document = build_report(frame, title=title, period=period) + renderer = Renderer(font_files=[str(font)], pdf_profile="tagged", document_timestamp="2026-10-20T00:00:00Z") + source = document.compile(a11y_mode="raise", target="pdf") + prepared = document.prepare(renderer, a11y_mode="raise") + pdf = prepared.render_pdf() + path = out / "report.pdf" + path.write_bytes(pdf) + for styled, filename in ((True, "report.html"), (False, "report-unstyled.html")): + html = document.to_html(include_styles=styled, a11y_mode="raise") + if styled: + # Font loading and screen framing belong to the export boundary. + # The document itself is authored entirely with Python components. + html = html.replace("", '''''', 1) + (out / filename).write_text(html, encoding="utf-8", newline="\n") + (out / "fonts").mkdir(exist_ok=True) + shutil.copyfile(font, out / "fonts" / font.name) + shutil.copyfile(Path(__file__).with_name("Inter-OFL.txt"), out / "fonts/Inter-OFL.txt") + inspection = fullbleed.inspect_pdf(str(path)) + if previews: + renderer.engine_for(document).render_finalized_pdf_image_pages_to_dir( + str(path), str(out / "previews"), dpi=110) + report = { + "schema": "fullbleed.pandas_components_example.v1", + "fullbleed": metadata.version("fullbleed"), "pandas": metadata.version("pandas"), + "rows": len(frame), "totals": totals(frame), "page_count": inspection["page_count"], + "pdf_sha256": sha256(pdf).hexdigest(), "font_sha256": sha256(font.read_bytes()).hexdigest(), + "profile": "tagged", "timestamp": "2026-10-20T00:00:00Z", + "validation_scope": "Source structural checks and native layout checks; no PDF/UA conformance claim.", + } + for name, value in (("render-report.json", report), ("a11y-source.json", source.a11y_report), + ("pagination.json", prepared.export_render_time_pagination_trace()), ("inspection.json", inspection)): + (out / name).write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8", newline="\n") + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--csv", type=Path, default=Path(__file__).with_name("products.csv")) + parser.add_argument("--out", type=Path, default=Path(__file__).with_name("output")) + parser.add_argument("--title", default="The product edit.") + parser.add_argument("--period", default="September 2026") + parser.add_argument("--previews", action="store_true") + args = parser.parse_args() + print(json.dumps(render(load_products(args.csv), args.out.resolve(), title=args.title, + period=args.period, previews=args.previews)), flush=True) + + +if __name__ == "__main__": + main() diff --git a/examples/pandas-components/requirements-check.txt b/examples/pandas-components/requirements-check.txt new file mode 100644 index 0000000..16ec1fd --- /dev/null +++ b/examples/pandas-components/requirements-check.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pypdf==6.19.0 +pypdfium2==5.14.0 +Pillow==12.3.0 diff --git a/examples/pandas-components/requirements.txt b/examples/pandas-components/requirements.txt new file mode 100644 index 0000000..bc63625 --- /dev/null +++ b/examples/pandas-components/requirements.txt @@ -0,0 +1,2 @@ +fullbleed==2.6.0a9 +pandas==3.0.6 diff --git a/examples/pandas-components/verify.py b/examples/pandas-components/verify.py new file mode 100644 index 0000000..4e629ba --- /dev/null +++ b/examples/pandas-components/verify.py @@ -0,0 +1,139 @@ +# SPDX-License-Identifier: MIT +"""Exercise data boundaries and inspect actual saved PDFs with independent readers.""" +import argparse +from hashlib import sha256 +from html.parser import HTMLParser +import json +from pathlib import Path +import re +import subprocess +import sys + +import pandas as pd +from pandas.testing import assert_frame_equal +from pypdf import PdfReader +import pypdfium2 as pdfium + +from data import load_products +from report import render + +HERE = Path(__file__).resolve().parent + + +class Markup(HTMLParser): + def __init__(self, html): + super().__init__() + self.tags, self.attrs = [], [] + self.feed(html) + + def handle_starttag(self, tag, attrs): + self.tags.append(tag) + self.attrs.extend(attrs) + + +def check_pdf(path, skus, out): + reader = PdfReader(path) + assert reader.trailer["/Root"].get("/StructTreeRoot") + texts = [page.extract_text() for page in reader.pages] + text = "\n".join(texts) + observed = re.findall(r"\b(?:GL|TEST)-\d{3}\b", text) + assert observed == list(skus), (path, observed) + for i, content in enumerate(texts, 1): + # pypdf separates individually positioned, letter-spaced header glyphs. + assert "GRIDLINE/OPERATIONS" in re.sub(r"\s+", "", content), (path, i, "header") + assert "GRIDLINE / FICTIONAL DATA" in content, (path, i, "footer") + assert f"{i} / {len(texts)}" in content, (path, i, "page total") + if re.search(r"\b(?:GL|TEST)-\d{3}\b", content): + assert all(label in content for label in ("SKU", "Product", "Category", "Units", "Net sales", "Last restock")) + out.mkdir(parents=True, exist_ok=True) + with pdfium.PdfDocument(path) as doc: + for i, page in enumerate(doc): + width, height = page.get_size() + assert abs(width - 841.89) < .1 and abs(height - 595.276) < .1 + textpage = page.get_textpage() + for n in range(textpage.count_chars()): + if not textpage.get_text_range(n, 1).strip(): + continue + left, bottom, right, top = textpage.get_charbox(n) + assert 5 <= left <= right <= width - 5 and 5 <= bottom <= top <= height - 5, (path, i, n, "outside page") + page.render(scale=1).to_pil().save(out / f"page-{i + 1}.png") + textpage.close() + page.close() + return {"pages": len(reader.pages), "sha256": sha256(path.read_bytes()).hexdigest(), "rows": len(skus)}, text + + +def main(out): + out.mkdir(parents=True, exist_ok=True) + frame = load_products(HERE / "products.csv") + before = frame.copy(deep=True) + cases = {"sample": frame, "one": frame.iloc[:1].copy()} + mixed = frame.iloc[:3].copy() + mixed.loc[0, "product"] = ' & München — café' + mixed.loc[1, "product"] = "A long, descriptive product name that wraps across several lines in the ledger. " * 5 + mixed.loc[0, "net_sales_cents"] = -12050 + mixed.loc[1, "last_restock"] = pd.NaT + mixed.loc[2, "units"] = 0 + cases["mixed"] = mixed + many = pd.concat([frame] * 3, ignore_index=True) + many["sku"] = pd.Series([f"TEST-{i:03}" for i in range(1, 121)], dtype="string") + cases["many"] = many + results = {} + for name, value in cases.items(): + snapshot = value.copy(deep=True) + dest = out / name + report = render(value, dest) + assert_frame_equal(value, snapshot) + result, text = check_pdf(dest / "report.pdf", value["sku"], dest / "independent-previews") + assert report["page_count"] == result["pages"] + styled_html = (dest / "report.html").read_text(encoding="utf-8") + plain_html = (dest / "report-unstyled.html").read_text(encoding="utf-8") + styled, plain = Markup(styled_html), Markup(plain_html) + assert "style" in styled.tags and "style" not in plain.tags + assert all(key != "style" for key, _ in plain.attrs) + assert any(key == "class" and "ledger" in value.split() for key, value in plain.attrs) + assert all("script" not in markup.tags for markup in (styled, plain)) + if name == "sample": + assert result["pages"] == 3 + assert "$150,236.25" in text and "6,040" in text and "München" in text + if name == "mixed": + assert "-$120.50" in text and "Not recorded" in text and 'alert("literal")' in text + assert "<script>" in styled_html + results[name] = result + assert_frame_equal(frame, before) + # A fresh interpreter and output directory must produce the same saved PDF. + replay = out / "fresh-process" + run = subprocess.run([sys.executable, str(HERE / "report.py"), "--out", str(replay)], + text=True, capture_output=True, check=True) + assert json.loads(run.stdout)["pdf_sha256"] == results["sample"]["sha256"] + assert (replay / "report.pdf").read_bytes() == (out / "sample/report.pdf").read_bytes() + + raw = pd.read_csv(HERE / "products.csv", keep_default_na=False) + invalid = {"empty": raw.iloc[:0], "missing-column": raw.drop(columns="sku"), + "duplicate": pd.concat([raw, raw.iloc[:1]], ignore_index=True)} + for name, column, value in (("blank-product", "product", " "), ("missing-units", "units", ""), + ("negative-units", "units", -1), ("fractional-cents", "net_sales_cents", 1.5), + ("bad-date", "last_restock", "2026-99-99")): + bad = raw.astype(object).copy() + bad.loc[0, column] = value + invalid[name] = bad + for name, bad in invalid.items(): + path = out / f"invalid-{name}.csv" + bad.to_csv(path, index=False) + try: + load_products(path) + except (ValueError, TypeError): + pass + else: + raise AssertionError(f"Accepted invalid CSV: {name}") + evidence = {"ok": True, "cases": results, "invalid_inputs_rejected": list(invalid), + "fresh_process_pdf_identical": True, "input_frames_unchanged": True, + "html_styled_and_unstyled_checked": True, + "scope": "Saved-PDF text, row order, repeated headings, page totals, page bounds, independent rasterization; no PDF/UA or browser-pagination claim."} + (out / "verification.json").write_text(json.dumps(evidence, indent=2) + "\n", encoding="utf-8") + print(json.dumps(evidence), flush=True) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=HERE / "output/verification") + main(parser.parse_args().out.resolve()) diff --git a/mkdocs.yml b/mkdocs.yml index 79abaf7..4ce1a97 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -94,6 +94,7 @@ nav: - Chinese text and fonts: guides/chinese-pdf.md - Arabic and English PDFs: guides/arabic-pdf.md - pandas DataFrame to PDF: guides/pandas-to-pdf.md + - pandas with Python components: guides/pandas-components.md - Matplotlib chart reports: guides/matplotlib-pdf.md - Streamlit PDF downloads: guides/streamlit-pdf.md - Rebuild as you edit: guides/render-watch.md diff --git a/tools/build_pandas_components.py b/tools/build_pandas_components.py new file mode 100644 index 0000000..0e5dcbb --- /dev/null +++ b/tools/build_pandas_components.py @@ -0,0 +1,37 @@ +"""Build the small deterministic source kit; never replace rendered evidence.""" +from hashlib import sha256 +from io import BytesIO +import json +from pathlib import Path +from zipfile import ZipFile, ZipInfo, ZIP_STORED + +ROOT = Path(__file__).resolve().parents[1] +PROJECT = ROOT / "examples/pandas-components" +ASSETS = ROOT / "docs/assets/pandas-components" +FILES = ("LICENSE", "Inter-OFL.txt", "README.md", "components.py", "data.py", "products.csv", "report.py", + "requirements.txt", "requirements-check.txt", "verify.py") + + +def main(): + # This kit contains only UTF-8 text files. Canonical LF bytes keep Windows + # checkout settings from changing the downloadable archive or its hashes. + contents = {name: (PROJECT / name).read_text(encoding="utf-8").encode("utf-8") for name in sorted(FILES)} + buffer = BytesIO() + with ZipFile(buffer, "w", compression=ZIP_STORED) as archive: + for name, data in contents.items(): + entry = ZipInfo("fullbleed-pandas-components/" + name, (2026, 10, 20, 0, 0, 0)) + entry.create_system = 3 + entry.external_attr = 0o100644 << 16 + archive.writestr(entry, data) + package = buffer.getvalue() + ASSETS.mkdir(parents=True, exist_ok=True) + (ASSETS / "project.zip").write_bytes(package) + source = {"schema": "fullbleed.pandas_components_source.v1", "fullbleed": "2.6.0a9", "pandas": "3.0.6", + "zip_sha256": sha256(package).hexdigest(), "zip_bytes": len(package), + "files": {name: sha256(data).hexdigest() for name, data in contents.items()}} + (ASSETS / "source.json").write_text(json.dumps(source, indent=2) + "\n", encoding="utf-8", newline="\n") + print(json.dumps({"ok": True, "files": len(contents), "zip_sha256": source["zip_sha256"]})) + + +if __name__ == "__main__": + main() diff --git a/tools/check_pandas_components_browser.py b/tools/check_pandas_components_browser.py new file mode 100644 index 0000000..4997385 --- /dev/null +++ b/tools/check_pandas_components_browser.py @@ -0,0 +1,112 @@ +"""Exercise the real guide downloads and the delivered standalone HTML.""" +from argparse import ArgumentParser +from functools import partial +from hashlib import sha256 +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +import json +from pathlib import Path +import threading +from zipfile import ZipFile + +from playwright.sync_api import expect, sync_playwright +from pypdf import PdfReader + + +def main(site, out, base_url=None): + out.mkdir(parents=True, exist_ok=False) + assets = site / "assets/pandas-components" + checks, downloads, errors = [], {}, [] + server = None + + class Handler(SimpleHTTPRequestHandler): + def log_message(self, *args): + pass + + if not base_url: + server = ThreadingHTTPServer(("127.0.0.1", 0), partial(Handler, directory=str(site))) + threading.Thread(target=server.serve_forever, daemon=True).start() + base_url = f"http://127.0.0.1:{server.server_port}/" + + def check(name, value): + assert value, name + checks.append(name) + + record = {"url": base_url, "checks": checks, "downloads": downloads, "errors": errors} + try: + with sync_playwright() as pw: + browser = pw.chromium.launch(channel="chrome") + context = browser.new_context(accept_downloads=True) + page = context.new_page() + page.on("pageerror", lambda error: errors.append(str(error))) + record["browser"] = browser.version + try: + for width, scheme in ((1440, "light"), (390, "light"), (390, "dark")): + page.set_viewport_size({"width": width, "height": 1000}) + page.emulate_media(color_scheme=scheme) + page.goto(base_url.rstrip("/") + "/guides/pandas-components/", wait_until="networkidle") + if scheme == "dark" and page.locator("body").get_attribute("data-md-color-scheme") != "slate": + page.locator('label[title="Switch to dark mode"]').click() + expect(page.locator("body")).to_have_attribute("data-md-color-scheme", "slate" if scheme == "dark" else "default") + expect(page.get_by_role("heading", level=1)).to_contain_text("pandas reports with Python components") + expect(page.locator(".md-content")).to_contain_text("2.6.0a9") + check(f"guide fits {width}px {scheme}", page.evaluate("document.documentElement.scrollWidth <= innerWidth + 1")) + expect(page.locator(".md-content img").first).to_be_visible() + check(f"PDF preview loads {width}px {scheme}", page.locator(".md-content img").first.evaluate("img => img.complete && img.naturalWidth > 0")) + page.screenshot(path=str(out / f"guide-{width}-{scheme}.png"), animations="disabled") + if scheme == "dark": + continue + for label, filename in (("Download the PDF", "report.pdf"), ("Get the runnable source", "project.zip"), + ("Download both HTML exports", "html-exports.zip")): + with page.expect_download() as pending: + page.get_by_role("link", name=label, exact=True).click() + target = out / f"{width}-{filename}" + pending.value.save_as(target) + data = target.read_bytes() + check(f"{width}px {filename} download matches", data == (assets / filename).read_bytes()) + downloads[f"{width}-{filename}"] = sha256(data).hexdigest() + if filename.endswith(".pdf"): + check(f"{width}px PDF has three pages", len(PdfReader(target).pages) == 3) + with ZipFile(out / "1440-html-exports.zip") as archive: + names = set(archive.namelist()) + assert names == {"fullbleed-pandas-html/" + name for name in + ("report.html", "report-unstyled.html", "fonts/Inter-Variable.ttf", "fonts/Inter-OFL.txt")} + archive.extractall(out) + page.emulate_media(color_scheme="light") + page.set_viewport_size({"width": 1280, "height": 1000}) + for plain, filename in ((False, "report.html"), (True, "report-unstyled.html")): + page.goto((out / "fullbleed-pandas-html" / filename).as_uri(), wait_until="load") + page.evaluate("document.fonts.ready") + check(f"{filename} has all records", page.locator("table tbody tr").count() == 40) + expect(page.get_by_role("heading", level=1)).to_have_text("The product edit.") + check(f"{filename} retains ledger class", page.locator("table.ledger").count() == 1) + check(f"{filename} has no scripts", page.locator("script").count() == 0) + if plain: + check("unstyled export has no CSS", page.locator("style, [style]").count() == 0) + else: + check("bundled Inter loads", page.evaluate("document.fonts.check('12px Inter')")) + check("styled table header is green", page.locator("th").first.evaluate("el=>getComputedStyle(el).backgroundColor") == "rgb(23, 62, 53)") + check("styled export fits desktop", page.evaluate("document.documentElement.scrollWidth <= innerWidth + 1")) + page.screenshot(path=str(out / filename.replace(".html", ".png")), animations="disabled") + check("no uncaught browser errors", not errors) + record["ok"] = True + except Exception: + page.screenshot(path=str(out / "failure.png"), full_page=True) + raise + finally: + context.close() + browser.close() + finally: + if server: + server.shutdown() + server.server_close() + (out / "verification.json").write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8") + print(json.dumps({"ok": record["ok"], "checks": len(checks), "downloads": len(downloads)}), flush=True) + + +if __name__ == "__main__": + parser = ArgumentParser(description=__doc__) + parser.add_argument("--site", type=Path, required=True) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--base-url") + args = parser.parse_args() + main(args.site.resolve(), args.out.resolve(), args.base_url) diff --git a/tools/verify_pandas_components.py b/tools/verify_pandas_components.py new file mode 100644 index 0000000..30f2015 --- /dev/null +++ b/tools/verify_pandas_components.py @@ -0,0 +1,37 @@ +"""Verify the delivered ZIP as a consumer, including the published PDF bytes.""" +from argparse import ArgumentParser +from hashlib import sha256 +import json +from pathlib import Path +import subprocess +import sys +from zipfile import ZipFile + +ROOT = Path(__file__).resolve().parents[1] +ASSETS = ROOT / "docs/assets/pandas-components" + + +def main(out): + out.mkdir(parents=True, exist_ok=True) + source = json.loads((ASSETS / "source.json").read_text(encoding="utf-8")) + assert sha256((ASSETS / "project.zip").read_bytes()).hexdigest() == source["zip_sha256"] + with ZipFile(ASSETS / "project.zip") as archive: + assert set(archive.namelist()) == {"fullbleed-pandas-components/" + name for name in source["files"]} + archive.extractall(out) + project = out / "fullbleed-pandas-components" + for name, digest in source["files"].items(): + assert sha256((project / name).read_bytes()).hexdigest() == digest + result = subprocess.run([sys.executable, str(project / "verify.py"), "--out", str(out / "checks")], + capture_output=True, text=True, encoding="utf-8", check=True) + record = json.loads(result.stdout) + assert record["ok"] + assert (out / "checks/sample/report.pdf").read_bytes() == (ASSETS / "report.pdf").read_bytes(), "Published PDF differs from source kit" + record.update(source_zip_sha256=source["zip_sha256"], published_pdf_identical=True) + (out / "verification.json").write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8", newline="\n") + print(json.dumps(record), flush=True) + + +if __name__ == "__main__": + parser = ArgumentParser(description=__doc__) + parser.add_argument("--out", type=Path, default=ROOT / "target/pandas-components") + main(parser.parse_args().out.resolve())