diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 132c6dc..e8cd5dc 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -112,6 +112,31 @@ jobs: name: component-notebook-${{ matrix.channel }}-${{ matrix.os }} path: target/component-notebook/ retention-days: 14 + page-band-notebook: + name: Page-band notebook (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + - run: | + python -m pip install --only-binary=:all: -r tools/component-notebook-browser-requirements.txt + - name: Execute the page-band notebook and inspect PDF, HTML and ZIP files + run: python -I tools/verify_page_band_notebook.py --notebook examples/page-band-notebook-alpha9/component_page_bands.ipynb --out target/page-band-notebook + - name: Edit, run and download the page-band notebook in Jupyter + run: python -I tools/check_page_band_notebook_jupyter.py --reference target/page-band-notebook --out target/page-band-notebook/browser --browser chrome + - uses: actions/upload-artifact@v4 + if: always() + with: + name: page-band-notebook-${{ matrix.os }} + path: target/page-band-notebook/ + retention-days: 14 complete-html: name: Complete HTML guide and download (${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -1006,7 +1031,7 @@ jobs: path: site deploy: if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - needs: [build, accessible-showcase, showcase-authoring, component-notebook, complete-html, component-preview, bootstrap-preview, components, node-components, dotnet-components, aspnet, chinese, arabic, markdown, tagged-notice, rust-download, matplotlib, numbered-report, axum, lambda-starter, n8n-workflow, laravel-queue, laravel-platform, streamlit] + needs: [build, accessible-showcase, showcase-authoring, component-notebook, page-band-notebook, complete-html, component-preview, bootstrap-preview, components, node-components, dotnet-components, aspnet, chinese, arabic, markdown, tagged-notice, rust-download, matplotlib, numbered-report, axum, lambda-starter, n8n-workflow, laravel-queue, laravel-platform, streamlit] runs-on: ubuntu-latest permissions: pages: write diff --git a/docs/assets/component-preview-9/field-notes-1.png b/docs/assets/component-preview-9/field-notes-1.png new file mode 100644 index 0000000..6f0fefd Binary files /dev/null and b/docs/assets/component-preview-9/field-notes-1.png differ diff --git a/docs/assets/component-preview-9/field-notes-2.png b/docs/assets/component-preview-9/field-notes-2.png new file mode 100644 index 0000000..ed0724c Binary files /dev/null and b/docs/assets/component-preview-9/field-notes-2.png differ diff --git a/docs/assets/component-preview-9/field-notes-3.png b/docs/assets/component-preview-9/field-notes-3.png new file mode 100644 index 0000000..f46985a Binary files /dev/null and b/docs/assets/component-preview-9/field-notes-3.png differ diff --git a/docs/assets/page-band-notebook-alpha9/browser-verification.json b/docs/assets/page-band-notebook-alpha9/browser-verification.json new file mode 100644 index 0000000..f166cf6 --- /dev/null +++ b/docs/assets/page-band-notebook-alpha9/browser-verification.json @@ -0,0 +1,100 @@ +{ + "ok": true, + "checked_at": "2026-10-19T21:30:21.380252+00:00", + "notebook_sha256": "3c32e97d7e91196921e0e276f5185a7a9a93f89323d881989af691cf4260bb28", + "runtime_version": "2.6.0a9", + "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": 3, + "actual_browser_download": true, + "filename": "component-field-notes.zip", + "href_path": "/files/output/component-field-notes.zip", + "zip_sha256": "2646aba82de3c4c9307a9e4d76c9c740b9c575b8e72e20106cada36b56658f79", + "artifact_hashes": { + "field-notes.pdf": "eb8f7c2b4fb5e123feb46a99e0521a7ab04181c0847c675076fe759e2d4bbf94", + "field-notes.styled.html": "215153772a49e7e6ec7b4a1e34a20ffbc47ced7bf4c3ad1a3f6d95fadd27293e", + "field-notes.unstyled.html": "7fc4b0bef4fe94eea9e54230db9bf68a17e1c1049ff8c86b23013ac6baa688ac", + "field-notes-page-1.png": "626217670103c45188dd3c1cf96e2a82ef2add3b1bb00baa6dc19fb150eebea9", + "field-notes-page-2.png": "4da3c325f07434b01c455b3231e2c63de607a2b7db87a6b09f75a561b92eb925", + "field-notes-page-3.png": "b3ddbd6538cf7e9f7109ca8b185dcc56f4e778808ae38ea9a18582628a432a16" + }, + "matches_independent_reference": true, + "visible_previews_match_download": true + }, + { + "name": "edited", + "pages": 3, + "actual_browser_download": true, + "filename": "component-field-notes.zip", + "href_path": "/files/output/component-field-notes.zip", + "zip_sha256": "892e87661f5c98b54986afff8a7aebd0c22c69efc2f74be77d541a85349b1fc5", + "artifact_hashes": { + "field-notes.pdf": "28cfb6ec8f9f92ac9d4e066e7a24dd1d036b90756ddd4f7fc87af8fd0793f701", + "field-notes.styled.html": "5d659c401b1b3be0d64c830a9ab6b6e30e4ffd48c2ef2b7606d92edf8f331979", + "field-notes.unstyled.html": "2dd7badcfd1b2cbe03523d15724b839365c71087bc694f9adbd599c172f1a486", + "field-notes-page-1.png": "5d9a4dfbfdb9d42440a4e26a374063ffe2dee56b373e7ad0cfff659d77485722", + "field-notes-page-2.png": "ab45145ded5f5f6d5f3ad5694caa88e3c06e585d677ea48e47b617bfcd95260f", + "field-notes-page-3.png": "cee354802353e0eaa452923658f33937ed5e7ea858e178c3d738f0f39609b09d" + }, + "matches_independent_reference": true, + "visible_previews_match_download": true + }, + { + "name": "long", + "pages": 8, + "actual_browser_download": true, + "filename": "component-field-notes.zip", + "href_path": "/files/output/component-field-notes.zip", + "zip_sha256": "b92d585634678ef83da87d4aab80439c23ff32e304d4c3be904b3b10b9021a3f", + "artifact_hashes": { + "field-notes.pdf": "acd62ae55466277dff26a25c79d151cc2e641bfbbb73f93ab3f74fbbbd45f46c", + "field-notes.styled.html": "eb0cc12991e953f5b818c194cffdb50a05e2f133e93cfa86f11417bf9f17c87b", + "field-notes.unstyled.html": "b0e68ee4983b4cbe3e791654857742bb72e1b5fc2f3e4eaef0766b9235adbd62", + "field-notes-page-1.png": "74c2e71cc5c4d32171b5397739d5e4e1da91fbfa15155ff1b4dc8a839cc21c82", + "field-notes-page-2.png": "5097d31cc63715cb87f25ddcffe84856cdc328cb0ef950f4792dea4a441d9afe", + "field-notes-page-3.png": "79652b15c7a888337c23562cbe0fc65263d921aae45e7755b91e86b309cc2a3e", + "field-notes-page-4.png": "b97c3eb1a6cd74f043f86f47cd004ecccc738f36913811ba9c1ea4671decc014", + "field-notes-page-5.png": "3e86dd8de2ea14e183b2bdb2aa929ca013403ed4433b1a907cc59b4e009e014a", + "field-notes-page-6.png": "7ded0703778250a4ae2b4eedb4bc9d27c59358854f5536cf6b9938f9fa2567ff", + "field-notes-page-7.png": "ce4d9ad2c5c676197f766be0952f833d219983aeb1bf93e33464e93f93fa15dd", + "field-notes-page-8.png": "50a71a7b28960ffc5aca3707fe4f265b065315b9dd2eab4bb9feeda73b9a6f42" + }, + "matches_independent_reference": true, + "visible_previews_match_download": true + }, + { + "name": "empty-after-long", + "pages": 3, + "actual_browser_download": true, + "filename": "component-field-notes.zip", + "href_path": "/files/output/component-field-notes.zip", + "zip_sha256": "e9d5eae54f388dbb92d4f21c82ee16c4d8779dd671eb24adeb2981526781bb4e", + "artifact_hashes": { + "field-notes.pdf": "114f2076f53ef9e30704b94551c8c8ff6aab8ed56c3d2cb1f6e09e662897b942", + "field-notes.styled.html": "79ed8d981685fdfae2ec3b6af66fcd4d0ec11da1395bf4bba2fa03cefe127d2c", + "field-notes.unstyled.html": "3b93493642feb1c4770622561d0ccf1747edb9134d8e2a25a5246a2dd8b35604", + "field-notes-page-1.png": "626217670103c45188dd3c1cf96e2a82ef2add3b1bb00baa6dc19fb150eebea9", + "field-notes-page-2.png": "68578aef1e42fbe276c7225322ffd4710398456e5e13c42994b145936680e599", + "field-notes-page-3.png": "b3ddbd6538cf7e9f7109ca8b185dcc56f4e778808ae38ea9a18582628a432a16" + }, + "matches_independent_reference": true, + "visible_previews_match_download": true + } + ], + "stale_previews_excluded": true, + "local_install_override": false, + "scope": "Local Jupyter Notebook editing, execution, finalized counters, displayed previews and actual browser ZIP downloads. The receipt identifies any local-wheel install override. Hosted Colab is not tested.", + "source_commit": "982009ce6eb43d0af7d4475bb75df7fe70478784", + "source_path": "examples/page-band-notebook-alpha9/component_page_bands.ipynb", + "publication_status": "Verified with the public Fullbleed 2.6.0a9 wheel; hosted Colab execution is not claimed." +} diff --git a/docs/assets/page-band-notebook-alpha9/component_page_bands.ipynb b/docs/assets/page-band-notebook-alpha9/component_page_bands.ipynb new file mode 100644 index 0000000..1d89aec --- /dev/null +++ b/docs/assets/page-band-notebook-alpha9/component_page_bands.ipynb @@ -0,0 +1,386 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# Your design, on every page\n", + "\n", + "Build a three-page Common Ground report with reusable Python components, a\n", + "styled header, a data table, and finalized page numbers. Edit the values below\n", + "and rerun to make the design your own. No HTML strings are needed for authoring.\n", + "\n", + "This notebook is an opt-in **Fullbleed 2.6.0a9** example. The component API is\n", + "experimental. All content is fictional. Fullbleed and this notebook are MIT licensed.\n", + "\n", + "Use a standard CPU runtime in Colab or a local Jupyter notebook. Run all cells,\n", + "review the page previews, and download your files before leaving the session." + ] + }, + { + "cell_type": "markdown", + "id": "install-help", + "metadata": {}, + "source": [ + "## 1. Install the pinned preview\n", + "\n", + "Fullbleed generates the PDF without a browser or system fonts. The notebook host supplies its display and download interface." + ] + }, + { + "cell_type": "code", + "id": "install-fullbleed", + "metadata": {}, + "source": [ + "%pip install --quiet --only-binary=:all: \"fullbleed==2.6.0a9\"" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "controls-help", + "metadata": {}, + "source": [ + "## 2. Make it yours\n", + "\n", + "Start with the name, headline, accent color, and activity rows. All text values\n", + "remain text, including `<` and `&`. After an edit, rerun this cell and every cell\n", + "below it. Add activity rows to see the page total change after pagination." + ] + }, + { + "cell_type": "code", + "id": "report-controls", + "metadata": {}, + "source": [ + "PROJECT_NAME = \"Common Ground\"\n", + "HEADLINE = \"A season of shared space.\"\n", + "ACCENT = \"#755122\"\n", + "ACTIVITY = [\n", + " (\"Garden mornings\", \"12\", \"84 visits\"),\n", + " (\"Repair afternoons\", \"4\", \"31 items repaired\"),\n", + " (\"Shared meals\", \"3\", \"96 places set\"),\n", + " (\"Seed exchanges\", \"2\", \"47 packets shared\"),\n", + " (\"Welcome walks\", \"6\", \"28 neighbors\"),\n", + "]" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "styles-help", + "metadata": {}, + "source": [ + "## 3. Give the components a shared style\n", + "\n", + "`Theme` supplies inherited text and named colors. `styled(...)` creates reusable\n", + "defaults. `class_name=\"folio\"` below adds a literal class; the `.folio` selector\n", + "in `Document(styles=...)` defines how the page counter looks." + ] + }, + { + "cell_type": "code", + "id": "shared-styles", + "metadata": {}, + "source": [ + "# SPDX-License-Identifier: MIT\n", + "\"\"\"A fictional seasonal brief built from reusable component values.\"\"\"\n", + "from fullbleed.ui.experimental import (\n", + " Box, Column, DataTable, Document, Flow, Heading, Page, PageBand, PageBreak,\n", + " PageNumbers, Paragraph, Row, Stack, Strong, Theme, component, mm, pt, style,\n", + " styled, token,\n", + ")\n", + "\n", + "\n", + "THEME = Theme(\n", + " {\"ink\": \"#183e34\", \"muted\": \"#53665c\", \"paper\": \"#edf2e6\", \"accent\": ACCENT},\n", + " text=style(font_family=\"Inter\", font_size=pt(10), line_height=1.45, color=token(\"ink\")),\n", + ")\n", + "Kicker = styled(Paragraph, font_size=pt(7), font_weight=700, letter_spacing=pt(1),\n", + " text_transform=\"uppercase\", color=token(\"accent\"))\n", + "Title = styled(Heading, font_size=pt(31), line_height=1.08, letter_spacing=pt(-0.8))\n", + "Small = styled(Paragraph, font_size=pt(8), color=token(\"muted\"))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "components-help", + "metadata": {}, + "source": [ + "## 4. Compose reusable pieces\n", + "\n", + "`Brand`, `Metric`, and `NextStep` return ordinary component trees. Named `part`\n", + "values let a caller use `.with_parts(...)` to customize an internal element.\n", + "The same `Brand()` value can be used in a page band or in document content." + ] + }, + { + "cell_type": "code", + "id": "reusable-components", + "metadata": {}, + "source": [ + "@component(parts=(\"name\", \"detail\"))\n", + "def Brand():\n", + " return Stack(\n", + " Paragraph(PROJECT_NAME.upper(), part=\"name\", style=style(font_size=pt(8), font_weight=700, letter_spacing=pt(0.8))),\n", + " Paragraph(\"A neighborhood, growing together.\", part=\"detail\", style=style(font_size=pt(6), color=token(\"muted\"))),\n", + " gap=pt(2),\n", + " )\n", + "\n", + "\n", + "@component(parts=(\"value\", \"label\"))\n", + "def Metric(value, label):\n", + " return Stack(\n", + " Paragraph(value, part=\"value\", style=style(font_size=pt(32), font_weight=700, line_height=1.1)),\n", + " Small(label, part=\"label\"), gap=pt(5),\n", + " )\n", + "\n", + "\n", + "@component(parts=(\"heading\", \"body\"))\n", + "def NextStep(number, title, text):\n", + " return Stack(\n", + " Kicker(number),\n", + " Heading(title, level=3, part=\"heading\", style=style(font_size=pt(16))),\n", + " Paragraph(text, part=\"body\"), gap=pt(6),\n", + " )" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "document-help", + "metadata": {}, + "source": [ + "## 5. Put the report together\n", + "\n", + "`PageBand` places components in left, center, or right margin slots.\n", + "`PageNumbers(total=True)` uses final page counts. Page margins must leave enough\n", + "space for the bands; a fit error identifies the page, slot, and authored source.\n", + "\n", + "The bands repeat as PDF artifacts outside body reading order. Essential identity\n", + "and report information also belong in the body.", + "\n\nThe short lesson box uses `break_inside=\"avoid\"` to keep its heading and body\ntogether when a longer activity table pushes it to the next page." + ] + }, + { + "cell_type": "code", + "id": "compose-document", + "metadata": {}, + "source": [ + "def build_document():\n", + " activity = ACTIVITY\n", + " return Document(\n", + " Flow(\n", + " Kicker(\"Field notes / Autumn 2026\"),\n", + " Title(HEADLINE, level=1),\n", + " Paragraph(\"A small record of what happens when neighbors have a place to meet, make, and lend a hand.\"),\n", + " Box(\n", + " Row(Metric(\"126\", \"neighbors took part\"), Metric(\"21\", \"open sessions\"), gap=pt(18)),\n", + " style=style(background=token(\"paper\"), padding=pt(18)),\n", + " ),\n", + " Heading(\"The place is the invitation\", level=2, style=style(font_size=pt(17))),\n", + " Paragraph(\"We kept the gate open, the table long, and the activities simple. New faces became familiar ones. Practical work made room for conversation.\"),\n", + " Small(f\"{PROJECT_NAME} is a fictional community project. Every figure in this brief is sample data.\"),\n", + " gap=pt(15),\n", + " ),\n", + " PageBreak(),\n", + " Flow(\n", + " Kicker(\"01 / What we made possible\"),\n", + " Title(\"Small actions. Shared results.\", level=2),\n", + " Paragraph(\"The busiest sessions mixed a useful task with time to stay. No specialist knowledge was needed to join in.\"),\n", + " DataTable(\n", + " activity, caption=\"Season activity\", columns=(\n", + " Column(\"Activity\", lambda row: row[0]),\n", + " Column(\"Sessions\", lambda row: row[1], align=\"right\", width=mm(18)),\n", + " Column(\"Participation\", lambda row: row[2], width=mm(37)),\n", + " ),\n", + " ).with_parts(\n", + " caption=style(font_size=pt(9), font_weight=700, margin_bottom=pt(7)),\n", + " header=style(background=token(\"paper\"), font_size=pt(8), color=token(\"ink\")),\n", + " cell=style(font_size=pt(8), padding=pt(8)),\n", + " ),\n", + " Box(\n", + " Kicker(\"A useful lesson\"),\n", + " Paragraph(Strong(\"Make the first step visible. \"), \"A welcome at the gate, a clearly marked tool bench, and a task that takes ten minutes helped people begin.\"),\n", + " style=style(background=token(\"paper\"), padding=pt(15), break_inside=\"avoid\"),\n", + " ),\n", + " Small(\"Visits and participation counts can include the same neighbor more than once. They describe activity, not unique attendance.\"),\n", + " gap=pt(12),\n", + " ),\n", + " PageBreak(),\n", + " Flow(\n", + " Kicker(\"02 / What comes next\"),\n", + " Title(\"Keep the welcome growing.\", level=2),\n", + " Paragraph(\"Three modest commitments will guide the next season. Each has a clear owner and a way for someone new to help.\"),\n", + " NextStep(\"01\", \"Make room at the table\", \"Reserve a few places at every shared meal for neighbors joining for the first time. Publish the menu and access information together.\"),\n", + " NextStep(\"02\", \"Pass on a practical skill\", \"Pair each repair session with a short demonstration. Keep a simple printed guide beside the tools for people who prefer to follow at their own pace.\"),\n", + " NextStep(\"03\", \"Listen before adding more\", \"Ask what made participation easy and what got in the way. Use those answers to choose the next opening hours and activities.\"),\n", + " Small(f\"Prepared by {PROJECT_NAME} / October 2026. This sample is authored with Fullbleed components, including its repeated header and finalized page numbers.\"),\n", + " gap=pt(15),\n", + " ),\n", + " title=f\"{PROJECT_NAME} — Field notes\", lang=\"en-US\", theme=THEME,\n", + " page=Page(\n", + " \"A5\", margin=(mm(20), mm(17), mm(20), mm(17)),\n", + " header=PageBand(left=Brand(), right=Kicker(\"Field notes / 03\").with_style(font_size=pt(6))),\n", + " footer=PageBand(\n", + " left=Small(\"AUTUMN 2026 · SAMPLE DATA\").with_style(font_size=pt(6)),\n", + " right=PageNumbers(total=True, prefix=\"\", separator=\" / \", class_name=\"folio\"),\n", + " ),\n", + " ),\n", + " styles={\".folio\": style(font_size=pt(8), color=token(\"muted\"))},\n", + " )\n", + "\n", + "\n", + "document = build_document()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "render-help", + "metadata": {}, + "source": [ + "## 6. Render PDF and export HTML\n", + "\n", + "The bundled Inter font is registered explicitly for PDF generation. Styled HTML\n", + "keeps its font-family declaration; a browser host supplies Inter or a fallback.\n", + "Unstyled HTML keeps content and classes while removing generated and inline styles.\n", + "Both exports contain one header/main/footer sequence and explicit `[page]` /\n", + "`[pages]` placeholders. They do not add JavaScript or browser pagination.\n", + "\n", + "The PDF/UA-2 profile and source checks express authoring intent. Review and\n", + "validate your own output; this notebook does not establish arbitrary-document\n", + "accessibility or replace human assistive-technology testing." + ] + }, + { + "cell_type": "code", + "id": "render-export", + "metadata": {}, + "source": [ + "from importlib import metadata, resources\n", + "from pathlib import Path\n", + "from fullbleed.ui.experimental import Renderer\n", + "\n", + "assert metadata.version(\"fullbleed\") == \"2.6.0a9\"\n", + "output = Path(\"output\")\n", + "output.mkdir(exist_ok=True)\n", + "font = resources.files(\"fullbleed_assets\").joinpath(\"fonts/Inter-Variable.ttf\")\n", + "renderer = Renderer(\n", + " font_files=[str(font)], pdf_profile=\"pdfua2\",\n", + " document_timestamp=\"2026-10-19T00:00:00Z\",\n", + ")\n", + "prepared = document.prepare(renderer, a11y_mode=\"raise\")\n", + "pdf_path = output / \"field-notes.pdf\"\n", + "pdf_path.write_bytes(prepared.render_pdf())\n", + "for include_styles, filename in (\n", + " (True, \"field-notes.styled.html\"),\n", + " (False, \"field-notes.unstyled.html\"),\n", + "):\n", + " (output / filename).write_text(\n", + " document.to_html(include_styles=include_styles, a11y_mode=\"raise\"),\n", + " encoding=\"utf-8\", newline=\"\\n\",\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\"field-notes-page-{number}.png\"\n", + " path.write_bytes(png)\n", + " preview_paths.append(path)\n", + " display(Image(data=png, width=540))\n", + "print(f\"{len(preview_paths)} pages. Review every page before using the document.\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "download-help", + "metadata": {}, + "source": [ + "## 7. Download the current result\n", + "\n", + "The ZIP contains the PDF, both HTML exports, and this run's page previews. In\n", + "Colab the next cell starts a download; local Jupyter shows a download link.\n", + "The individual files also remain in `output/`. Shorter reruns exclude old,\n", + "longer-run previews from the ZIP." + ] + }, + { + "cell_type": "code", + "id": "download", + "metadata": {}, + "source": [ + "import sys\n", + "from zipfile import ZipFile, ZIP_DEFLATED\n", + "\n", + "bundle_path = output / \"component-field-notes.zip\"\n", + "current_files = [pdf_path, output / \"field-notes.styled.html\",\n", + " output / \"field-notes.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", + "- [Page-band guide and complete source kit](https://docs.fullbleed.dev/guides/component-preview-9/)\n", + "- [API, fit diagnostics, and limitations](https://github.com/fullbleed-engine/fullbleed-official/blob/v2.6.0a9/docs/component-page-bands.md)\n", + "- [Northstar and Common Ground through both authoring paths](https://docs.fullbleed.dev/guides/showcase-authoring/)\n", + "- [Share a small authoring example](https://github.com/fullbleed-engine/fullbleed-official/discussions/86)\n", + "\n", + "Native streaming output is not atomic. Managed fit checks are conservative.\n", + "Named or facing-page band variants are not part of this preview. Bootstrap\n", + "components and HTML exports remain static." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + }, + "colab": { + "name": "component_page_bands.ipynb", + "provenance": [] + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/assets/page-band-notebook-alpha9/verification.json b/docs/assets/page-band-notebook-alpha9/verification.json new file mode 100644 index 0000000..49bb23a --- /dev/null +++ b/docs/assets/page-band-notebook-alpha9/verification.json @@ -0,0 +1,691 @@ +{ + "ok": true, + "notebook": "examples/page-band-notebook-alpha9/component_page_bands.ipynb", + "notebook_sha256": "3c32e97d7e91196921e0e276f5185a7a9a93f89323d881989af691cf4260bb28", + "runtime_version": "2.6.0a9", + "install_cell_executed": true, + "public_install_cell": true, + "local_install_override": false, + "cases": [ + { + "name": "default", + "pages": 3, + "row_count": 5, + "controls": { + "PROJECT_NAME": "Common Ground", + "HEADLINE": "A season of shared space.", + "ACCENT": "#755122", + "ACTIVITY": [ + [ + "Garden mornings", + "12", + "84 visits" + ], + [ + "Repair afternoons", + "4", + "31 items repaired" + ], + [ + "Shared meals", + "3", + "96 places set" + ], + [ + "Seed exchanges", + "2", + "47 packets shared" + ], + [ + "Welcome walks", + "6", + "28 neighbors" + ] + ] + }, + "pdf_sha256": "eb8f7c2b4fb5e123feb46a99e0521a7ab04181c0847c675076fe759e2d4bbf94", + "text_markers": [ + "Common Ground", + "A season of shared space.", + "Keep the welcome growing.", + "Garden mornings", + "Repair afternoons", + "Shared meals", + "Seed exchanges", + "Welcome walks" + ], + "font_count": 1, + "accent": "#755122", + "accent_pixels": 2138, + "independent_char_bounds_checked": 1807, + "repeat_pdf_identical": true, + "direct_pdf_identical": true, + "zip_files": [ + "field-notes.pdf", + "field-notes.styled.html", + "field-notes.unstyled.html", + "field-notes-page-1.png", + "field-notes-page-2.png", + "field-notes-page-3.png" + ], + "styled_and_unstyled_html": true, + "finalized_page_counts": true, + "callout_kept_together": true, + "artifact_hashes": { + "field-notes.pdf": "eb8f7c2b4fb5e123feb46a99e0521a7ab04181c0847c675076fe759e2d4bbf94", + "field-notes.styled.html": "215153772a49e7e6ec7b4a1e34a20ffbc47ced7bf4c3ad1a3f6d95fadd27293e", + "field-notes.unstyled.html": "7fc4b0bef4fe94eea9e54230db9bf68a17e1c1049ff8c86b23013ac6baa688ac", + "field-notes-page-1.png": "626217670103c45188dd3c1cf96e2a82ef2add3b1bb00baa6dc19fb150eebea9", + "field-notes-page-2.png": "4da3c325f07434b01c455b3231e2c63de607a2b7db87a6b09f75a561b92eb925", + "field-notes-page-3.png": "b3ddbd6538cf7e9f7109ca8b185dcc56f4e778808ae38ea9a18582628a432a16" + }, + "qualified_field_notes_pdf_reproduced": true + }, + { + "name": "edited", + "pages": 3, + "row_count": 5, + "controls": { + "PROJECT_NAME": "River ", + "HEADLINE": "A place to meet & make.", + "ACCENT": "#763c96", + "ACTIVITY": [ + [ + "Studio & cards", + "16", + "89 visits" + ], + [ + "Repair afternoons", + "4", + "31 items repaired" + ], + [ + "Shared meals", + "3", + "96 places set" + ], + [ + "Seed exchanges", + "2", + "47 packets shared" + ], + [ + "Welcome walks", + "6", + "28 neighbors" + ] + ] + }, + "pdf_sha256": "28cfb6ec8f9f92ac9d4e066e7a24dd1d036b90756ddd4f7fc87af8fd0793f701", + "text_markers": [ + "River ", + "A place to meet & make.", + "Keep the welcome growing.", + "Studio & cards", + "Repair afternoons", + "Shared meals", + "Seed exchanges", + "Welcome walks" + ], + "font_count": 1, + "accent": "#763c96", + "accent_pixels": 2141, + "independent_char_bounds_checked": 1818, + "repeat_pdf_identical": true, + "direct_pdf_identical": true, + "zip_files": [ + "field-notes.pdf", + "field-notes.styled.html", + "field-notes.unstyled.html", + "field-notes-page-1.png", + "field-notes-page-2.png", + "field-notes-page-3.png" + ], + "styled_and_unstyled_html": true, + "finalized_page_counts": true, + "callout_kept_together": true, + "artifact_hashes": { + "field-notes.pdf": "28cfb6ec8f9f92ac9d4e066e7a24dd1d036b90756ddd4f7fc87af8fd0793f701", + "field-notes.styled.html": "5d659c401b1b3be0d64c830a9ab6b6e30e4ffd48c2ef2b7606d92edf8f331979", + "field-notes.unstyled.html": "2dd7badcfd1b2cbe03523d15724b839365c71087bc694f9adbd599c172f1a486", + "field-notes-page-1.png": "5d9a4dfbfdb9d42440a4e26a374063ffe2dee56b373e7ad0cfff659d77485722", + "field-notes-page-2.png": "ab45145ded5f5f6d5f3ad5694caa88e3c06e585d677ea48e47b617bfcd95260f", + "field-notes-page-3.png": "cee354802353e0eaa452923658f33937ed5e7ea858e178c3d738f0f39609b09d" + } + }, + { + "name": "long", + "pages": 8, + "row_count": 72, + "controls": { + "PROJECT_NAME": "Common Ground", + "HEADLINE": "A season of shared space.", + "ACCENT": "#755122", + "ACTIVITY": [ + [ + "Session 01", + "1", + "8 neighbors" + ], + [ + "Session 02", + "2", + "8 neighbors" + ], + [ + "Session 03", + "3", + "8 neighbors" + ], + [ + "Session 04", + "4", + "8 neighbors" + ], + [ + "Session 05", + "5", + "8 neighbors" + ], + [ + "Session 06", + "6", + "8 neighbors" + ], + [ + "Session 07", + "7", + "8 neighbors" + ], + [ + "Session 08", + "8", + "8 neighbors" + ], + [ + "Session 09", + "9", + "8 neighbors" + ], + [ + "Session 10", + "10", + "8 neighbors" + ], + [ + "Session 11", + "11", + "8 neighbors" + ], + [ + "Session 12", + "12", + "8 neighbors" + ], + [ + "Session 13", + "13", + "8 neighbors" + ], + [ + "Session 14", + "14", + "8 neighbors" + ], + [ + "Session 15", + "15", + "8 neighbors" + ], + [ + "Session 16", + "16", + "8 neighbors" + ], + [ + "Session 17", + "17", + "8 neighbors" + ], + [ + "Session 18", + "18", + "8 neighbors" + ], + [ + "Session 19", + "19", + "8 neighbors" + ], + [ + "Session 20", + "20", + "8 neighbors" + ], + [ + "Session 21", + "21", + "8 neighbors" + ], + [ + "Session 22", + "22", + "8 neighbors" + ], + [ + "Session 23", + "23", + "8 neighbors" + ], + [ + "Session 24", + "24", + "8 neighbors" + ], + [ + "Session 25", + "25", + "8 neighbors" + ], + [ + "Session 26", + "26", + "8 neighbors" + ], + [ + "Session 27", + "27", + "8 neighbors" + ], + [ + "Session 28", + "28", + "8 neighbors" + ], + [ + "Session 29", + "29", + "8 neighbors" + ], + [ + "Session 30", + "30", + "8 neighbors" + ], + [ + "Session 31", + "31", + "8 neighbors" + ], + [ + "Session 32", + "32", + "8 neighbors" + ], + [ + "Session 33", + "33", + "8 neighbors" + ], + [ + "Session 34", + "34", + "8 neighbors" + ], + [ + "Session 35", + "35", + "8 neighbors" + ], + [ + "Session 36", + "36", + "8 neighbors" + ], + [ + "Session 37", + "37", + "8 neighbors" + ], + [ + "Session 38", + "38", + "8 neighbors" + ], + [ + "Session 39", + "39", + "8 neighbors" + ], + [ + "Session 40", + "40", + "8 neighbors" + ], + [ + "Session 41", + "41", + "8 neighbors" + ], + [ + "Session 42", + "42", + "8 neighbors" + ], + [ + "Session 43", + "43", + "8 neighbors" + ], + [ + "Session 44", + "44", + "8 neighbors" + ], + [ + "Session 45", + "45", + "8 neighbors" + ], + [ + "Session 46", + "46", + "8 neighbors" + ], + [ + "Session 47", + "47", + "8 neighbors" + ], + [ + "Session 48", + "48", + "8 neighbors" + ], + [ + "Session 49", + "49", + "8 neighbors" + ], + [ + "Session 50", + "50", + "8 neighbors" + ], + [ + "Session 51", + "51", + "8 neighbors" + ], + [ + "Session 52", + "52", + "8 neighbors" + ], + [ + "Session 53", + "53", + "8 neighbors" + ], + [ + "Session 54", + "54", + "8 neighbors" + ], + [ + "Session 55", + "55", + "8 neighbors" + ], + [ + "Session 56", + "56", + "8 neighbors" + ], + [ + "Session 57", + "57", + "8 neighbors" + ], + [ + "Session 58", + "58", + "8 neighbors" + ], + [ + "Session 59", + "59", + "8 neighbors" + ], + [ + "Session 60", + "60", + "8 neighbors" + ], + [ + "Session 61", + "61", + "8 neighbors" + ], + [ + "Session 62", + "62", + "8 neighbors" + ], + [ + "Session 63", + "63", + "8 neighbors" + ], + [ + "Session 64", + "64", + "8 neighbors" + ], + [ + "Session 65", + "65", + "8 neighbors" + ], + [ + "Session 66", + "66", + "8 neighbors" + ], + [ + "Session 67", + "67", + "8 neighbors" + ], + [ + "Session 68", + "68", + "8 neighbors" + ], + [ + "Session 69", + "69", + "8 neighbors" + ], + [ + "Session 70", + "70", + "8 neighbors" + ], + [ + "Session 71", + "71", + "8 neighbors" + ], + [ + "Session 72", + "72", + "8 neighbors" + ] + ] + }, + "pdf_sha256": "acd62ae55466277dff26a25c79d151cc2e641bfbbb73f93ab3f74fbbbd45f46c", + "text_markers": [ + "Common Ground", + "A season of shared space.", + "Keep the welcome growing.", + "Session 01", + "Session 02", + "Session 03", + "Session 04", + "Session 05", + "Session 06", + "Session 07", + "Session 08", + "Session 09", + "Session 10", + "Session 11", + "Session 12", + "Session 13", + "Session 14", + "Session 15", + "Session 16", + "Session 17", + "Session 18", + "Session 19", + "Session 20", + "Session 21", + "Session 22", + "Session 23", + "Session 24", + "Session 25", + "Session 26", + "Session 27", + "Session 28", + "Session 29", + "Session 30", + "Session 31", + "Session 32", + "Session 33", + "Session 34", + "Session 35", + "Session 36", + "Session 37", + "Session 38", + "Session 39", + "Session 40", + "Session 41", + "Session 42", + "Session 43", + "Session 44", + "Session 45", + "Session 46", + "Session 47", + "Session 48", + "Session 49", + "Session 50", + "Session 51", + "Session 52", + "Session 53", + "Session 54", + "Session 55", + "Session 56", + "Session 57", + "Session 58", + "Session 59", + "Session 60", + "Session 61", + "Session 62", + "Session 63", + "Session 64", + "Session 65", + "Session 66", + "Session 67", + "Session 68", + "Session 69", + "Session 70", + "Session 71", + "Session 72" + ], + "font_count": 1, + "accent": "#755122", + "accent_pixels": 3109, + "independent_char_bounds_checked": 3689, + "repeat_pdf_identical": true, + "direct_pdf_identical": true, + "zip_files": [ + "field-notes.pdf", + "field-notes.styled.html", + "field-notes.unstyled.html", + "field-notes-page-1.png", + "field-notes-page-2.png", + "field-notes-page-3.png", + "field-notes-page-4.png", + "field-notes-page-5.png", + "field-notes-page-6.png", + "field-notes-page-7.png", + "field-notes-page-8.png" + ], + "styled_and_unstyled_html": true, + "finalized_page_counts": true, + "callout_kept_together": true, + "artifact_hashes": { + "field-notes.pdf": "acd62ae55466277dff26a25c79d151cc2e641bfbbb73f93ab3f74fbbbd45f46c", + "field-notes.styled.html": "eb0cc12991e953f5b818c194cffdb50a05e2f133e93cfa86f11417bf9f17c87b", + "field-notes.unstyled.html": "b0e68ee4983b4cbe3e791654857742bb72e1b5fc2f3e4eaef0766b9235adbd62", + "field-notes-page-1.png": "74c2e71cc5c4d32171b5397739d5e4e1da91fbfa15155ff1b4dc8a839cc21c82", + "field-notes-page-2.png": "5097d31cc63715cb87f25ddcffe84856cdc328cb0ef950f4792dea4a441d9afe", + "field-notes-page-3.png": "79652b15c7a888337c23562cbe0fc65263d921aae45e7755b91e86b309cc2a3e", + "field-notes-page-4.png": "b97c3eb1a6cd74f043f86f47cd004ecccc738f36913811ba9c1ea4671decc014", + "field-notes-page-5.png": "3e86dd8de2ea14e183b2bdb2aa929ca013403ed4433b1a907cc59b4e009e014a", + "field-notes-page-6.png": "7ded0703778250a4ae2b4eedb4bc9d27c59358854f5536cf6b9938f9fa2567ff", + "field-notes-page-7.png": "ce4d9ad2c5c676197f766be0952f833d219983aeb1bf93e33464e93f93fa15dd", + "field-notes-page-8.png": "50a71a7b28960ffc5aca3707fe4f265b065315b9dd2eab4bb9feeda73b9a6f42" + } + }, + { + "name": "empty-after-long", + "pages": 3, + "row_count": 0, + "controls": { + "PROJECT_NAME": "Common Ground", + "HEADLINE": "A season of shared space.", + "ACCENT": "#755122", + "ACTIVITY": [] + }, + "pdf_sha256": "114f2076f53ef9e30704b94551c8c8ff6aab8ed56c3d2cb1f6e09e662897b942", + "text_markers": [ + "Common Ground", + "A season of shared space.", + "Keep the welcome growing." + ], + "font_count": 1, + "accent": "#755122", + "accent_pixels": 2137, + "independent_char_bounds_checked": 1685, + "repeat_pdf_identical": true, + "direct_pdf_identical": true, + "zip_files": [ + "field-notes.pdf", + "field-notes.styled.html", + "field-notes.unstyled.html", + "field-notes-page-1.png", + "field-notes-page-2.png", + "field-notes-page-3.png" + ], + "styled_and_unstyled_html": true, + "finalized_page_counts": true, + "callout_kept_together": true, + "artifact_hashes": { + "field-notes.pdf": "114f2076f53ef9e30704b94551c8c8ff6aab8ed56c3d2cb1f6e09e662897b942", + "field-notes.styled.html": "79ed8d981685fdfae2ec3b6af66fcd4d0ec11da1395bf4bba2fa03cefe127d2c", + "field-notes.unstyled.html": "3b93493642feb1c4770622561d0ccf1747edb9134d8e2a25a5246a2dd8b35604", + "field-notes-page-1.png": "626217670103c45188dd3c1cf96e2a82ef2add3b1bb00baa6dc19fb150eebea9", + "field-notes-page-2.png": "68578aef1e42fbe276c7225322ffd4710398456e5e13c42994b145936680e599", + "field-notes-page-3.png": "b3ddbd6538cf7e9f7109ca8b185dcc56f4e778808ae38ea9a18582628a432a16" + } + } + ], + "stale_previews_excluded": true, + "checked_at": "2026-10-19T21:25:11.896702+00:00", + "scope": "Jupyter kernel execution, independent saved-PDF inspection, repeat rendering, HTML semantics, and ZIP contents. Browser downloads and hosted Colab are separate checks.", + "source_commit": "982009ce6eb43d0af7d4475bb75df7fe70478784", + "source_path": "examples/page-band-notebook-alpha9/component_page_bands.ipynb", + "publication_status": "Verified with the public Fullbleed 2.6.0a9 wheel; hosted Colab execution is not claimed." +} diff --git a/docs/getting-started/component-notebook.md b/docs/getting-started/component-notebook.md index 57ba7ec..6610618 100644 --- a/docs/getting-started/component-notebook.md +++ b/docs/getting-started/component-notebook.md @@ -5,8 +5,9 @@ description: Edit order data and component styles in Colab or Jupyter, then down # Try Python components in a notebook -!!! info "Current component preview: 2.6.0a8" - [Get the current showcase source kit and verified PDFs](../guides/component-preview-8.md). +!!! info "Current component preview: 2.6.0a9" + [Get the current showcase source kit and verified PDFs](../guides/component-preview-9.md). + [Try the new page-band notebook](page-band-notebook.md) for reusable headers and final page totals. The downloadable kits on this page retain their original version pins and evidence. diff --git a/docs/getting-started/page-band-notebook.md b/docs/getting-started/page-band-notebook.md new file mode 100644 index 0000000..ce756a5 --- /dev/null +++ b/docs/getting-started/page-band-notebook.md @@ -0,0 +1,114 @@ +--- +title: Try reusable page bands in a notebook +description: Edit a complete Python component report in Colab or Jupyter. Customize the theme, repeating header, activity table and finalized page numbers, then download PDF and HTML. +--- + +# Your design, on every page + +Change the name, headline, colors and activity rows in a complete Python report. +The header repeats, the table continues across pages, and the footer uses the +final page total. The notebook includes all the component source, with no +authored HTML strings. + +[Open in Colab](https://colab.research.google.com/github/fullbleed-engine/docs/blob/982009ce6eb43d0af7d4475bb75df7fe70478784/examples/page-band-notebook-alpha9/component_page_bands.ipynb){ .md-button .md-button--primary } +[Download the notebook](../assets/page-band-notebook-alpha9/component_page_bands.ipynb){ .md-button download="component_page_bands.ipynb" } +[View the pinned source](https://github.com/fullbleed-engine/docs/blob/982009ce6eb43d0af7d4475bb75df7fe70478784/examples/page-band-notebook-alpha9/component_page_bands.ipynb){ .md-button } + +!!! info "Opt-in component preview: 2.6.0a9" + The first cell installs `fullbleed==2.6.0a9`. Its API under + `fullbleed.ui.experimental` remains experimental. The + [page-band guide](../guides/component-preview-9.md) includes the source ZIP, + API details, verification scope and current limits. + +[![Common Ground field notes with a reusable brand header, editorial title, two summary metrics and a finalized page count.](../assets/component-preview-9/field-notes-1.png){ width="480" }](https://github.com/fullbleed-engine/fullbleed-official/releases/download/v2.6.0a9/common-ground-field-notes-pdfua2-2.6.0a9.pdf) + +## Run in Colab + +1. Open the notebook, sign in if prompted, and use a standard CPU runtime. +2. Choose **Runtime > Run all**. Review all three default page previews. +3. Edit `PROJECT_NAME`, `HEADLINE`, `ACCENT` or `ACTIVITY` in step 2. +4. Rerun from that cell through the download cell and save the resulting ZIP. + +The ZIP contains the current PDF, styled HTML, unstyled HTML and every current +page preview. Download it before leaving the hosted session. This example needs +no GPU. Colab's own availability and usage limits apply. + +## Run in local Jupyter + +Download the notebook and open a terminal in its folder. These instructions use +Python 3.11 or later and Jupyter Notebook 7.6.3. + +**Windows (PowerShell):** + +```powershell +py -m venv .venv +.\.venv\Scripts\python.exe -m pip install "notebook==7.6.3" +.\.venv\Scripts\python.exe -m notebook component_page_bands.ipynb +``` + +**macOS or Linux:** + +```sh +python3 -m venv .venv +.venv/bin/python -m pip install "notebook==7.6.3" +.venv/bin/python -m notebook component_page_bands.ipynb +``` + +Wait for the Python kernel to connect, then choose **Run > Run All Cells**. +Review the page previews and click **output/component-field-notes.zip** in +step 7. After an edit, rerun from that cell through the download cell. + +## See how the styles connect + +| What to change | Where to change it | +| --- | --- | +| Name, headline and activity rows | The values in step 2 | +| Shared colors and inherited text | `ACCENT` and `Theme(...)` | +| Reusable typography | `styled(...)` definitions | +| Table caption, header and cells | `DataTable(...).with_parts(...)` | +| Page counter appearance | `class_name="folio"` and the matching `.folio` rule in `Document(styles=...)` | +| Repeated brand and page counts | `PageBand(...)` and `PageNumbers(total=True)` | +| A short callout that should stay together | `style(break_inside="avoid", ...)` on its `Box` | + +The PDF renderer registers the bundled Inter font explicitly. Styled HTML keeps +its font-family declaration; the browser host supplies Inter or a fallback. +Unstyled HTML preserves content and classes for the host's CSS. Both HTML exports +use one header/main/footer sequence and explicit `[page]` / `[pages]` +placeholders. They do not add JavaScript or paginate the browser. + +To try the longer report, replace the activity rows in step 2 with: + +```python +ACTIVITY = [ + (f"Session {number:02d}", str(number), "8 neighbors") + for number in range(1, 73) +] +``` + +The checked long example has eight pages. Set `ACTIVITY = []` and rerun to see +the empty table and a three-page result. Old previews from a longer run stay out +of the new ZIP. + +## What has been checked + +The [execution record](../assets/page-band-notebook-alpha9/verification.json) +covers the default report, edited text and colors, 72 activity rows, and an +empty table after the long run: 3, 3, 8 and 3 pages. Independent PDF readers check +text, character bounds, colors and final page counts. Repeat renders and the +ZIP contents are checked, and all 17 final page previews were visually reviewed. + +The [browser record](../assets/page-band-notebook-alpha9/browser-verification.json) +covers editing cells, running them in local Jupyter, displaying every preview, +and clicking the actual ZIP download. Each downloaded artifact matches its +independently inspected reference. The default PDF reproduces the exact +qualified field-notes specimen. + +These checks cover local Jupyter; they do not execute a hosted Colab session. +The PDF/UA-2 profile and source checks express authoring intent. Validate your +own output and include human assistive-technology review where needed. +Repeated PDF bands are artifacts outside body reading order, so essential +identity and report information also belong in the body. + +The [earlier Bootstrap notebook](component-notebook.md) retains its original +alpha 6 pin and examples. [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. diff --git a/docs/guides/accessible-showcase.md b/docs/guides/accessible-showcase.md index 353f44e..82ab57a 100644 --- a/docs/guides/accessible-showcase.md +++ b/docs/guides/accessible-showcase.md @@ -5,8 +5,8 @@ description: A designed two-page Common Ground guide with Python component sourc # An accessible document, authored with components -!!! info "Current component preview: 2.6.0a8" - [Get the current showcase source kit and verified PDFs](component-preview-8.md). +!!! info "Current component preview: 2.6.0a9" + [Get the current showcase source kit and verified PDFs](component-preview-9.md). The downloadable kits on this page retain their original version pins and evidence. diff --git a/docs/guides/bootstrap-components-preview.md b/docs/guides/bootstrap-components-preview.md index 04c74af..69d07e8 100644 --- a/docs/guides/bootstrap-components-preview.md +++ b/docs/guides/bootstrap-components-preview.md @@ -5,8 +5,8 @@ description: Try an experimental Python component kit with Bootstrap 5.0.0 cards # Bootstrap components, authored in Python -!!! info "Current component preview: 2.6.0a8" - [Get the current showcase source kit and verified PDFs](component-preview-8.md). +!!! info "Current component preview: 2.6.0a9" + [Get the current showcase source kit and verified PDFs](component-preview-9.md). The downloadable kits on this page retain their original version pins and evidence. diff --git a/docs/guides/component-preview-8.md b/docs/guides/component-preview-8.md index 1c14dc6..3e19fea 100644 --- a/docs/guides/component-preview-8.md +++ b/docs/guides/component-preview-8.md @@ -1,10 +1,14 @@ --- -title: Python components — current preview and source showcases +title: Python component alpha 8 and source showcases description: Recreate the Northstar invoice, Common Ground report and accessible community guide with Fullbleed 2.6.0a8. Download the verified source and export PDF or styled and unstyled HTML. --- # Python components: make the design your own +!!! info "Current component preview: 2.6.0a9" + [Get reusable page bands, finalized page numbers and the current source kit](component-preview-9.md). + This page retains the alpha 8 package and its verification record. + Start with a complete invoice, report or community guide. Edit Python functions, data and styles, then generate a PDF and HTML from the same component tree. The **2.6.0a8 preview** includes the page-furniture and running-header fixes from @@ -112,7 +116,8 @@ checksums, limits and reproduction details are linked from the release. These fixture checks do not establish accessibility for arbitrary documents or human assistive-technology usability. In the published a8 package, running-box measurements do not establish complete painted-ink fit; transformed content and -oversized descendants can clip. `PageBand` and `PageNumbers` are implemented in -[unreleased source](https://github.com/fullbleed-engine/fullbleed-official/blob/4379bb4c0ea792b747942d2dc7df12cd40e79150/docs/component-page-bands.md) -and are not included in this published package. The preview preserves the +oversized descendants can clip. `PageBand` and `PageNumbers` are available in +[alpha 9](component-preview-9.md) and are not included in the a8 package. The +[initial implementation source](https://github.com/fullbleed-engine/fullbleed-official/blob/4379bb4c0ea792b747942d2dc7df12cd40e79150/docs/component-page-bands.md) +records their original API qualification. The preview preserves the deterministic print-document engine and static HTML export boundaries. diff --git a/docs/guides/component-preview-9.md b/docs/guides/component-preview-9.md new file mode 100644 index 0000000..4ab4fe3 --- /dev/null +++ b/docs/guides/component-preview-9.md @@ -0,0 +1,136 @@ +--- +title: Reusable PDF headers, footers and page numbers in Python +description: Build a complete report from Python components, including styled headers and finalized page counts. Download the Fullbleed 2.6.0a9 source kit and export PDF or HTML. +--- + +# Reuse your design on every page + +A document's header can use the same brand component as its body. Its footer can +carry a styled page count that updates after pagination. Fullbleed **2.6.0a9** +adds `PageBand` and `PageNumbers` to the experimental Python component API. + +The Common Ground field-notes example combines a shared theme, reusable brand +and metric components, a table, and three numbered pages. Edit its Python +functions and data to make it your own. + +[Download the complete source kit](https://github.com/fullbleed-engine/fullbleed-official/releases/download/v2.6.0a9/fullbleed-component-showcases-2.6.0a9.zip){ .md-button .md-button--primary } +[Open the field-notes PDF](https://github.com/fullbleed-engine/fullbleed-official/releases/download/v2.6.0a9/common-ground-field-notes-pdfua2-2.6.0a9.pdf){ .md-button } +[Try the page-band notebook](../getting-started/page-band-notebook.md){ .md-button } + +![Common Ground field notes: a seasonal report with reusable brand header, editorial typography, summary metrics and a finalized page count.](../assets/component-preview-9/field-notes-1.png) + +!!! info "Install the preview explicitly" + Use Python 3.10 or later in a virtual environment. The kit pins + `fullbleed==2.6.0a9`; ordinary `pip install fullbleed` selects the stable + channel. Components under `fullbleed.ui.experimental` may change. + +## Run the report + +Extract the ZIP, open its `fullbleed-component-showcases-2.6.0a9` folder, and run: + +```sh +python -m pip install -r requirements.txt +python component_page_bands/render.py --out output/field-notes --pdf-profile pdfua2 +``` + +Open `output/field-notes/field-notes.pdf`, `field-notes.html`, or +`field-notes-unstyled.html`. The PDF has repeating bands and page values such as +`1 / 3`. HTML has one semantic header, main and footer, with explicit +`[page] / [pages]` placeholders. It does not paginate the browser. + +The content is fictional. Fonts come from the pinned package or the source kit; +PDF generation does not need a browser or system fonts. Choose `--pdf-profile +tagged` for ordinary tagged PDF, or `pdfua1` for the other accessible profile. + +## Give the margins reusable components + +This complete example creates a PDF with a branded header and a numbered footer: + +```python +from importlib.resources import files +from pathlib import Path +from fullbleed.ui.experimental import ( + Document, Heading, Page, PageBand, PageNumbers, Paragraph, Renderer, + Theme, component, mm, pt, style, +) + +@component +def Brand(name): + return Paragraph(name, style=style(font_size=pt(8), font_weight=700)) + +document = Document( + Heading("Project summary", level=1), + Paragraph("Prepared by Northstar Studio."), + title="Project summary", + lang="en-US", + theme=Theme(text=style(font_family="Inter", font_size=pt(10))), + page=Page( + "A4", margin=mm(20), + header=PageBand(left=Brand("NORTHSTAR STUDIO")), + footer=PageBand(right=PageNumbers(total=True, class_name="folio")), + ), + styles={".folio": style(font_size=pt(8), color="#53665c")}, +) +font = files("fullbleed_assets").joinpath("fonts/Inter-Variable.ttf") +renderer = Renderer(font_files=[str(font)]) +Path("summary.pdf").write_bytes(document.render_pdf(renderer)) +Path("summary.html").write_text(document.to_html(include_styles=True), encoding="utf-8") +Path("summary.unstyled.html").write_text(document.to_html(include_styles=False), encoding="utf-8") +``` + +`class_name="folio"` adds the literal class. The matching `.folio` rule in +`Document(styles=...)` supplies its appearance. Components also accept `style=`; +`.with_style(...)` customizes their root and `.with_parts(...)` customizes named +internal elements. In the complete report, `Brand`, `Metric` and `NextStep` +expose parts, and `styled(...)` defines reusable typography. + +`Page` reserves the margins. A band does not enlarge them automatically. It +accepts `left`, `center` and `right` slots, with `first_page` and `blank_pages` +policies. A `PageNumbers` value occupies its own slot. Managed bands report +layout or conservative paint-bound failures with the page, slot and authoring +source. Register fonts explicitly so those checks can use the actual glyphs. + +See the [page-band API and limits](https://github.com/fullbleed-engine/fullbleed-official/blob/v2.6.0a9/docs/component-page-bands.md) +for per-record numbering, fit diagnostics, styling precedence and export behavior. + +## Keep both authoring paths + +The same kit also includes the existing Northstar invoice, Common Ground +report and authored accessible community guide: + +```sh +python design_showcase/compare_authoring.py --out output/paired +python accessible_showcase/render.py --out output/accessible-ua1 +python accessible_showcase/render.py --pdf-profile pdfua2 --out output/accessible-ua2 +``` + +Northstar and the report each include component and HTML/CSS sources for the +same design. The paired command compares the rendered pages and writes styled +and unstyled HTML. The guide demonstrates headings, captioned tables, column +headers, lists and image descriptions. Bootstrap components remain static, +including HTML export; no Bootstrap JavaScript is included. + +The [paired-authoring walkthrough](showcase-authoring.md), +[accessible-guide walkthrough](accessible-showcase.md), and +[Bootstrap catalog](bootstrap-components-preview.md) retain their versioned +examples. The [alpha 8 guide](component-preview-8.md) documents the earlier package, +which does not include `PageBand` or `PageNumbers`. + +## Verification and scope + +The [release evidence](https://github.com/fullbleed-engine/fullbleed-official/releases/tag/v2.6.0a9) +links source provenance, checksums, saved-output comparisons and independent +PDF/UA validator reports. The source ZIP is tested after extraction using a +fresh public package installation. + +The component source retains 1,480 native tests, 11 doctests and 970 Python +tests. Its 33-case page-band matrix includes per-record numbering, styled +number comparisons, first/blank-page policies and rejected inputs. Earlier +saved-PDF fixtures and the designed showcases retain their reviewed output. +The complete CSS comparison retains its 25 failed cases and 18 disputed references. + +Repeated bands are artifacts outside the PDF body reading order. Keep essential +identity and report details in the body as well. Profile selection and passing +specimens do not establish accessibility for arbitrary documents or human +assistive-technology usability. Fit checks are conservative; named/facing-page +band variants are outside this API. Native streaming output is not atomic. diff --git a/docs/guides/showcase-authoring.md b/docs/guides/showcase-authoring.md index c07958e..5ff8311 100644 --- a/docs/guides/showcase-authoring.md +++ b/docs/guides/showcase-authoring.md @@ -5,8 +5,8 @@ description: Build the Northstar invoice and Common Ground report with HTML/CSS # One design, two authoring paths -!!! info "Current component preview: 2.6.0a8" - [Get the current showcase source kit and verified PDFs](component-preview-8.md). +!!! info "Current component preview: 2.6.0a9" + [Get the current showcase source kit and verified PDFs](component-preview-9.md). The downloadable kits on this page retain their original version pins and evidence. diff --git a/examples/page-band-notebook-alpha9/component_page_bands.ipynb b/examples/page-band-notebook-alpha9/component_page_bands.ipynb new file mode 100644 index 0000000..1d89aec --- /dev/null +++ b/examples/page-band-notebook-alpha9/component_page_bands.ipynb @@ -0,0 +1,386 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "intro", + "metadata": {}, + "source": [ + "# Your design, on every page\n", + "\n", + "Build a three-page Common Ground report with reusable Python components, a\n", + "styled header, a data table, and finalized page numbers. Edit the values below\n", + "and rerun to make the design your own. No HTML strings are needed for authoring.\n", + "\n", + "This notebook is an opt-in **Fullbleed 2.6.0a9** example. The component API is\n", + "experimental. All content is fictional. Fullbleed and this notebook are MIT licensed.\n", + "\n", + "Use a standard CPU runtime in Colab or a local Jupyter notebook. Run all cells,\n", + "review the page previews, and download your files before leaving the session." + ] + }, + { + "cell_type": "markdown", + "id": "install-help", + "metadata": {}, + "source": [ + "## 1. Install the pinned preview\n", + "\n", + "Fullbleed generates the PDF without a browser or system fonts. The notebook host supplies its display and download interface." + ] + }, + { + "cell_type": "code", + "id": "install-fullbleed", + "metadata": {}, + "source": [ + "%pip install --quiet --only-binary=:all: \"fullbleed==2.6.0a9\"" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "controls-help", + "metadata": {}, + "source": [ + "## 2. Make it yours\n", + "\n", + "Start with the name, headline, accent color, and activity rows. All text values\n", + "remain text, including `<` and `&`. After an edit, rerun this cell and every cell\n", + "below it. Add activity rows to see the page total change after pagination." + ] + }, + { + "cell_type": "code", + "id": "report-controls", + "metadata": {}, + "source": [ + "PROJECT_NAME = \"Common Ground\"\n", + "HEADLINE = \"A season of shared space.\"\n", + "ACCENT = \"#755122\"\n", + "ACTIVITY = [\n", + " (\"Garden mornings\", \"12\", \"84 visits\"),\n", + " (\"Repair afternoons\", \"4\", \"31 items repaired\"),\n", + " (\"Shared meals\", \"3\", \"96 places set\"),\n", + " (\"Seed exchanges\", \"2\", \"47 packets shared\"),\n", + " (\"Welcome walks\", \"6\", \"28 neighbors\"),\n", + "]" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "styles-help", + "metadata": {}, + "source": [ + "## 3. Give the components a shared style\n", + "\n", + "`Theme` supplies inherited text and named colors. `styled(...)` creates reusable\n", + "defaults. `class_name=\"folio\"` below adds a literal class; the `.folio` selector\n", + "in `Document(styles=...)` defines how the page counter looks." + ] + }, + { + "cell_type": "code", + "id": "shared-styles", + "metadata": {}, + "source": [ + "# SPDX-License-Identifier: MIT\n", + "\"\"\"A fictional seasonal brief built from reusable component values.\"\"\"\n", + "from fullbleed.ui.experimental import (\n", + " Box, Column, DataTable, Document, Flow, Heading, Page, PageBand, PageBreak,\n", + " PageNumbers, Paragraph, Row, Stack, Strong, Theme, component, mm, pt, style,\n", + " styled, token,\n", + ")\n", + "\n", + "\n", + "THEME = Theme(\n", + " {\"ink\": \"#183e34\", \"muted\": \"#53665c\", \"paper\": \"#edf2e6\", \"accent\": ACCENT},\n", + " text=style(font_family=\"Inter\", font_size=pt(10), line_height=1.45, color=token(\"ink\")),\n", + ")\n", + "Kicker = styled(Paragraph, font_size=pt(7), font_weight=700, letter_spacing=pt(1),\n", + " text_transform=\"uppercase\", color=token(\"accent\"))\n", + "Title = styled(Heading, font_size=pt(31), line_height=1.08, letter_spacing=pt(-0.8))\n", + "Small = styled(Paragraph, font_size=pt(8), color=token(\"muted\"))" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "components-help", + "metadata": {}, + "source": [ + "## 4. Compose reusable pieces\n", + "\n", + "`Brand`, `Metric`, and `NextStep` return ordinary component trees. Named `part`\n", + "values let a caller use `.with_parts(...)` to customize an internal element.\n", + "The same `Brand()` value can be used in a page band or in document content." + ] + }, + { + "cell_type": "code", + "id": "reusable-components", + "metadata": {}, + "source": [ + "@component(parts=(\"name\", \"detail\"))\n", + "def Brand():\n", + " return Stack(\n", + " Paragraph(PROJECT_NAME.upper(), part=\"name\", style=style(font_size=pt(8), font_weight=700, letter_spacing=pt(0.8))),\n", + " Paragraph(\"A neighborhood, growing together.\", part=\"detail\", style=style(font_size=pt(6), color=token(\"muted\"))),\n", + " gap=pt(2),\n", + " )\n", + "\n", + "\n", + "@component(parts=(\"value\", \"label\"))\n", + "def Metric(value, label):\n", + " return Stack(\n", + " Paragraph(value, part=\"value\", style=style(font_size=pt(32), font_weight=700, line_height=1.1)),\n", + " Small(label, part=\"label\"), gap=pt(5),\n", + " )\n", + "\n", + "\n", + "@component(parts=(\"heading\", \"body\"))\n", + "def NextStep(number, title, text):\n", + " return Stack(\n", + " Kicker(number),\n", + " Heading(title, level=3, part=\"heading\", style=style(font_size=pt(16))),\n", + " Paragraph(text, part=\"body\"), gap=pt(6),\n", + " )" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "document-help", + "metadata": {}, + "source": [ + "## 5. Put the report together\n", + "\n", + "`PageBand` places components in left, center, or right margin slots.\n", + "`PageNumbers(total=True)` uses final page counts. Page margins must leave enough\n", + "space for the bands; a fit error identifies the page, slot, and authored source.\n", + "\n", + "The bands repeat as PDF artifacts outside body reading order. Essential identity\n", + "and report information also belong in the body.", + "\n\nThe short lesson box uses `break_inside=\"avoid\"` to keep its heading and body\ntogether when a longer activity table pushes it to the next page." + ] + }, + { + "cell_type": "code", + "id": "compose-document", + "metadata": {}, + "source": [ + "def build_document():\n", + " activity = ACTIVITY\n", + " return Document(\n", + " Flow(\n", + " Kicker(\"Field notes / Autumn 2026\"),\n", + " Title(HEADLINE, level=1),\n", + " Paragraph(\"A small record of what happens when neighbors have a place to meet, make, and lend a hand.\"),\n", + " Box(\n", + " Row(Metric(\"126\", \"neighbors took part\"), Metric(\"21\", \"open sessions\"), gap=pt(18)),\n", + " style=style(background=token(\"paper\"), padding=pt(18)),\n", + " ),\n", + " Heading(\"The place is the invitation\", level=2, style=style(font_size=pt(17))),\n", + " Paragraph(\"We kept the gate open, the table long, and the activities simple. New faces became familiar ones. Practical work made room for conversation.\"),\n", + " Small(f\"{PROJECT_NAME} is a fictional community project. Every figure in this brief is sample data.\"),\n", + " gap=pt(15),\n", + " ),\n", + " PageBreak(),\n", + " Flow(\n", + " Kicker(\"01 / What we made possible\"),\n", + " Title(\"Small actions. Shared results.\", level=2),\n", + " Paragraph(\"The busiest sessions mixed a useful task with time to stay. No specialist knowledge was needed to join in.\"),\n", + " DataTable(\n", + " activity, caption=\"Season activity\", columns=(\n", + " Column(\"Activity\", lambda row: row[0]),\n", + " Column(\"Sessions\", lambda row: row[1], align=\"right\", width=mm(18)),\n", + " Column(\"Participation\", lambda row: row[2], width=mm(37)),\n", + " ),\n", + " ).with_parts(\n", + " caption=style(font_size=pt(9), font_weight=700, margin_bottom=pt(7)),\n", + " header=style(background=token(\"paper\"), font_size=pt(8), color=token(\"ink\")),\n", + " cell=style(font_size=pt(8), padding=pt(8)),\n", + " ),\n", + " Box(\n", + " Kicker(\"A useful lesson\"),\n", + " Paragraph(Strong(\"Make the first step visible. \"), \"A welcome at the gate, a clearly marked tool bench, and a task that takes ten minutes helped people begin.\"),\n", + " style=style(background=token(\"paper\"), padding=pt(15), break_inside=\"avoid\"),\n", + " ),\n", + " Small(\"Visits and participation counts can include the same neighbor more than once. They describe activity, not unique attendance.\"),\n", + " gap=pt(12),\n", + " ),\n", + " PageBreak(),\n", + " Flow(\n", + " Kicker(\"02 / What comes next\"),\n", + " Title(\"Keep the welcome growing.\", level=2),\n", + " Paragraph(\"Three modest commitments will guide the next season. Each has a clear owner and a way for someone new to help.\"),\n", + " NextStep(\"01\", \"Make room at the table\", \"Reserve a few places at every shared meal for neighbors joining for the first time. Publish the menu and access information together.\"),\n", + " NextStep(\"02\", \"Pass on a practical skill\", \"Pair each repair session with a short demonstration. Keep a simple printed guide beside the tools for people who prefer to follow at their own pace.\"),\n", + " NextStep(\"03\", \"Listen before adding more\", \"Ask what made participation easy and what got in the way. Use those answers to choose the next opening hours and activities.\"),\n", + " Small(f\"Prepared by {PROJECT_NAME} / October 2026. This sample is authored with Fullbleed components, including its repeated header and finalized page numbers.\"),\n", + " gap=pt(15),\n", + " ),\n", + " title=f\"{PROJECT_NAME} — Field notes\", lang=\"en-US\", theme=THEME,\n", + " page=Page(\n", + " \"A5\", margin=(mm(20), mm(17), mm(20), mm(17)),\n", + " header=PageBand(left=Brand(), right=Kicker(\"Field notes / 03\").with_style(font_size=pt(6))),\n", + " footer=PageBand(\n", + " left=Small(\"AUTUMN 2026 · SAMPLE DATA\").with_style(font_size=pt(6)),\n", + " right=PageNumbers(total=True, prefix=\"\", separator=\" / \", class_name=\"folio\"),\n", + " ),\n", + " ),\n", + " styles={\".folio\": style(font_size=pt(8), color=token(\"muted\"))},\n", + " )\n", + "\n", + "\n", + "document = build_document()" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "render-help", + "metadata": {}, + "source": [ + "## 6. Render PDF and export HTML\n", + "\n", + "The bundled Inter font is registered explicitly for PDF generation. Styled HTML\n", + "keeps its font-family declaration; a browser host supplies Inter or a fallback.\n", + "Unstyled HTML keeps content and classes while removing generated and inline styles.\n", + "Both exports contain one header/main/footer sequence and explicit `[page]` /\n", + "`[pages]` placeholders. They do not add JavaScript or browser pagination.\n", + "\n", + "The PDF/UA-2 profile and source checks express authoring intent. Review and\n", + "validate your own output; this notebook does not establish arbitrary-document\n", + "accessibility or replace human assistive-technology testing." + ] + }, + { + "cell_type": "code", + "id": "render-export", + "metadata": {}, + "source": [ + "from importlib import metadata, resources\n", + "from pathlib import Path\n", + "from fullbleed.ui.experimental import Renderer\n", + "\n", + "assert metadata.version(\"fullbleed\") == \"2.6.0a9\"\n", + "output = Path(\"output\")\n", + "output.mkdir(exist_ok=True)\n", + "font = resources.files(\"fullbleed_assets\").joinpath(\"fonts/Inter-Variable.ttf\")\n", + "renderer = Renderer(\n", + " font_files=[str(font)], pdf_profile=\"pdfua2\",\n", + " document_timestamp=\"2026-10-19T00:00:00Z\",\n", + ")\n", + "prepared = document.prepare(renderer, a11y_mode=\"raise\")\n", + "pdf_path = output / \"field-notes.pdf\"\n", + "pdf_path.write_bytes(prepared.render_pdf())\n", + "for include_styles, filename in (\n", + " (True, \"field-notes.styled.html\"),\n", + " (False, \"field-notes.unstyled.html\"),\n", + "):\n", + " (output / filename).write_text(\n", + " document.to_html(include_styles=include_styles, a11y_mode=\"raise\"),\n", + " encoding=\"utf-8\", newline=\"\\n\",\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\"field-notes-page-{number}.png\"\n", + " path.write_bytes(png)\n", + " preview_paths.append(path)\n", + " display(Image(data=png, width=540))\n", + "print(f\"{len(preview_paths)} pages. Review every page before using the document.\")" + ], + "execution_count": null, + "outputs": [] + }, + { + "cell_type": "markdown", + "id": "download-help", + "metadata": {}, + "source": [ + "## 7. Download the current result\n", + "\n", + "The ZIP contains the PDF, both HTML exports, and this run's page previews. In\n", + "Colab the next cell starts a download; local Jupyter shows a download link.\n", + "The individual files also remain in `output/`. Shorter reruns exclude old,\n", + "longer-run previews from the ZIP." + ] + }, + { + "cell_type": "code", + "id": "download", + "metadata": {}, + "source": [ + "import sys\n", + "from zipfile import ZipFile, ZIP_DEFLATED\n", + "\n", + "bundle_path = output / \"component-field-notes.zip\"\n", + "current_files = [pdf_path, output / \"field-notes.styled.html\",\n", + " output / \"field-notes.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", + "- [Page-band guide and complete source kit](https://docs.fullbleed.dev/guides/component-preview-9/)\n", + "- [API, fit diagnostics, and limitations](https://github.com/fullbleed-engine/fullbleed-official/blob/v2.6.0a9/docs/component-page-bands.md)\n", + "- [Northstar and Common Ground through both authoring paths](https://docs.fullbleed.dev/guides/showcase-authoring/)\n", + "- [Share a small authoring example](https://github.com/fullbleed-engine/fullbleed-official/discussions/86)\n", + "\n", + "Native streaming output is not atomic. Managed fit checks are conservative.\n", + "Named or facing-page band variants are not part of this preview. Bootstrap\n", + "components and HTML exports remain static." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + }, + "colab": { + "name": "component_page_bands.ipynb", + "provenance": [] + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/mkdocs.yml b/mkdocs.yml index cc5f211..79abaf7 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -77,6 +77,7 @@ nav: - Python installation: getting-started/installation.md - Python notebook: getting-started/notebook.md - Component notebook: getting-started/component-notebook.md + - Page-band notebook: getting-started/page-band-notebook.md - Use WooCommerce: guides/woocommerce.md - Python fonts and previews: getting-started/first-pdf.md - Examples and downloads: examples.md @@ -125,7 +126,7 @@ nav: - Engine architecture: engine/overview.md - Evaluate: - Component design preview: guides/component-design-preview.md - - Current component preview: guides/component-preview-8.md + - Current component preview: guides/component-preview-9.md - Bootstrap component alpha: guides/bootstrap-components-preview.md - Compare Python PDF libraries: guides/comparison.md - Three-renderer comparison: guides/renderer-comparison.md diff --git a/tools/check_component_notebook_jupyter.py b/tools/check_component_notebook_jupyter.py index e6859f2..ac97f9d 100644 --- a/tools/check_component_notebook_jupyter.py +++ b/tools/check_component_notebook_jupyter.py @@ -46,8 +46,15 @@ def check_browser(page, context, source, work, out, reference, token): context.on("page", lambda child: child.on("download", lambda item: downloads.append(item))) page.on("download", lambda item: downloads.append(item)) reports = [] + write(out / "startup-progress.json", dict(stage="waiting-for-run-menu")) page.get_by_role("menuitem", name="Run", exact=True).wait_for(timeout=60000) - expect(page.get_by_role("textbox")).to_have_count(7) + # The Run menu appears before Jupyter finishes creating the cell editors. + # A loaded Windows runner exceeded Playwright's default five-second wait. + write(out / "startup-progress.json", dict(stage="waiting-for-cell-editors")) + expect(page.get_by_role("textbox")).to_have_count(7, timeout=60000) + write(out / "startup-progress.json", dict(stage="waiting-for-kernel-ready")) + expect(page.get_by_role("progressbar", name="Kernel status")).to_have_attribute("aria-valuenow", "100", timeout=60000) + write(out / "startup-progress.json", dict(stage="ready-to-run")) for index, name in enumerate(("default", "edited", "long", "empty-after-long")): destination = out / name destination.mkdir() @@ -183,9 +190,30 @@ def main(): try: context = browser.new_context(viewport={"width": 1440, "height": 1000}, accept_downloads=True) page = context.new_page() + page_events = [] + page.on("crash", lambda: page_events.append("page-crash")) + page.on("pageerror", lambda error: page_events.append(str(error).replace(token, "[redacted]"))) 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) + try: + cases = check_browser(page, context, source, work, out, reference, token) + except Exception as error: + # Save the original failure before browser captures or + # process cleanup can raise their own exceptions. + failure = dict(ok=False, type=type(error).__name__, + error=str(error).replace(token, "[redacted]"), page_events=page_events, + url=page.url.replace(token, "[redacted]"), capture_errors=[]) + write(out / "failure.json", failure) + for capture in ("screenshot", "snapshot"): + try: + if capture == "screenshot": + page.screenshot(path=str(out / "failure.png"), timeout=5000, animations="disabled") + else: + (out / "failure.txt").write_text(page.locator("body").aria_snapshot(timeout=5000).replace(token, "[redacted]"), encoding="utf-8") + except Exception as capture_error: + failure["capture_errors"].append(dict(capture=capture, error=str(capture_error).replace(token, "[redacted]"))) + write(out / "failure.json", failure) + raise browser_version = browser.version finally: browser.close() diff --git a/tools/check_page_band_notebook_jupyter.py b/tools/check_page_band_notebook_jupyter.py new file mode 100644 index 0000000..11e969d --- /dev/null +++ b/tools/check_page_band_notebook_jupyter.py @@ -0,0 +1,236 @@ +"""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) == 8 + downloads = [] + context.on("page", lambda child: child.on("download", lambda item: downloads.append(item))) + page.on("download", lambda item: downloads.append(item)) + reports = [] + write(out / "startup-progress.json", dict(stage="waiting-for-run-menu")) + page.get_by_role("menuitem", name="Run", exact=True).wait_for(timeout=60000) + write(out / "startup-progress.json", dict(stage="waiting-for-cell-editors")) + expect(page.get_by_role("textbox")).to_have_count(8) + # The editor can finish loading before its first kernel connection. The + # observed first attempt marked cells trusted but executed none of them. + write(out / "startup-progress.json", dict(stage="waiting-for-kernel-ready")) + expect(page.get_by_role("progressbar", name="Kernel status")).to_have_attribute("aria-valuenow", "100", timeout=60000) + write(out / "startup-progress.json", dict(stage="recording-ready-snapshot")) + (out / "ready-to-run.txt").write_text(page.locator("body").aria_snapshot().replace(token, "[redacted]"), encoding="utf-8") + for index, expected in enumerate(reference["cases"]): + name = expected["name"] + destination = out / name + destination.mkdir() + if index: + text = "\n".join(key + " = " + repr(value) for key, value in expected["controls"].items()) + editor = page.get_by_role("textbox").nth(1) + 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) * 8}]:", 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-field-notes.zip", exact=True) + link.wait_for() + href_path = urlsplit(link.get_attribute("href")).path + assert href_path == "/files/output/component-field-notes.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 the actual browser ZIP download" + download = downloads[0] + assert download.suggested_filename == "component-field-notes.zip" and download.failure() is None + archive = destination / download.suggested_filename + download.save_as(archive) + 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() + 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 and image["src"].startswith("data:image/png;base64,") + assert sha256(base64.b64decode(image["src"].split(",", 1)[1])).hexdigest() == hashes[f"field-notes-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")) + (destination / "notebook.txt").write_text(page.locator("body").aria_snapshot().replace(token, "[redacted]"), 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/field-notes-page-4.png").exists() + assert "field-notes-page-4.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 = Path(reference["notebook"]) + assert reference["ok"] and reference["install_cell_executed"] + assert source_path.name == "component_page_bands.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) + if reference["local_install_override"]: + wheel = Path(reference["local_wheel"]["path"]) + assert sha256(wheel.read_bytes()).hexdigest() == reference["local_wheel"]["sha256"] + install = next(cell for cell in source["cells"] if cell["id"] == "install-fullbleed") + install["source"] = ['%pip install --quiet --no-index --no-deps "' + wheel.as_posix() + '"'] + (work / source_path.name).write_text(json.dumps(source, ensure_ascii=False, indent=1) + "\n", encoding="utf-8") + 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_events = [] + page.on("crash", lambda: page_events.append("page-crash")) + page.on("pageerror", lambda error: page_events.append(str(error).replace(token, "[redacted]"))) + page.goto(base + "/tree?token=" + token, wait_until="domcontentloaded") + page.goto(base + "/notebooks/" + source_path.name, wait_until="domcontentloaded") + try: + cases = check_browser(page, context, source, work, out, reference, token) + except Exception as error: + failure = dict(ok=False, type=type(error).__name__, + error=str(error).replace(token, "[redacted]"), page_events=page_events, + url=page.url.replace(token, "[redacted]"), capture_errors=[]) + write(out / "failure.json", failure) + for capture in ("screenshot", "snapshot"): + try: + if capture == "screenshot": + page.screenshot(path=str(out / "failure.png"), timeout=5000, animations="disabled") + else: + (out / "failure.txt").write_text(page.locator("body").aria_snapshot(timeout=5000).replace(token, "[redacted]"), encoding="utf-8") + except Exception as capture_error: + failure["capture_errors"].append(dict(capture=capture, error=str(capture_error).replace(token, "[redacted]"))) + write(out / "failure.json", failure) + raise + 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, local_install_override=reference["local_install_override"], + scope="Local Jupyter Notebook editing, execution, finalized counters, displayed previews and actual browser ZIP downloads. The receipt identifies any local-wheel install override. 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/verify_page_band_notebook.py b/tools/verify_page_band_notebook.py new file mode 100644 index 0000000..6e034b3 --- /dev/null +++ b/tools/verify_page_band_notebook.py @@ -0,0 +1,203 @@ +"""Execute the authored notebook and inspect its saved PDF, HTML, previews and ZIP.""" +from __future__ import annotations +from argparse import ArgumentParser +from copy import deepcopy +from datetime import datetime, timezone +from hashlib import sha256 +from html.parser import HTMLParser +from pathlib import Path +from zipfile import ZipFile +import json +import shutil +import sys + +import nbformat +from nbclient import NotebookClient +from jupyter_client import AsyncKernelManager +from jupyter_client.kernelspec import KernelSpecManager +from pypdf import PdfReader +import pypdfium2 as pdfium + +VERSION = '2.6.0a9' +DEFAULT_PDF = 'eb8f7c2b4fb5e123feb46a99e0521a7ab04181c0847c675076fe759e2d4bbf94' +def save(path, value): + path.write_text(json.dumps(value, indent=2) + '\n', encoding='utf-8', newline='\n') +def normalize(text): + return ' '.join(text.split()) +def compact(text): + # pypdf splits tracked headings into one-character lines; PDFium below + # independently checks word-level extraction from the same saved PDF. + return ''.join(text.split()) +class Markup(HTMLParser): + def __init__(self, source): + super().__init__(convert_charrefs=True) + self.elements, self.text = [], [] + self.feed(source) + def handle_starttag(self, tag, attrs): + self.elements.append((tag, dict(attrs))) + def handle_data(self, text): + self.text.append(text) +def execute(notebook, work, destination, kernel_dir): + notebook.cells.append(nbformat.v4.new_code_cell(''' +import fullbleed, importlib.metadata, json +assert prepared.render_pdf() == pdf_path.read_bytes(), "Repeat rendering differs" +assert document.render_pdf(renderer, a11y_mode="raise") == pdf_path.read_bytes(), "Direct rendering differs" +Path("notebook-runtime.json").write_text(json.dumps({ + "version": importlib.metadata.version("fullbleed"), + "inspection": dict(fullbleed.inspect_pdf(str(pdf_path))), + "repeat_pdf_identical": True, + "direct_pdf_identical": True, + "preview_count": len(preview_paths), +}), encoding="utf-8") +''')) + name = 'page-band-notebook-check' + client = NotebookClient(notebook, km=AsyncKernelManager(kernel_name=name, + kernel_spec_manager=KernelSpecManager(kernel_dirs=[str(kernel_dir)])), timeout=300, + kernel_name=name, resources={'metadata':{'path':str(work)}}) + try: + client.execute(cleanup_kc=True) + finally: + nbformat.write(notebook, destination / 'executed.ipynb') +def check(work, destination, name, controls): + output = work / 'output' + runtime = json.loads((work / 'notebook-runtime.json').read_text(encoding='utf-8')) + inspection = runtime['inspection'] + assert runtime['version'] == VERSION + assert not inspection.get('warnings') and not inspection.get('composition', {}).get('issues') + assert inspection['profile']['embedded_font_count'] >= 1 + pdf_path = output / 'field-notes.pdf' + reader = PdfReader(pdf_path) + texts = [normalize(page.extract_text() or '') for page in reader.pages] + pages = len(texts) + assert pages == inspection['page_count'] == runtime['preview_count'] + assert (pages > 3 if name == 'long' else pages == 3), (name, pages) + markers = [controls['PROJECT_NAME'], controls['HEADLINE'], 'Keep the welcome growing.'] + markers += [str(row[0]) for row in controls['ACTIVITY']] + text = normalize(' '.join(texts)) + assert all(compact(marker) in compact(text) for marker in markers), (name, markers) + assert all(f'{index} / {pages}' in text for index, text in enumerate(texts, 1)), (name, texts) + assert all(compact(controls['PROJECT_NAME'].upper()) in compact(text) for text in texts), (name, 'repeating header') + for filename, styled in [('field-notes.styled.html', True), ('field-notes.unstyled.html', False)]: + parsed = Markup((output / filename).read_text(encoding='utf-8')) + tags = [tag for tag, attrs in parsed.elements] + assert all(tags.count(tag) == 1 for tag in ('header', 'main', 'footer')) + assert 'script' not in tags and not any(key.lower().startswith('on') for _, attrs in parsed.elements for key in attrs) + assert 'studio' not in tags and 'notebooks' not in tags + assert any('folio' in attrs.get('class', '').split() for _, attrs in parsed.elements) + html_text = normalize(' '.join(parsed.text)) + assert all(normalize(marker) in html_text for marker in markers), (name, filename) + assert '[page]' in html_text and '[pages]' in html_text + assert ('style' in tags) == styled + if not styled: + assert not any('style' in attrs or (tag == 'link' and attrs.get('rel') == 'stylesheet') for tag, attrs in parsed.elements) + previews = [f'field-notes-page-{index}.png' for index in range(1, pages + 1)] + expected = ['field-notes.pdf', 'field-notes.styled.html', 'field-notes.unstyled.html', *previews] + bundle_path = output / 'component-field-notes.zip' + with ZipFile(bundle_path) as bundle: + assert bundle.testzip() is None and len(bundle.namelist()) == len(set(bundle.namelist())) + assert set(bundle.namelist()) == set(expected) + assert all(bundle.read(filename) == (output / filename).read_bytes() for filename in expected) + for filename in [*expected, bundle_path.name]: + shutil.copyfile(output / filename, destination / filename) + accent = tuple(int(controls['ACCENT'][index:index+2], 16) for index in (1, 3, 5)) + color_pixels = characters = 0 + independent_texts = [] + document = pdfium.PdfDocument(pdf_path) + try: + assert len(document) == pages + for index in range(pages): + page = document[index] + textpage = page.get_textpage() + independent_texts.append(normalize(textpage.get_text_range())) + width, height = page.get_size() + for number in range(textpage.count_chars()): + char = textpage.get_text_range(number, 1) + if not char or char.isspace(): + continue + x0, y0, x1, y1 = textpage.get_charbox(number) + assert x0 >= -0.5 and y0 >= -0.5 and x1 <= width + 0.5 and y1 <= height + 0.5, (name, index + 1, char) + characters += 1 + bitmap = page.render(scale=110 / 72) + image = bitmap.to_pil().convert('RGB') + color_pixels += sum(pixel == accent for pixel in image.get_flattened_data()) + image.save(destination / f'pdfium-page-{index + 1}.png') + image.close(); bitmap.close(); textpage.close(); page.close() + finally: + document.close() + independent_text = normalize(' '.join(independent_texts)) + assert all(normalize(marker) in independent_text for marker in markers) + assert all(f'{index} / {pages}' in text for index, text in enumerate(independent_texts, 1)) + assert all(controls['PROJECT_NAME'].upper() in text for text in independent_texts) + lesson_page, = [text for text in independent_texts if 'AUSEFULLESSON' in compact(text)] + assert 'Make the first step visible.' in lesson_page, (name, 'Lesson heading separated from its body') + assert color_pixels > 30, (name, color_pixels) + result = dict(name=name, pages=pages, row_count=len(controls['ACTIVITY']), controls=controls, + pdf_sha256=sha256(pdf_path.read_bytes()).hexdigest(), text_markers=markers, + font_count=inspection['profile']['embedded_font_count'], accent=controls['ACCENT'], accent_pixels=color_pixels, + independent_char_bounds_checked=characters, repeat_pdf_identical=True, direct_pdf_identical=True, + zip_files=expected, styled_and_unstyled_html=True, finalized_page_counts=True, callout_kept_together=True, + artifact_hashes={filename:sha256((destination / filename).read_bytes()).hexdigest() for filename in expected}) + if name == 'default': + assert result['pdf_sha256'] == DEFAULT_PDF, 'Notebook default differs from the qualified source-kit PDF; inspect before claiming reproduction.' + result['qualified_field_notes_pdf_reproduced'] = True + save(destination / 'verification.json', result) + save(destination / 'inspection.json', inspection) + return result +def main(): + parser = ArgumentParser(description=__doc__) + parser.add_argument('--notebook', type=Path, required=True) + parser.add_argument('--out', type=Path, required=True) + parser.add_argument('--local-wheel', type=Path, help='Local qualification only: substitute this verified wheel for the public install cell.') + args = parser.parse_args() + out = args.out.resolve() + out.mkdir(parents=True, exist_ok=False) + source_path = args.notebook.resolve() + source = nbformat.read(source_path, as_version=4) + nbformat.validate(source) + assert all(not cell.outputs and cell.execution_count is None for cell in source.cells if cell.cell_type == 'code') + assert len({cell.id for cell in source.cells}) == len(source.cells) + default = {} + exec(next(cell.source for cell in source.cells if cell.id == 'report-controls'), {}, default) + edited = deepcopy(default) + edited.update(PROJECT_NAME='River ', HEADLINE='A place to meet & make.', ACCENT='#763c96') + edited['ACTIVITY'][0] = ('Studio & cards', '16', '89 visits') + long = deepcopy(default) + long['ACTIVITY'] = [(f'Session {number:02d}', str(number), '8 neighbors') for number in range(1, 73)] + empty = deepcopy(default) + empty['ACTIVITY'] = [] + work = out / 'work' + work.mkdir() + kernel = out / 'kernelspec/page-band-notebook-check' + kernel.mkdir(parents=True) + save(kernel / 'kernel.json', dict(argv=[sys.executable, '-I', '-m', 'ipykernel_launcher', '-f', '{connection_file}'], + display_name='Page-band notebook check', language='python')) + reports = [] + for index, (name, controls) in enumerate((('default', default), ('edited', edited), ('long', long), ('empty-after-long', empty))): + notebook = deepcopy(source) + for cell in notebook.cells: + if cell.id == 'install-fullbleed': + if index: + cell.metadata.setdefault('tags', []).append('skip-execution') + elif args.local_wheel: + assert args.local_wheel.is_file() + cell.source = '%pip install --quiet --no-index --no-deps "' + args.local_wheel.resolve().as_posix() + '"' + if cell.id == 'report-controls' and index: + cell.source = '\n'.join(key + ' = ' + repr(value) for key, value in controls.items()) + destination = out / name + destination.mkdir() + execute(notebook, work, destination, kernel.parent) + reports.append(check(work, destination, name, controls)) + print(json.dumps(dict(case=name, pages=reports[-1]['pages'], ok=True)), flush=True) + assert len({row['pdf_sha256'] for row in reports}) == 4 + assert (work / 'output/field-notes-page-4.png').exists() + assert 'field-notes-page-4.png' not in reports[-1]['zip_files'] + record = dict(ok=True, notebook=str(source_path), notebook_sha256=sha256(source_path.read_bytes()).hexdigest(), + runtime_version=VERSION, install_cell_executed=True, public_install_cell=not bool(args.local_wheel), + local_install_override=bool(args.local_wheel), cases=reports, stale_previews_excluded=True, + local_wheel=(dict(path=str(args.local_wheel.resolve()), sha256=sha256(args.local_wheel.read_bytes()).hexdigest()) if args.local_wheel else None), + checked_at=datetime.now(timezone.utc).isoformat(), + scope='Jupyter kernel execution, independent saved-PDF inspection, repeat rendering, HTML semantics, and ZIP contents. Browser downloads and hosted Colab are separate checks.') + save(out / 'verification.json', record) + print(json.dumps(dict(ok=True, cases=len(reports), pages=sum(row['pages'] for row in reports))), flush=True) +if __name__ == '__main__': + main()