diff --git a/.gitattributes b/.gitattributes index de3dc4e..ce92280 100644 --- a/.gitattributes +++ b/.gitattributes @@ -152,3 +152,8 @@ examples/accessible-showcase-alpha6/fonts/*.ttf binary docs/assets/accessible-showcase-alpha6/** -text whitespace=cr-at-eol docs/assets/accessible-showcase-alpha6/*.pdf binary docs/assets/accessible-showcase-alpha6/fonts/*.ttf binary + +# The Jupyter download fix is a new notebook revision; old assets stay immutable. +examples/component-notebook-alpha6-r1/** text eol=lf +docs/assets/component-notebook-alpha6-r1/** -text +docs/assets/component-notebook-alpha6-r1/*.pdf binary diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 74f3577..6747bf2 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -77,7 +77,7 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] - channel: [alpha3, alpha5, alpha6] + channel: [alpha3, alpha5, alpha6, alpha6-r1] steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v6 @@ -87,6 +87,13 @@ jobs: python -m pip install --only-binary=:all: -r tools/component-notebook-requirements.txt - name: Execute the notebook and check PDF, HTML and ZIP downloads run: python -I tools/verify_component_notebook.py --channel ${{ matrix.channel }} --check-downloads --out target/component-notebook + - name: Install the optional Jupyter browser test tools + if: matrix.channel == 'alpha6-r1' + run: | + python -m pip install --only-binary=:all: -r tools/component-notebook-browser-requirements.txt + - name: Edit, run and download in Jupyter Notebook + if: matrix.channel == 'alpha6-r1' + run: python -I tools/check_component_notebook_jupyter.py --reference target/component-notebook --out target/component-notebook/browser --browser chrome - uses: actions/upload-artifact@v4 if: always() with: diff --git a/docs/assets/component-notebook-alpha6-r1/bootstrap_components.ipynb b/docs/assets/component-notebook-alpha6-r1/bootstrap_components.ipynb new file mode 100644 index 0000000..0f75b7a --- /dev/null +++ b/docs/assets/component-notebook-alpha6-r1/bootstrap_components.ipynb @@ -0,0 +1,330 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "welcome", + "metadata": {}, + "source": [ + "# Make a PDF from Python components\n", + "\n", + "Edit an order, customize its cards and table, and export a PDF plus styled\n", + "and unstyled HTML. You write Python values and components; no HTML strings\n", + "are needed. All sample data is fictional.\n", + "\n", + "**In Colab:** choose **Runtime → Run all**, sign in if prompted, and use a\n", + "standard CPU runtime. No GPU or paid plan is needed for this example;\n", + "[Colab availability and limits](https://research.google.com/colaboratory/faq.html)\n", + "still apply. Runtime files are temporary, so download your results before\n", + "leaving. Local Jupyter also works.\n", + "\n", + "**Experimental:** this notebook pins Fullbleed **2.6.0a6**. The component API\n", + "may change and has documented CSS limits. Bootstrap components are static\n", + "in both PDF and HTML; no Bootstrap JavaScript is included." + ] + }, + { + "cell_type": "markdown", + "id": "install-help", + "metadata": {}, + "source": [ + "## 1. Install the tested alpha\n", + "\n", + "Run this before importing Fullbleed. If you already imported a different\n", + "version in this notebook session, restart its kernel/runtime first.\n", + "The wheel supplies the engine and font; Jupyter supplies display tools." + ] + }, + { + "cell_type": "code", + "id": "install-fullbleed", + "metadata": { + "tags": [ + "install-fullbleed" + ] + }, + "source": [ + "%pip install --quiet --only-binary=:all: \"fullbleed==2.6.0a6\"" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "data-help", + "metadata": {}, + "source": [ + "## 2. Make the order yours\n", + "\n", + "Change a name, quantity or price, then rerun this cell and the cells below it.\n", + "Prices are decimal strings; this example omits tax, shipping and discounts.\n", + "Try `items=[]` to see the empty state, or add rows to explore pagination." + ] + }, + { + "cell_type": "code", + "id": "order-data", + "metadata": {}, + "source": [ + "order = {\n", + " \"number\": \"1048\",\n", + " \"customer\": \"Moss & Fern Studio\",\n", + " \"collection\": \"The autumn collection\",\n", + " \"status\": \"Ready to ship\",\n", + " \"items\": [\n", + " {\"item\": \"Studio notebooks\", \"quantity\": 24, \"unit_price\": \"15.00\"},\n", + " {\"item\": \"Cotton stationery sets\", \"quantity\": 12, \"unit_price\": \"18.00\"},\n", + " {\"item\": \"Archival prints\", \"quantity\": 8, \"unit_price\": \"60.00\"},\n", + " ],\n", + "}" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "styles-help", + "metadata": {}, + "source": [ + "## 3. Choose the accent and type size\n", + "\n", + "These values feed ordinary Python styles. Later, `.with_parts(...)` styles\n", + "a card's own title and body. The custom `dispatch-note` class gets its rule\n", + "from `Document(styles=...)`; a class name alone does not create a style." + ] + }, + { + "cell_type": "code", + "id": "style-controls", + "metadata": {}, + "source": [ + "accent = \"#205b47\"\n", + "title_size = 30" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "components-help", + "metadata": {}, + "source": [ + "## 4. Compose reusable components\n", + "\n", + "`Panel` supplies reusable card defaults. `Metric` is an ordinary Python\n", + "function. `DataTable` builds its structure from rows and column functions.\n", + "Bootstrap classes such as `mb-4` come with the components; your custom\n", + "classes need their own rules. Text values are escaped automatically.\n", + "\n", + "A column callback can return a component: `Strong(row[\"item\"])` emphasizes\n", + "each item inside its table cell. Return text or compose other components\n", + "without constructing an HTML string.\n" + ] + }, + { + "cell_type": "code", + "id": "components", + "metadata": {}, + "source": [ + "from decimal import Decimal\n", + "from fullbleed.ui.experimental import Document, Page, Strong, Theme, mm, pt, style, styled\n", + "from fullbleed.ui.experimental import bootstrap as bs\n", + "\n", + "def money(value):\n", + " return f\"USD {value:,.2f}\"\n", + "\n", + "Panel = styled(bs.Card, border_color=\"#d7e1de\", border_radius=\"8px\")\n", + "\n", + "def Metric(value, label):\n", + " return Panel(\n", + " bs.Heading(value, level=2, class_name=\"mb-1\", style=style(color=accent)),\n", + " bs.Text(label, class_name=\"text-secondary\"),\n", + " )\n", + "\n", + "def OrderSummary(data):\n", + " rows = [dict(row, amount=Decimal(row[\"unit_price\"]) * row[\"quantity\"])\n", + " for row in data[\"items\"]]\n", + " total = sum((row[\"amount\"] for row in rows), Decimal(\"0.00\"))\n", + " units = sum(row[\"quantity\"] for row in rows)\n", + " details = bs.DataTable(\n", + " rows,\n", + " columns=[\n", + " bs.Column(\"Item\", lambda row: Strong(row[\"item\"])),\n", + " bs.Column(\"Quantity\", lambda row: row[\"quantity\"], align=\"right\"),\n", + " bs.Column(\"Amount\", lambda row: money(row[\"amount\"]), align=\"right\"),\n", + " ],\n", + " striped=True,\n", + " caption=\"Order contents · USD\",\n", + " ) if rows else bs.Alert(\"No items in this order.\", variant=\"light\")\n", + " return Document(\n", + " bs.Box(bs.Text(\"NORTHSTAR / STUDIO\", class_name=\"fw-bold\"),\n", + " bs.Badge(data[\"status\"], variant=\"light\", class_name=\"ms-3\"),\n", + " class_name=\"mb-4\"),\n", + " bs.Heading(data[\"collection\"], level=1,\n", + " style=style(font_size=pt(title_size), color=accent, line_height=1.1)),\n", + " bs.Paragraph(f'Prepared for {data[\"customer\"]}', class_name=\"lead mb-4\"),\n", + " bs.Row(\n", + " bs.Col(Metric(str(units), \"Items in this order\"), span=5),\n", + " bs.Col(Metric(money(total), \"Order total\"), span=7),\n", + " gutter=3, class_name=\"mb-4 mx-0\",\n", + " ),\n", + " Panel(details, title=f'Order {data[\"number\"]}',\n", + " footer=\"Made with care. Packed with purpose.\").with_parts(\n", + " title=style(color=accent, font_size=pt(16)),\n", + " body=style(padding=\"1.25rem\"),\n", + " ),\n", + " bs.Alert(\"Please retain this copy with your shipment.\",\n", + " class_name=\"dispatch-note mt-4\"),\n", + " title=f'Order {data[\"number\"]}',\n", + " page=Page(size=\"A4\", margin=mm(16)),\n", + " theme=Theme(text=style(font_family=\"Inter, Arial, sans-serif\", font_size=pt(10),\n", + " color=\"#18382f\", line_height=1.4)),\n", + " styles={\".dispatch-note\": style(color=accent, border_color=accent,\n", + " background_color=\"#f0f5f2\")},\n", + " )\n", + "\n", + "document = OrderSummary(order)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "render-help", + "metadata": {}, + "source": [ + "## 5. Render and export\n", + "\n", + "The PDF renderer receives the bundled Inter font file explicitly. A CSS\n", + "family name does not load a font. Styled HTML keeps the family declaration;\n", + "its browser host supplies Inter separately, or uses the Arial/sans-serif fallback.\n", + "\n", + "`include_styles=False` removes generated and inline styling but keeps classes\n", + "for a host application's CSS. Both export modes also work on an individual\n", + "component, such as `Metric(\"44\", \"Items\").to_html(include_styles=False)`.\n", + "Styled Bootstrap fragments include global Bootstrap rules; use unstyled\n", + "fragments when the host page already provides its stylesheet." + ] + }, + { + "cell_type": "code", + "id": "render-export", + "metadata": {}, + "source": [ + "from importlib.resources import files\n", + "from pathlib import Path\n", + "from fullbleed.ui.experimental import Renderer\n", + "\n", + "output = Path(\"output\")\n", + "output.mkdir(exist_ok=True)\n", + "font = files(\"fullbleed_assets\").joinpath(\"fonts/Inter-Variable.ttf\")\n", + "renderer = Renderer(font_files=[str(font)],\n", + " document_timestamp=\"2026-10-01T00:00:00Z\")\n", + "prepared = document.prepare(renderer)\n", + "pdf_path = output / \"order.pdf\"\n", + "pdf_path.write_bytes(prepared.render_pdf())\n", + "(output / \"order.styled.html\").write_text(\n", + " document.to_html(include_styles=True), encoding=\"utf-8\", newline=\"\\n\")\n", + "(output / \"order.unstyled.html\").write_text(\n", + " document.to_html(include_styles=False), encoding=\"utf-8\", newline=\"\\n\")\n", + "print(f\"Saved {pdf_path} and both HTML exports.\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "preview", + "metadata": {}, + "source": [ + "from IPython.display import Image, display\n", + "\n", + "preview_paths = []\n", + "for number, png in enumerate(prepared.render_image_pages(dpi=110), start=1):\n", + " path = output / f\"order-page-{number}.png\"\n", + " path.write_bytes(png)\n", + " preview_paths.append(path)\n", + " display(Image(data=png, width=650))\n", + "print(f\"{len(preview_paths)} page(s). Review every page before using the document.\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "download-help", + "metadata": {}, + "source": [ + "## 6. Download your files\n", + "\n", + "This ZIP contains the current PDF, both HTML exports, and every current page\n", + "preview. In Colab the next cell starts the download. In local Jupyter it\n", + "displays a link. The runtime's `output/` folder also contains the individual\n", + "files. If you edit the order or styles, rerun from that edit through this cell." + ] + }, + { + "cell_type": "code", + "id": "download", + "metadata": {}, + "source": [ + "import sys\n", + "from zipfile import ZipFile, ZIP_DEFLATED\n", + "\n", + "bundle_path = output / \"component-order.zip\"\n", + "current_files = [pdf_path, output / \"order.styled.html\",\n", + " output / \"order.unstyled.html\", *preview_paths]\n", + "with ZipFile(bundle_path, \"w\", compression=ZIP_DEFLATED) as bundle:\n", + " for path in current_files:\n", + " bundle.write(path, arcname=path.name)\n", + "\n", + "if \"google.colab\" in sys.modules:\n", + " from google.colab import files\n", + " files.download(str(bundle_path))\n", + "else:\n", + " from IPython.display import FileLink\n", + " display(FileLink(bundle_path.as_posix()))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "next-steps", + "metadata": {}, + "source": [ + "## Keep composing\n", + "\n", + "- [Component guide and catalog](https://docs.fullbleed.dev/guides/bootstrap-components-preview/)\n", + " explain class names, named parts, fonts, static states and current limits.\n", + "- [Northstar and Common Ground source kits](https://docs.fullbleed.dev/guides/showcase-authoring/)\n", + " show a complete invoice and illustrated report in components and HTML/CSS.\n", + "- [The authored accessible guide](https://docs.fullbleed.dev/guides/accessible-showcase/)\n", + " includes the exact PDF, semantic source and scoped validation receipts.\n", + "- [Share an authoring example](https://github.com/fullbleed-engine/fullbleed-official/discussions/86)\n", + " if a composition or styling task feels awkward. Include the small component,\n", + " expected result and Fullbleed version; use fictional data in public posts.\n", + "- [Stable Python guide](https://docs.fullbleed.dev/guides/python-components/)\n", + " documents the established API separately from this alpha.\n", + "\n", + "The notebook and Fullbleed are MIT licensed. This example does not establish\n", + "complete Bootstrap CSS, browser, accessibility or PDF conformance." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + }, + "colab": { + "name": "bootstrap_components.ipynb", + "provenance": [] + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/assets/component-notebook-alpha6-r1/browser-verification.json b/docs/assets/component-notebook-alpha6-r1/browser-verification.json new file mode 100644 index 0000000..e6c1325 --- /dev/null +++ b/docs/assets/component-notebook-alpha6-r1/browser-verification.json @@ -0,0 +1,86 @@ +{ + "ok": true, + "checked_at": "2026-10-18T16:41:19.430606+00:00", + "notebook_sha256": "38007bb834b8affd0e2062cdba77eee56c2eae25c51ded9f37c6d40066b8e997", + "runtime_version": "2.6.0a6", + "system": "Windows", + "python": "3.11.8", + "browser": "155.0.8059.40", + "tools": { + "notebook": "7.6.3", + "jupyter_server": "2.21.1", + "ipykernel": "7.4.0", + "playwright": "1.49.1" + }, + "cases": [ + { + "name": "default", + "pages": 1, + "actual_browser_download": true, + "filename": "component-order.zip", + "href_path": "/files/output/component-order.zip", + "zip_sha256": "559869425c983a7f71f7e12ed10c4b827982e30f0d0959c1fa7418cb3d60bcb3", + "artifact_hashes": { + "order.pdf": "e273572ff4885ba8b139563cec45b590b385a2e7330d8728d2f71596e2fdbce6", + "order.styled.html": "c53d95a831b1d2fda92da28e6dcc5bf6063a7b1090cf1053930c39ff7b54103f", + "order.unstyled.html": "3e072e3dd684f9e7c483859a41051619effa814225a64a1a0e500e4d6be936e6", + "order-page-1.png": "2b6c470b1e49a906170be982a00398ed57033b353cb14e6b56f97763b99bbd2b" + }, + "matches_independent_reference": true, + "visible_previews_match_download": true + }, + { + "name": "edited", + "pages": 1, + "actual_browser_download": true, + "filename": "component-order.zip", + "href_path": "/files/output/component-order.zip", + "zip_sha256": "2fda77249124c3c4492661fa3ecf8951fb0c4c609194d8cdac76ac9ed6554a11", + "artifact_hashes": { + "order.pdf": "5823731e0146ab74806acce6bbd0676af5b77bf04566827c9c50523004cdb4c4", + "order.styled.html": "ffeb7c3da32ee2f88ffca18fdf98a57207217d35dfa9a8b6ad86b889ef763f49", + "order.unstyled.html": "7079de5ca04cb177864165f59f3a77ddf5d3d4406fb274973ea6bbf0b6254fac", + "order-page-1.png": "a9610eb13d8b47f2f643a0809de412302428d772e9798e1ab17d3f9361c69fa7" + }, + "matches_independent_reference": true, + "visible_previews_match_download": true + }, + { + "name": "long", + "pages": 4, + "actual_browser_download": true, + "filename": "component-order.zip", + "href_path": "/files/output/component-order.zip", + "zip_sha256": "72eb4fccf02ae5b5e9bbc3d37a1b3e4b8cfff61d89b026eebb0251b4757ffc42", + "artifact_hashes": { + "order.pdf": "c543c6cd5c6dadc7b4e77fd043a3fa5f2693dfbb0e20d9f844a5e6bb894a4735", + "order.styled.html": "40b7281978170db2c4da4ca73ab86c54e367014f66d1ae64ea279b83a44f148d", + "order.unstyled.html": "a9a83ab877726691b998e0b53fef913a927044c9f1dbaa422fc2a47d18fecbf8", + "order-page-1.png": "d9dac9397542e717cf089da666509b3511e0700134dd6b77fe77bbe6751450f8", + "order-page-2.png": "cba70b5c9faf533ff81d3ae6d0cfee2a08b5edca98de02a38f05ed0c471f2eea", + "order-page-3.png": "20953665812775ad39f9c1b02b80f1d083a5ac24d4e98150e091bf59cee01881", + "order-page-4.png": "9c7b49a79e37676a917621f4e603dcd1f1bd961ee1c2743b361a910ce4632e33" + }, + "matches_independent_reference": true, + "visible_previews_match_download": true + }, + { + "name": "empty-after-long", + "pages": 1, + "actual_browser_download": true, + "filename": "component-order.zip", + "href_path": "/files/output/component-order.zip", + "zip_sha256": "f011e7c092d956e9f978fa71d9fe16806d115d259f7bda05be2cc7bcf2f842ec", + "artifact_hashes": { + "order.pdf": "5994d1a6f3a7176c8f4f614221cd60d27028a1fd8d80a372f4510eeeb056b573", + "order.styled.html": "7f7576f19793dbb00055d236a6e42a31970d0eee7a6bf20a966aaec80542b934", + "order.unstyled.html": "8d287583c01c902d4d58f400e36c7d3437631999aacfc16f146955a1311b130a", + "order-page-1.png": "d18340f788498d3f1912c80675562eaa47353e85a6e0a5bb6837dbf0a6ced454" + }, + "matches_independent_reference": true, + "visible_previews_match_download": true + } + ], + "stale_previews_excluded": true, + "scope": "Local Jupyter Notebook editing, execution, displayed previews and actual browser ZIP downloads. Hosted Colab is not tested." +} diff --git a/docs/assets/component-notebook-alpha6-r1/order-page-1.png b/docs/assets/component-notebook-alpha6-r1/order-page-1.png new file mode 100644 index 0000000..0303b90 Binary files /dev/null and b/docs/assets/component-notebook-alpha6-r1/order-page-1.png differ diff --git a/docs/assets/component-notebook-alpha6-r1/order.pdf b/docs/assets/component-notebook-alpha6-r1/order.pdf new file mode 100644 index 0000000..2d2bb4b Binary files /dev/null and b/docs/assets/component-notebook-alpha6-r1/order.pdf differ diff --git a/docs/assets/component-notebook-alpha6-r1/order.styled.html b/docs/assets/component-notebook-alpha6-r1/order.styled.html new file mode 100644 index 0000000..42fbeb4 --- /dev/null +++ b/docs/assets/component-notebook-alpha6-r1/order.styled.html @@ -0,0 +1,39 @@ +Order 1048
NORTHSTAR / STUDIOReady to ship

The autumn collection

Prepared for Moss & Fern Studio

44

Items in this order

USD 1,056.00

Order total

Order 1048

Order contents · USD
ItemQuantityAmount
Studio notebooks24USD 360.00
Cotton stationery sets12USD 216.00
Archival prints8USD 480.00
Please retain this copy with your shipment.
\ No newline at end of file diff --git a/docs/assets/component-notebook-alpha6-r1/order.unstyled.html b/docs/assets/component-notebook-alpha6-r1/order.unstyled.html new file mode 100644 index 0000000..fe4de62 --- /dev/null +++ b/docs/assets/component-notebook-alpha6-r1/order.unstyled.html @@ -0,0 +1 @@ +Order 1048
NORTHSTAR / STUDIOReady to ship

The autumn collection

Prepared for Moss & Fern Studio

44

Items in this order

USD 1,056.00

Order total

Order 1048

Order contents · USD
ItemQuantityAmount
Studio notebooks24USD 360.00
Cotton stationery sets12USD 216.00
Archival prints8USD 480.00
Please retain this copy with your shipment.
\ No newline at end of file diff --git a/docs/assets/component-notebook-alpha6-r1/verification.json b/docs/assets/component-notebook-alpha6-r1/verification.json new file mode 100644 index 0000000..7c02ceb --- /dev/null +++ b/docs/assets/component-notebook-alpha6-r1/verification.json @@ -0,0 +1,67 @@ +{ + "runtime_version": "2.6.0a6", + "notebook_sha256": "38007bb834b8affd0e2062cdba77eee56c2eae25c51ded9f37c6d40066b8e997", + "cases": [ + { + "name": "default", + "pages": 1, + "row_count": 3, + "total": "1056.00", + "pdf_sha256": "e273572ff4885ba8b139563cec45b590b385a2e7330d8728d2f71596e2fdbce6", + "accent": "#205b47", + "rich_cell_components_checked": true + }, + { + "name": "edited", + "pages": 1, + "row_count": 3, + "total": "1176.00", + "pdf_sha256": "5823731e0146ab74806acce6bbd0676af5b77bf04566827c9c50523004cdb4c4", + "accent": "#763c96", + "rich_cell_components_checked": true + }, + { + "name": "long", + "pages": 4, + "row_count": 72, + "total": "684.00", + "pdf_sha256": "c543c6cd5c6dadc7b4e77fd043a3fa5f2693dfbb0e20d9f844a5e6bb894a4735", + "accent": "#205b47", + "rich_cell_components_checked": true + }, + { + "name": "empty-after-long", + "pages": 1, + "row_count": 0, + "total": "0.00", + "pdf_sha256": "5994d1a6f3a7176c8f4f614221cd60d27028a1fd8d80a372f4510eeeb056b573", + "accent": "#205b47", + "rich_cell_components_checked": true + } + ], + "downloads": { + "bootstrap_components.ipynb": "38007bb834b8affd0e2062cdba77eee56c2eae25c51ded9f37c6d40066b8e997", + "order.pdf": "e273572ff4885ba8b139563cec45b590b385a2e7330d8728d2f71596e2fdbce6", + "order-page-1.png": "d84d30060d7593e28dbc9ae16cb0f7c4ef1a013f83ed82cff9df8e9519eae1fa", + "order.styled.html": "c53d95a831b1d2fda92da28e6dcc5bf6063a7b1090cf1053930c39ff7b54103f", + "order.unstyled.html": "3e072e3dd684f9e7c483859a41051619effa814225a64a1a0e500e4d6be936e6" + }, + "preview": "PDFium rendering of the default output PDF at 110 DPI", + "checks": [ + "Notebook installation and execution", + "Independent PDF text and page bounds", + "Requested PDF accent colours", + "Repeat/direct PDF byte equality", + "Static styled and unstyled HTML", + "Rich item cell emphasis, order and escaped text in both HTML exports", + "Exact ZIP contents", + "Long-to-empty rerun excludes old pages" + ], + "scope": "Executed Jupyter cells and independent PDF/HTML/ZIP checks. Hosted Colab runtime and download UI, exhaustive CSS/browser parity and standards conformance are not evaluated.", + "browser_verification": { + "file": "browser-verification.json", + "sha256": "78aeabb0c3a3cfa720f3b7718cda620cc9f31a20654ed6c6b370fccfa982ae05", + "system": "Windows", + "scope": "Local Jupyter Notebook editing, execution, displayed previews and actual browser ZIP downloads. Hosted Colab is not tested." + } +} diff --git a/docs/getting-started/component-notebook.md b/docs/getting-started/component-notebook.md index 3324387..3265362 100644 --- a/docs/getting-started/component-notebook.md +++ b/docs/getting-started/component-notebook.md @@ -11,9 +11,9 @@ The notebook contains the complete source, with no authored HTML strings. Item cells return `Strong(...)` components, so emphasis stays part of the component tree. -[Open in Colab](https://colab.research.google.com/github/fullbleed-engine/docs/blob/ee8cfbef90116c4e1ea2db7c0f005dce946a11a8/examples/component-notebook-alpha6/bootstrap_components.ipynb){ .md-button .md-button--primary } -[Download the notebook](../assets/component-notebook-alpha6/bootstrap_components.ipynb){ .md-button download="bootstrap_components.ipynb" } -[View the source](https://github.com/fullbleed-engine/docs/blob/ee8cfbef90116c4e1ea2db7c0f005dce946a11a8/examples/component-notebook-alpha6/bootstrap_components.ipynb){ .md-button } +[Open in Colab](https://colab.research.google.com/github/fullbleed-engine/docs/blob/43aff6dee9692d371fa49221c4bcb99045d12639/examples/component-notebook-alpha6-r1/bootstrap_components.ipynb){ .md-button .md-button--primary } +[Download the notebook](../assets/component-notebook-alpha6-r1/bootstrap_components.ipynb){ .md-button download="bootstrap_components.ipynb" } +[View the source](https://github.com/fullbleed-engine/docs/blob/43aff6dee9692d371fa49221c4bcb99045d12639/examples/component-notebook-alpha6-r1/bootstrap_components.ipynb){ .md-button } !!! warning "Opt-in component alpha" The notebook installs **Fullbleed 2.6.0a6**. Its APIs under @@ -22,7 +22,7 @@ component tree. is included. The [stable Python guide](../guides/python-components.md) documents the established API. -## Run it, then make an edit +## Run in Colab 1. Open the notebook, sign in to Google if prompted, and choose **Runtime > Run all**. 2. Edit the order data in step 2 or the accent and title size in step 3. @@ -32,20 +32,60 @@ component tree. Use a standard CPU runtime; this example needs no GPU or paid plan. Colab runs the code on a hosted machine, applies its [own availability and usage limits](https://research.google.com/colaboratory/faq.html), and deletes runtime files after the session expires. Download your results before -leaving. You can also open the notebook in local Jupyter; the same cells display -a download link there. +leaving. + +## Run in local Jupyter + +Download the notebook above. In the folder containing `bootstrap_components.ipynb`, +create a virtual environment and launch Jupyter with these commands. + +**Windows (PowerShell):** + +```powershell +py -m venv .venv +.\.venv\Scripts\python.exe -m pip install "notebook==7.6.3" +.\.venv\Scripts\python.exe -m notebook bootstrap_components.ipynb +``` + +**macOS or Linux:** + +```bash +python3 -m venv .venv +.venv/bin/python -m pip install "notebook==7.6.3" +.venv/bin/python -m notebook bootstrap_components.ipynb +``` + +In the browser, choose **Run > Run All Cells**. The first cell installs the pinned +Fullbleed alpha in this environment. When the kernel finishes, review the page +previews and click **output/component-order.zip** in step 6. + +After editing the data or styles, use **Run > Run All Cells** again and download +the new ZIP. Its individual PDF, HTML and PNG files also remain in the notebook's +local `output/` folder. This notebook revision normalizes the download URL on +Windows so the browser saves the ZIP as `component-order.zip`. ## What you will make -[![A Northstar order summary with green headings, quantity and total cards, a three-row table and a shipment note.](../assets/component-notebook-alpha6/order-page-1.png){ width="480" }](../assets/component-notebook-alpha6/order.pdf) +[![A Northstar order summary with green headings, quantity and total cards, a three-row table and a shipment note.](../assets/component-notebook-alpha6-r1/order-page-1.png){ width="480" }](../assets/component-notebook-alpha6-r1/order.pdf) -[Open the sample PDF](../assets/component-notebook-alpha6/order.pdf){ .md-button } -[Open styled HTML](../assets/component-notebook-alpha6/order.styled.html){ .md-button } -[Open unstyled HTML](../assets/component-notebook-alpha6/order.unstyled.html){ .md-button } +[Open the sample PDF](../assets/component-notebook-alpha6-r1/order.pdf){ .md-button } +[Open styled HTML](../assets/component-notebook-alpha6-r1/order.styled.html){ .md-button } +[Open unstyled HTML](../assets/component-notebook-alpha6-r1/order.unstyled.html){ .md-button } The fictional order contains 44 items totaling USD 1,056.00. Python `Decimal` calculates the total; the example omits tax, shipping and discounts. Try an empty -item list or add rows to explore pagination. +item list or add rows to explore pagination. For the 72-row example, append +this to the order-data cell in step 2 and rerun from there: + +```python +order["items"] = [ + {"item": f"Edition {n:02d} / art print", "quantity": 1, "unit_price": "9.50"} + for n in range(1, 73) +] +``` + +Replace that assignment with `order["items"] = []` to try the empty state. +The download includes only the current order's pages, even after a longer run. ## See how styling connects @@ -74,9 +114,15 @@ compare repeat renders; and check both HTML exports and the ZIP's exact contents that rich item cells preserve their emphasis, order and escaped text. Old page previews are excluded from a shorter order's download. -The [verification record](../assets/component-notebook-alpha6/verification.json) includes -the sample and notebook hashes. These checks do not execute a hosted Colab runtime -or its download UI, or establish complete CSS, browser or PDF-standard conformance. +The [verification record](../assets/component-notebook-alpha6-r1/verification.json) includes +the sample and notebook hashes. A separate +[Jupyter browser record](../assets/component-notebook-alpha6-r1/browser-verification.json) +covers all four cases in Windows Chrome with Notebook 7.6.3: editing cells, +running them, displaying every preview, and clicking the actual ZIP download. +Every downloaded PDF, HTML file and PNG matches the independently checked output. + +These checks do not execute a hosted Colab runtime or its download UI, or establish +complete CSS, browser or PDF-standard conformance. [Share a small authoring example](https://github.com/fullbleed-engine/fullbleed-official/discussions/86) if a composition or styling task feels awkward. Use fictional data in public examples. @@ -84,3 +130,5 @@ if a composition or styling task feels awkward. Use fictional data in public exa The [archived alpha 3 notebook](https://colab.research.google.com/github/fullbleed-engine/docs/blob/8cf4915321f2faf517ad7e5b0725e83754e227a6/examples/component-notebook/bootstrap_components.ipynb), [sample PDF](../assets/component-notebook/order.pdf) and [archived verification record](../assets/component-notebook/verification.json) retain their original runtime and bytes. The [alpha 5 notebook guide](https://github.com/fullbleed-engine/docs/blob/9aa0b2d9b26d398df58f55c8dd3b240a2457e96e/docs/getting-started/component-notebook.md) retains its original pinned notebook and sample files. + +The [original alpha 6 notebook guide](https://github.com/fullbleed-engine/docs/blob/ba2540e2603787593189c1ae836b1c61702e313d/docs/getting-started/component-notebook.md) retains its original notebook and sample files. This revision uses the same Fullbleed 2.6.0a6 runtime and produces identical documents. diff --git a/examples/component-notebook-alpha6-r1/bootstrap_components.ipynb b/examples/component-notebook-alpha6-r1/bootstrap_components.ipynb new file mode 100644 index 0000000..0f75b7a --- /dev/null +++ b/examples/component-notebook-alpha6-r1/bootstrap_components.ipynb @@ -0,0 +1,330 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "welcome", + "metadata": {}, + "source": [ + "# Make a PDF from Python components\n", + "\n", + "Edit an order, customize its cards and table, and export a PDF plus styled\n", + "and unstyled HTML. You write Python values and components; no HTML strings\n", + "are needed. All sample data is fictional.\n", + "\n", + "**In Colab:** choose **Runtime → Run all**, sign in if prompted, and use a\n", + "standard CPU runtime. No GPU or paid plan is needed for this example;\n", + "[Colab availability and limits](https://research.google.com/colaboratory/faq.html)\n", + "still apply. Runtime files are temporary, so download your results before\n", + "leaving. Local Jupyter also works.\n", + "\n", + "**Experimental:** this notebook pins Fullbleed **2.6.0a6**. The component API\n", + "may change and has documented CSS limits. Bootstrap components are static\n", + "in both PDF and HTML; no Bootstrap JavaScript is included." + ] + }, + { + "cell_type": "markdown", + "id": "install-help", + "metadata": {}, + "source": [ + "## 1. Install the tested alpha\n", + "\n", + "Run this before importing Fullbleed. If you already imported a different\n", + "version in this notebook session, restart its kernel/runtime first.\n", + "The wheel supplies the engine and font; Jupyter supplies display tools." + ] + }, + { + "cell_type": "code", + "id": "install-fullbleed", + "metadata": { + "tags": [ + "install-fullbleed" + ] + }, + "source": [ + "%pip install --quiet --only-binary=:all: \"fullbleed==2.6.0a6\"" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "data-help", + "metadata": {}, + "source": [ + "## 2. Make the order yours\n", + "\n", + "Change a name, quantity or price, then rerun this cell and the cells below it.\n", + "Prices are decimal strings; this example omits tax, shipping and discounts.\n", + "Try `items=[]` to see the empty state, or add rows to explore pagination." + ] + }, + { + "cell_type": "code", + "id": "order-data", + "metadata": {}, + "source": [ + "order = {\n", + " \"number\": \"1048\",\n", + " \"customer\": \"Moss & Fern Studio\",\n", + " \"collection\": \"The autumn collection\",\n", + " \"status\": \"Ready to ship\",\n", + " \"items\": [\n", + " {\"item\": \"Studio notebooks\", \"quantity\": 24, \"unit_price\": \"15.00\"},\n", + " {\"item\": \"Cotton stationery sets\", \"quantity\": 12, \"unit_price\": \"18.00\"},\n", + " {\"item\": \"Archival prints\", \"quantity\": 8, \"unit_price\": \"60.00\"},\n", + " ],\n", + "}" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "styles-help", + "metadata": {}, + "source": [ + "## 3. Choose the accent and type size\n", + "\n", + "These values feed ordinary Python styles. Later, `.with_parts(...)` styles\n", + "a card's own title and body. The custom `dispatch-note` class gets its rule\n", + "from `Document(styles=...)`; a class name alone does not create a style." + ] + }, + { + "cell_type": "code", + "id": "style-controls", + "metadata": {}, + "source": [ + "accent = \"#205b47\"\n", + "title_size = 30" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "components-help", + "metadata": {}, + "source": [ + "## 4. Compose reusable components\n", + "\n", + "`Panel` supplies reusable card defaults. `Metric` is an ordinary Python\n", + "function. `DataTable` builds its structure from rows and column functions.\n", + "Bootstrap classes such as `mb-4` come with the components; your custom\n", + "classes need their own rules. Text values are escaped automatically.\n", + "\n", + "A column callback can return a component: `Strong(row[\"item\"])` emphasizes\n", + "each item inside its table cell. Return text or compose other components\n", + "without constructing an HTML string.\n" + ] + }, + { + "cell_type": "code", + "id": "components", + "metadata": {}, + "source": [ + "from decimal import Decimal\n", + "from fullbleed.ui.experimental import Document, Page, Strong, Theme, mm, pt, style, styled\n", + "from fullbleed.ui.experimental import bootstrap as bs\n", + "\n", + "def money(value):\n", + " return f\"USD {value:,.2f}\"\n", + "\n", + "Panel = styled(bs.Card, border_color=\"#d7e1de\", border_radius=\"8px\")\n", + "\n", + "def Metric(value, label):\n", + " return Panel(\n", + " bs.Heading(value, level=2, class_name=\"mb-1\", style=style(color=accent)),\n", + " bs.Text(label, class_name=\"text-secondary\"),\n", + " )\n", + "\n", + "def OrderSummary(data):\n", + " rows = [dict(row, amount=Decimal(row[\"unit_price\"]) * row[\"quantity\"])\n", + " for row in data[\"items\"]]\n", + " total = sum((row[\"amount\"] for row in rows), Decimal(\"0.00\"))\n", + " units = sum(row[\"quantity\"] for row in rows)\n", + " details = bs.DataTable(\n", + " rows,\n", + " columns=[\n", + " bs.Column(\"Item\", lambda row: Strong(row[\"item\"])),\n", + " bs.Column(\"Quantity\", lambda row: row[\"quantity\"], align=\"right\"),\n", + " bs.Column(\"Amount\", lambda row: money(row[\"amount\"]), align=\"right\"),\n", + " ],\n", + " striped=True,\n", + " caption=\"Order contents · USD\",\n", + " ) if rows else bs.Alert(\"No items in this order.\", variant=\"light\")\n", + " return Document(\n", + " bs.Box(bs.Text(\"NORTHSTAR / STUDIO\", class_name=\"fw-bold\"),\n", + " bs.Badge(data[\"status\"], variant=\"light\", class_name=\"ms-3\"),\n", + " class_name=\"mb-4\"),\n", + " bs.Heading(data[\"collection\"], level=1,\n", + " style=style(font_size=pt(title_size), color=accent, line_height=1.1)),\n", + " bs.Paragraph(f'Prepared for {data[\"customer\"]}', class_name=\"lead mb-4\"),\n", + " bs.Row(\n", + " bs.Col(Metric(str(units), \"Items in this order\"), span=5),\n", + " bs.Col(Metric(money(total), \"Order total\"), span=7),\n", + " gutter=3, class_name=\"mb-4 mx-0\",\n", + " ),\n", + " Panel(details, title=f'Order {data[\"number\"]}',\n", + " footer=\"Made with care. Packed with purpose.\").with_parts(\n", + " title=style(color=accent, font_size=pt(16)),\n", + " body=style(padding=\"1.25rem\"),\n", + " ),\n", + " bs.Alert(\"Please retain this copy with your shipment.\",\n", + " class_name=\"dispatch-note mt-4\"),\n", + " title=f'Order {data[\"number\"]}',\n", + " page=Page(size=\"A4\", margin=mm(16)),\n", + " theme=Theme(text=style(font_family=\"Inter, Arial, sans-serif\", font_size=pt(10),\n", + " color=\"#18382f\", line_height=1.4)),\n", + " styles={\".dispatch-note\": style(color=accent, border_color=accent,\n", + " background_color=\"#f0f5f2\")},\n", + " )\n", + "\n", + "document = OrderSummary(order)" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "render-help", + "metadata": {}, + "source": [ + "## 5. Render and export\n", + "\n", + "The PDF renderer receives the bundled Inter font file explicitly. A CSS\n", + "family name does not load a font. Styled HTML keeps the family declaration;\n", + "its browser host supplies Inter separately, or uses the Arial/sans-serif fallback.\n", + "\n", + "`include_styles=False` removes generated and inline styling but keeps classes\n", + "for a host application's CSS. Both export modes also work on an individual\n", + "component, such as `Metric(\"44\", \"Items\").to_html(include_styles=False)`.\n", + "Styled Bootstrap fragments include global Bootstrap rules; use unstyled\n", + "fragments when the host page already provides its stylesheet." + ] + }, + { + "cell_type": "code", + "id": "render-export", + "metadata": {}, + "source": [ + "from importlib.resources import files\n", + "from pathlib import Path\n", + "from fullbleed.ui.experimental import Renderer\n", + "\n", + "output = Path(\"output\")\n", + "output.mkdir(exist_ok=True)\n", + "font = files(\"fullbleed_assets\").joinpath(\"fonts/Inter-Variable.ttf\")\n", + "renderer = Renderer(font_files=[str(font)],\n", + " document_timestamp=\"2026-10-01T00:00:00Z\")\n", + "prepared = document.prepare(renderer)\n", + "pdf_path = output / \"order.pdf\"\n", + "pdf_path.write_bytes(prepared.render_pdf())\n", + "(output / \"order.styled.html\").write_text(\n", + " document.to_html(include_styles=True), encoding=\"utf-8\", newline=\"\\n\")\n", + "(output / \"order.unstyled.html\").write_text(\n", + " document.to_html(include_styles=False), encoding=\"utf-8\", newline=\"\\n\")\n", + "print(f\"Saved {pdf_path} and both HTML exports.\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "code", + "id": "preview", + "metadata": {}, + "source": [ + "from IPython.display import Image, display\n", + "\n", + "preview_paths = []\n", + "for number, png in enumerate(prepared.render_image_pages(dpi=110), start=1):\n", + " path = output / f\"order-page-{number}.png\"\n", + " path.write_bytes(png)\n", + " preview_paths.append(path)\n", + " display(Image(data=png, width=650))\n", + "print(f\"{len(preview_paths)} page(s). Review every page before using the document.\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "download-help", + "metadata": {}, + "source": [ + "## 6. Download your files\n", + "\n", + "This ZIP contains the current PDF, both HTML exports, and every current page\n", + "preview. In Colab the next cell starts the download. In local Jupyter it\n", + "displays a link. The runtime's `output/` folder also contains the individual\n", + "files. If you edit the order or styles, rerun from that edit through this cell." + ] + }, + { + "cell_type": "code", + "id": "download", + "metadata": {}, + "source": [ + "import sys\n", + "from zipfile import ZipFile, ZIP_DEFLATED\n", + "\n", + "bundle_path = output / \"component-order.zip\"\n", + "current_files = [pdf_path, output / \"order.styled.html\",\n", + " output / \"order.unstyled.html\", *preview_paths]\n", + "with ZipFile(bundle_path, \"w\", compression=ZIP_DEFLATED) as bundle:\n", + " for path in current_files:\n", + " bundle.write(path, arcname=path.name)\n", + "\n", + "if \"google.colab\" in sys.modules:\n", + " from google.colab import files\n", + " files.download(str(bundle_path))\n", + "else:\n", + " from IPython.display import FileLink\n", + " display(FileLink(bundle_path.as_posix()))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "next-steps", + "metadata": {}, + "source": [ + "## Keep composing\n", + "\n", + "- [Component guide and catalog](https://docs.fullbleed.dev/guides/bootstrap-components-preview/)\n", + " explain class names, named parts, fonts, static states and current limits.\n", + "- [Northstar and Common Ground source kits](https://docs.fullbleed.dev/guides/showcase-authoring/)\n", + " show a complete invoice and illustrated report in components and HTML/CSS.\n", + "- [The authored accessible guide](https://docs.fullbleed.dev/guides/accessible-showcase/)\n", + " includes the exact PDF, semantic source and scoped validation receipts.\n", + "- [Share an authoring example](https://github.com/fullbleed-engine/fullbleed-official/discussions/86)\n", + " if a composition or styling task feels awkward. Include the small component,\n", + " expected result and Fullbleed version; use fictional data in public posts.\n", + "- [Stable Python guide](https://docs.fullbleed.dev/guides/python-components/)\n", + " documents the established API separately from this alpha.\n", + "\n", + "The notebook and Fullbleed are MIT licensed. This example does not establish\n", + "complete Bootstrap CSS, browser, accessibility or PDF conformance." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + }, + "colab": { + "name": "bootstrap_components.ipynb", + "provenance": [] + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tools/build_component_notebook_samples.py b/tools/build_component_notebook_samples.py index 71648a1..c4e39d7 100644 --- a/tools/build_component_notebook_samples.py +++ b/tools/build_component_notebook_samples.py @@ -9,12 +9,13 @@ ROOT = Path(__file__).resolve().parents[1] parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--verification-dir", required=True, type=Path) -parser.add_argument("--channel", choices=("alpha3", "alpha5", "alpha6"), default="alpha3") +parser.add_argument("--browser-verification-dir", type=Path, help="Optional actual Jupyter browser check for this exact notebook") +parser.add_argument("--channel", choices=("alpha3", "alpha5", "alpha6", "alpha6-r1"), default="alpha3") args = parser.parse_args() checked = args.verification_dir.resolve() report = json.loads((checked / "verification.json").read_text(encoding="utf-8")) folder = "component-notebook" if args.channel == "alpha3" else f"component-notebook-{args.channel}" -runtime_version = {"alpha3": "2.6.0a3", "alpha5": "2.6.0a5", "alpha6": "2.6.0a6"}[args.channel] +runtime_version = {"alpha3": "2.6.0a3", "alpha5": "2.6.0a5", "alpha6": "2.6.0a6", "alpha6-r1": "2.6.0a6"}[args.channel] source = ROOT / "examples" / folder / "bootstrap_components.ipynb" assert report["ok"] and report["runtime_version"] == runtime_version assert report["notebook_sha256"] == sha256(source.read_bytes()).hexdigest() @@ -45,5 +46,20 @@ for row in manifest["cases"]: row["rich_cell_components_checked"] = True manifest["checks"].insert(5, "Rich item cell emphasis, order and escaped text in both HTML exports") +if args.browser_verification_dir: + browser_path = args.browser_verification_dir / "verification.json" + browser = json.loads(browser_path.read_text(encoding="utf-8")) + assert browser["ok"] and browser["notebook_sha256"] == report["notebook_sha256"] + assert browser["runtime_version"] == report["runtime_version"] + assert [r["name"] for r in browser["cases"]] == [r["name"] for r in report["cases"]] + for actual, expected in zip(browser["cases"], report["cases"]): + assert actual["actual_browser_download"] and actual["visible_previews_match_download"] + assert actual["filename"] == "component-order.zip" + assert actual["artifact_hashes"] == expected["artifact_hashes"] + assert browser["stale_previews_excluded"] + shutil.copyfile(browser_path, assets / "browser-verification.json") + manifest["browser_verification"] = {"file": "browser-verification.json", + "sha256": sha256(browser_path.read_bytes()).hexdigest(), "system": browser["system"], + "scope": browser["scope"]} (assets / "verification.json").write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8", newline="\n") print(json.dumps({"ok": True, "downloads": len(manifest["downloads"]), "cases": len(manifest["cases"])})) diff --git a/tools/check_component_notebook_jupyter.py b/tools/check_component_notebook_jupyter.py new file mode 100644 index 0000000..e6859f2 --- /dev/null +++ b/tools/check_component_notebook_jupyter.py @@ -0,0 +1,219 @@ +"""Edit and run the notebook in a browser, then verify actual ZIP downloads. + +Run verify_component_notebook.py first and supply its --out as --reference. +The reference supplies independently inspected PDF/HTML/PNG bytes. A private +loopback Jupyter server and a separate headless browser are stopped on exit. +""" +from __future__ import annotations + +import argparse +import ast +import base64 +from copy import deepcopy +from datetime import datetime, timezone +from hashlib import sha256 +import importlib.metadata +import json +import os +from pathlib import Path +import platform +import secrets +import shutil +import socket +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.request +from urllib.parse import urlsplit +from zipfile import ZipFile + +from playwright.sync_api import expect, sync_playwright + +ROOT = Path(__file__).resolve().parents[1] + + +def write(path, value): + path.write_text(json.dumps(value, indent=2) + "\n", encoding="utf-8", newline="\n") + + +def check_browser(page, context, source, work, out, reference, token): + source_cells = {c["id"]: "".join(c["source"]) for c in source["cells"] if c["cell_type"] == "code"} + assert len(source_cells) == 7 + order = ast.literal_eval(ast.parse(source_cells["order-data"]).body[0].value) + downloads = [] + context.on("page", lambda child: child.on("download", lambda item: downloads.append(item))) + page.on("download", lambda item: downloads.append(item)) + reports = [] + page.get_by_role("menuitem", name="Run", exact=True).wait_for(timeout=60000) + expect(page.get_by_role("textbox")).to_have_count(7) + for index, name in enumerate(("default", "edited", "long", "empty-after-long")): + destination = out / name + destination.mkdir() + if index: + data = deepcopy(order) + accent, size = "#205b47", 30 + if name == "edited": + data.update(number="1051", customer="Moss & Fern ", status="Packed") + data["items"][0].update(quantity=32, item="Studio & cards") + data["items"][1]["item"] = "Personalized note cards" + accent, size = "#763c96", 26 + elif name in {"long", "empty-after-long"}: + data["items"] = [] + data_source = "order = " + repr(data) + if name == "long": + data_source += "\norder['items'] = [\n dict(item=f'Edition {n:02d} / art print', quantity=1, unit_price='9.50')\n for n in range(1, 73)\n]" + for position, text in ((1, data_source), (2, f"accent = {accent!r}\ntitle_size = {size!r}")): + editor = page.get_by_role("textbox").nth(position) + editor.scroll_into_view_if_needed() + editor.fill(text) + assert "\n".join(editor.locator("div.cm-line").all_text_contents()) == text + page.get_by_role("menuitem", name="Run", exact=True).click() + page.get_by_role("menuitem", name="Run All Cells", exact=True).click() + page.get_by_text(f"[{(index + 1) * 7}]:", exact=True).wait_for(timeout=180000) + expect(page.get_by_role("progressbar", name="Kernel status")).to_have_attribute("aria-valuenow", "100", timeout=30000) + link = page.get_by_role("link", name="output/component-order.zip", exact=True) + link.wait_for() + href_path = urlsplit(link.get_attribute("href")).path + assert href_path == "/files/output/component-order.zip", href_path + page.get_by_role("button", name="Save and create checkpoint (Ctrl+S)", exact=True).click() + downloads.clear() + link.click() + deadline = time.monotonic() + 15 + while not downloads and time.monotonic() < deadline: + page.wait_for_timeout(100) + assert len(downloads) == 1, "Expected a real browser download, including a temporary download tab" + download = downloads[0] + assert download.suggested_filename == "component-order.zip", download.suggested_filename + assert download.failure() is None + archive = destination / download.suggested_filename + download.save_as(archive) + expected = next(row for row in reference["cases"] if row["name"] == name) + hashes = {} + with ZipFile(archive) as bundle: + assert bundle.testzip() is None + assert len(bundle.namelist()) == len(set(bundle.namelist())) + assert set(bundle.namelist()) == set(expected["zip_files"]) + for filename in bundle.namelist(): + content = bundle.read(filename) + assert sha256(content).hexdigest() == expected["artifact_hashes"][filename], (name, filename) + assert content == (work / "output" / filename).read_bytes(), (name, filename) + hashes[filename] = sha256(content).hexdigest() + previews = page.get_by_role("feed", name="Cells").get_by_role("img") + assert previews.count() == expected["pages"] + for number in range(expected["pages"]): + preview = previews.nth(number) + preview.scroll_into_view_if_needed() + image = preview.evaluate("img => ({complete: img.complete, width: img.naturalWidth, src: img.src})") + assert image["complete"] and image["width"] > 0 + assert image["src"].startswith("data:image/png;base64,") + assert sha256(base64.b64decode(image["src"].split(",", 1)[1])).hexdigest() == hashes[f"order-page-{number + 1}.png"] + preview.screenshot(path=str(destination / f"visible-preview-{number + 1}.png")) + link.scroll_into_view_if_needed() + page.screenshot(path=str(destination / "notebook-download.png")) + snapshot = page.locator("body").aria_snapshot().replace(token, "[redacted]") + (destination / "notebook.txt").write_text(snapshot, encoding="utf-8") + report = dict(name=name, pages=expected["pages"], actual_browser_download=True, + filename=download.suggested_filename, href_path=href_path, + zip_sha256=sha256(archive.read_bytes()).hexdigest(), artifact_hashes=hashes, + matches_independent_reference=True, visible_previews_match_download=True) + write(destination / "verification.json", report) + reports.append(report) + print(json.dumps(dict(case=name, pages=expected["pages"], browser_download=True)), flush=True) + assert (work / "output/order-page-4.png").exists() + assert "order-page-2.png" not in reports[-1]["artifact_hashes"] + return reports + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--reference", type=Path, required=True) + parser.add_argument("--out", type=Path, required=True) + parser.add_argument("--browser", choices=("chrome", "chromium"), default="chrome") + args = parser.parse_args() + out = args.out.resolve() + assert not out.exists(), "Use a new output directory to retain earlier evidence" + reference = json.loads((args.reference / "verification.json").read_text(encoding="utf-8")) + source_path = ROOT / reference["notebook"] + assert reference["ok"] and reference["install_cell_executed"] + assert reference["notebook"] == "examples/component-notebook-alpha6-r1/bootstrap_components.ipynb" + assert sha256(source_path.read_bytes()).hexdigest() == reference["notebook_sha256"] + source = json.loads(source_path.read_text(encoding="utf-8")) + assert all(not c["outputs"] and c["execution_count"] is None for c in source["cells"] if c["cell_type"] == "code") + out.mkdir(parents=True) + work = out / "work" + work.mkdir() + shutil.copyfile(source_path, work / source_path.name) + environment = {k: v for k, v in os.environ.items() if not k.startswith(("JUPYTER_", "IPYTHON"))} + token = secrets.token_urlsafe(32) + environment.update(JUPYTER_TOKEN=token, PYTHONUTF8="1") + with tempfile.TemporaryDirectory(prefix="fullbleed-notebook-browser-") as private: + private = Path(private) + for key, name in (("JUPYTER_CONFIG_DIR", "config"), ("JUPYTER_DATA_DIR", "data"), + ("JUPYTER_RUNTIME_DIR", "runtime"), ("IPYTHONDIR", "ipython")): + (private / name).mkdir() + environment[key] = str(private / name) + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + base = f"http://127.0.0.1:{port}" + with (private / "server.log").open("w", encoding="utf-8") as log: + server = subprocess.Popen([sys.executable, "-m", "notebook", "--no-browser", "--ServerApp.ip=127.0.0.1", + f"--ServerApp.port={port}", "--ServerApp.port_retries=0", f"--ServerApp.root_dir={work}", + "--ServerApp.shutdown_no_activity_timeout=600"], cwd=work, env=environment, + stdout=log, stderr=subprocess.STDOUT, + creationflags=subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0) + try: + deadline = time.monotonic() + 60 + while True: + assert server.poll() is None, "The private Jupyter server exited" + try: + request = urllib.request.Request(base + "/api/status", headers={"Authorization": "token " + token}) + with urllib.request.urlopen(request, timeout=2) as response: + assert response.status == 200 + break + except (urllib.error.URLError, TimeoutError): + if time.monotonic() >= deadline: + raise RuntimeError("Private Jupyter server did not become ready") from None + time.sleep(0.2) + with sync_playwright() as p: + options = {"channel": "chrome"} if args.browser == "chrome" else {} + browser = p.chromium.launch(**options) + try: + context = browser.new_context(viewport={"width": 1440, "height": 1000}, accept_downloads=True) + page = context.new_page() + page.goto(base + "/tree?token=" + token, wait_until="domcontentloaded") + page.goto(base + "/notebooks/" + source_path.name, wait_until="domcontentloaded") + cases = check_browser(page, context, source, work, out, reference, token) + browser_version = browser.version + finally: + browser.close() + finally: + if server.poll() is None: + try: + request = urllib.request.Request(base + "/api/shutdown", data=b"", method="POST", + headers={"Authorization": "token " + token}) + with urllib.request.urlopen(request, timeout=10): + pass + server.wait(timeout=30) + except (urllib.error.URLError, TimeoutError, subprocess.TimeoutExpired): + server.terminate() + server.wait(timeout=15) + write(out / "server-stopped.json", dict(stopped=server.poll() is not None, returncode=server.returncode)) + log.flush() + sanitized = (private / "server.log").read_text(encoding="utf-8", errors="replace").replace(token, "[redacted]") + (out / "server.log").write_text(sanitized, encoding="utf-8") + report = dict(ok=True, checked_at=datetime.now(timezone.utc).isoformat(), + notebook_sha256=reference["notebook_sha256"], runtime_version=importlib.metadata.version("fullbleed"), + system=platform.system(), python=platform.python_version(), browser=browser_version, + tools={name: importlib.metadata.version(name) for name in ("notebook", "jupyter_server", "ipykernel", "playwright")}, + cases=cases, stale_previews_excluded=True, + scope="Local Jupyter Notebook editing, execution, displayed previews and actual browser ZIP downloads. Hosted Colab is not tested.") + assert report["runtime_version"] == reference["runtime_version"] + write(out / "verification.json", report) + print(json.dumps(dict(ok=True, cases=len(cases), pages=sum(c["pages"] for c in cases)))) + + +if __name__ == "__main__": + main() diff --git a/tools/check_component_notebook_ui.py b/tools/check_component_notebook_ui.py index c9abba2..5ae19a7 100644 --- a/tools/check_component_notebook_ui.py +++ b/tools/check_component_notebook_ui.py @@ -20,7 +20,7 @@ def main(): args = parser.parse_args() root = args.site.resolve() args.out.mkdir(parents=True, exist_ok=True) - folder = "component-notebook-alpha6" + folder = "component-notebook-alpha6-r1" assets = root / "assets" / folder manifest = json.loads((assets / "verification.json").read_text(encoding="utf-8")) @@ -53,7 +53,8 @@ def log_message(self, *a): assert page.get_by_role("heading", name="Try Python components in a notebook", exact=False).is_visible() article = page.locator("article").inner_text() for value in ("Fullbleed 2.6.0a6", "fullbleed.ui.experimental", "no Bootstrap JavaScript", - "class_name", "Document(styles=...)", "Strong", "72-row", "four pages"): + "class_name", "Document(styles=...)", "Strong", "72-row", "four pages", + "Run in local Jupyter", "Run All Cells", "component-order.zip"): assert value in article, value colab = page.get_by_role("link", name="Open in Colab", exact=True).get_attribute("href") source = page.get_by_role("link", name="View the source", exact=True).get_attribute("href") @@ -83,11 +84,14 @@ def log_message(self, *a): page.screenshot(path=str(args.out / f"{label}-preview.png"), animations="disabled") downloads = [] for filename, title in (("order.pdf", "Open the sample PDF"), ("order.styled.html", "Open styled HTML"), - ("order.unstyled.html", "Open unstyled HTML"), ("verification.json", "verification record")): + ("order.unstyled.html", "Open unstyled HTML"), ("verification.json", "verification record"), + ("browser-verification.json", "Jupyter browser record")): link = page.get_by_role("link", name=title, exact=True) response = context.request.get(link.evaluate("a => a.href")) assert response.status == 200, filename assert response.body() == (assets / filename).read_bytes(), filename + if filename == "browser-verification.json": + assert sha256(response.body()).hexdigest() == manifest["browser_verification"]["sha256"] downloads.append({"file": filename, "sha256": sha256(response.body()).hexdigest()}) for filename in ("order.styled.html", "order.unstyled.html"): response = page.goto(base + "/assets/" + folder + "/" + filename, wait_until="networkidle") diff --git a/tools/component-notebook-browser-requirements.txt b/tools/component-notebook-browser-requirements.txt new file mode 100644 index 0000000..29910ec --- /dev/null +++ b/tools/component-notebook-browser-requirements.txt @@ -0,0 +1,3 @@ +-r component-notebook-requirements.txt +notebook==7.6.3 +playwright==1.49.1 diff --git a/tools/verify_component_notebook.py b/tools/verify_component_notebook.py index e2ec547..1fb0fac 100644 --- a/tools/verify_component_notebook.py +++ b/tools/verify_component_notebook.py @@ -28,6 +28,7 @@ "alpha3": ("2.6.0a3", "component-notebook"), "alpha5": ("2.6.0a5", "component-notebook-alpha5"), "alpha6": ("2.6.0a6", "component-notebook-alpha6"), + "alpha6-r1": ("2.6.0a6", "component-notebook-alpha6-r1"), } @@ -235,7 +236,7 @@ def main(): edited.update(number="1051", customer="Moss & Fern ", status="Packed") edited["items"][0]["quantity"] = 32 edited["items"][1]["item"] = "Personalized note cards" - if args.channel in {"alpha5", "alpha6"}: + if args.channel in {"alpha5", "alpha6", "alpha6-r1"}: edited["items"][0]["item"] = "Studio & cards" long = deepcopy(default) long["items"] = [{"item": f"Edition {n:02d} / art print", "quantity": 1, "unit_price": "9.50"} @@ -266,7 +267,7 @@ def main(): destination = out / name destination.mkdir() execute(notebook, work, destination, kernel_dir) - report = verify(work, destination, name, order, accent, version, args.channel in {"alpha5", "alpha6"}) + report = verify(work, destination, name, order, accent, version, args.channel in {"alpha5", "alpha6", "alpha6-r1"}) reports.append(report) print(json.dumps({"case": name, "pages": report["pages"], "ok": True}), flush=True) assert len({r["pdf_sha256"] for r in reports}) == len(reports)