[](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" }
+
+[](../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())