diff --git a/examples/browser/README.md b/examples/browser/README.md
index b9bebe4..be7f054 100644
--- a/examples/browser/README.md
+++ b/examples/browser/README.md
@@ -20,7 +20,12 @@ not upload document inputs to a PDF service. Nothing persists after a reload.
## Make your template
Change the invoice fields, or open **Edit the HTML & CSS** below the preview.
-Edits in that editor last until reload. To keep them in the project, edit:
+Use **Save HTML** and **Save CSS** to download the selected template's current
+source, including your edits and any placeholders. Saving does not require a
+successful PDF render. The files are named `invoice.html` / `invoice.css` or
+`report.html` / `report.css`; put them in the project's `src/` folder to use them
+after a reload. The downloads contain template source, not the values filled
+into the last PDF. You can also edit these files directly:
- `src/invoice.html` and `src/invoice.css`
- `src/report.html` and `src/report.css`
diff --git a/examples/browser/index.html b/examples/browser/index.html
index 93603a2..7b41962 100644
--- a/examples/browser/index.html
+++ b/examples/browser/index.html
@@ -50,13 +50,18 @@
A proper document. Made in your browser.
Edit the HTML & CSS
- Make the template your own. Invoice fields replace {{customer}} and {{reference}}; {{ink}} applies the color in CSS. Edits stay in this tab until you reload. Fullbleed uses static print HTML and CSS; document scripts do not run.
+ Make the template your own. Invoice fields replace {{customer}} and {{reference}}; {{ink}} applies the color in CSS. Save your HTML and CSS before leaving to keep your edits, including these placeholders. Fullbleed uses static print HTML and CSS; document scripts do not run.
Template source
- Generate edited PDF Reset this template
+ Generate edited PDF Reset this template Save HTML Save CSS
+
+ Use this in your app
+ Download the complete browser starter, then put your saved HTML and CSS in its src/ folder. It includes the templates, renderer setup, and dependency lockfile.
+
+
diff --git a/examples/browser/src/main.js b/examples/browser/src/main.js
index 8e52c14..97c49e1 100644
--- a/examples/browser/src/main.js
+++ b/examples/browser/src/main.js
@@ -116,6 +116,16 @@ $('reset-source').addEventListener('click', () => {
loadSource(); markChanged();
});
$('render-source').addEventListener('click', () => $('design-form').requestSubmit());
+function saveSource(extension) {
+ const design = $('kind').value;
+ const type = extension === 'html' ? 'text/html' : 'text/css';
+ const url = URL.createObjectURL(new Blob([templates[design][extension]], { type: `${type};charset=utf-8` }));
+ const link = document.createElement('a');
+ link.href = url; link.download = `${design}.${extension}`; link.click();
+ setTimeout(() => URL.revokeObjectURL(url), 60_000);
+}
+$('save-html').addEventListener('click', () => saveSource('html'));
+$('save-css').addEventListener('click', () => saveSource('css'));
$('cancel').addEventListener('click', () => controller?.abort());
$('download').addEventListener('click', () => {
if (!pdfUrl) return;
diff --git a/examples/browser/src/style.css b/examples/browser/src/style.css
index 94ddfa0..c28de8a 100644
--- a/examples/browser/src/style.css
+++ b/examples/browser/src/style.css
@@ -11,3 +11,4 @@ main{max-width:1440px;margin:auto;padding:0 5vw}.intro{padding:58px 0 38px}.eyeb
.template-editor{border-top:1px solid #bdc7bd;margin-top:36px;padding-top:22px}.template-editor summary{font-size:17px;font-weight:650;cursor:pointer}.editor-note{color:#54645b;font-size:12px;max-width:760px}.editor-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px}.editor-grid label{margin-top:0}.editor-grid textarea{width:100%;min-height:320px;resize:vertical;display:block;font:12px/1.6 monospace;padding:15px;background:#fffef9;color:#173b35;border:1px solid #bdc7bd;tab-size:2}.editor-actions{display:flex;gap:12px;align-items:center;margin-top:14px}.editor-actions p{font-size:11px;color:#657163;margin:0}code{font-size:.95em}@media(max-width:850px){.editor-grid{grid-template-columns:1fr}.editor-grid textarea{min-height:240px}}
select{appearance:none;line-height:1.25;padding-right:36px;background-image:linear-gradient(45deg,transparent 50%,#173b35 50%),linear-gradient(135deg,#173b35 50%,transparent 50%);background-position:calc(100% - 16px) 50%,calc(100% - 12px) 50%;background-size:4px 4px;background-repeat:no-repeat}.color-row input.color-text{width:90px;font:12px Inter,sans-serif;cursor:text}
+.editor-actions{flex-wrap:wrap}.source-downloads{display:flex;gap:12px;margin-left:auto}.source-save{border-color:transparent;background:transparent;color:#173b35;text-decoration:underline;text-underline-offset:4px}.next-step{border-top:1px solid #bdc7bd;margin-top:32px;padding-top:22px}.next-step p{max-width:680px;font-size:13px;line-height:1.7;color:#54645b}.next-step>div{display:flex;flex-wrap:wrap;gap:12px 24px}.next-step a{display:inline-flex;align-items:center;gap:10px;min-height:44px;font-size:13px;font-weight:650;text-underline-offset:4px}@media(max-width:600px){.source-downloads{width:100%;margin-left:0}.source-save{flex:1;text-align:left;padding-left:0}.editor-actions{gap:8px}}
diff --git a/tools/check-browser-starter.py b/tools/check-browser-starter.py
index b75fdf0..d747d3b 100644
--- a/tools/check-browser-starter.py
+++ b/tools/check-browser-starter.py
@@ -45,7 +45,7 @@ def log_message(self,*a):pass
server=ThreadingHTTPServer(('127.0.0.1',0),Handler)
thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
url=f'http://127.0.0.1:{server.server_port}{prefix}'
-checks=[];network=[];errors=[];documents=[]
+checks=[];network=[];errors=[];documents=[];sources=[]
def check(name,passed):
assert passed,name
checks.append(name);print(name+': passed',flush=True)
@@ -66,6 +66,13 @@ def download(name,pages,text):
check(name+': download has the expected pages and text',len(pdf.pages)==pages and all(t in contents for t in text))
documents.append({'name':name,'filename':item.suggested_filename,'pages':len(pdf.pages),'sha256':hashlib.sha256(path.read_bytes()).hexdigest()})
return path
+ def save_source(design,extension,expected,name):
+ with page.expect_download() as event:page.locator('#save-'+extension).click()
+ item=event.value;path=out/(name+'.'+extension);item.save_as(path)
+ check(name+': source download preserves the edited UTF-8 bytes and template filename',
+ item.suggested_filename==design+'.'+extension and path.read_bytes()==expected.encode('utf-8'))
+ sources.append({'name':name,'filename':item.suggested_filename,'bytes':path.stat().st_size,
+ 'sha256':hashlib.sha256(path.read_bytes()).hexdigest()})
try:
page.goto(url,wait_until='domcontentloaded');ready()
check('runtime resolves beneath a nested deployment path',any('/nested/demo/fullbleed/worker.js' in r['url'] for r in network))
@@ -89,26 +96,43 @@ def download(name,pages,text):
download('customized',1,['Birch & ','TEST-2042'])
check('customized bytes differ from the default invoice',documents[-1]['sha256']!=documents[0]['sha256'])
page.locator('#template-editor summary').click()
- page.locator('#html-source').fill('Edited template {{customer}} / {{reference}}
')
- page.locator('#css-source').fill('@page { size: A5; margin: 15mm } body { font-family: Helvetica } h1 { color: {{ink}}; font-size: 24pt }')
+ edited_html='Edited template \n{{customer}} / {{reference}}
\n'
+ edited_css='/* Color: café & "ink" */\n@page { size: A5; margin: 15mm } body { font-family: Helvetica } h1 { color: {{ink}}; font-size: 24pt }\n'
+ page.locator('#html-source').fill(edited_html)
+ page.locator('#css-source').fill(edited_css)
+ check('source can be saved before rendering pending edits',page.locator('#download').is_disabled())
+ save_source('invoice','html',edited_html,'edited-invoice')
+ save_source('invoice','css',edited_css,'edited-invoice')
page.locator('#render-source').click();ready()
path=download('edited',1,['Edited template','Birch & ','TEST-2042'])
check('edited print CSS changes the PDF page size',abs(float(PdfReader(path).pages[0].mediabox.width)-419.52756)<1)
check('document script does not run on the page',page.evaluate('window.documentScriptExecuted !== true'))
verify_edited_preview(page, path, out, check)
page.screenshot(path=str(out/'edited-template.png'),full_page=True)
+ for width,height in [(390,844),(320,760)]:
+ page.set_viewport_size({'width':width,'height':height})
+ check(f'{width}px: expanded editor and source downloads fit the viewport',page.evaluate('document.documentElement.scrollWidth <= innerWidth'))
+ page.screenshot(path=str(out/f'editor-width-{width}.png'),full_page=True)
+ page.set_viewport_size({'width':1440,'height':1000})
verify_engine_fixes(page, out, check, ready, manual=True)
- page.locator('#kind').select_option('report');page.locator('#generate').click();ready()
+ page.locator('#kind').select_option('report')
+ report_html=page.locator('#html-source').input_value()+'\n'
+ page.locator('#html-source').fill(report_html)
+ save_source('report','html',report_html,'edited-report')
+ save_source('report','css',page.locator('#css-source').input_value(),'edited-report')
+ page.locator('#generate').click();ready()
download('report',3,['COMMON','1,240','420,000.00'])
check('multi-page preview displays all report pages',page.locator('#preview img').count()==3)
page.locator('#kind').select_option('invoice')
check('each design preserves its template edits',page.locator('#html-source').input_value().startswith('Edited template'))
+ save_source('invoice','html',edited_html,'invoice-after-switch')
page.locator('#reset-source').click()
check('reset restores only the selected template', 'Northstar' in page.locator('#html-source').input_value())
page.locator('#html-source').fill('')
page.locator('#render-source').click()
expect(page.locator('#status')).to_contain_text('nonempty')
check('invalid input displays an error without a stale download',page.locator('#download').is_disabled())
+ save_source('invoice','html','','empty-html-after-error')
page.locator('#reset-source').click()
pause_engine.set()
page.locator('#generate').click()
@@ -122,7 +146,7 @@ def download(name,pages,text):
local_blob=f'blob:http://127.0.0.1:{server.server_port}/'
check('no document uploads or cross-origin runtime requests',all(r['method']=='GET' and not r['body'] and r['url'].startswith((url,local_blob)) for r in network))
check('no uncaught page errors',not errors)
- report={'ok':True,'checked_at':datetime.now(timezone.utc).isoformat(),'browser_engine':args.browser,'browser':browser.version,'playwright':version('playwright'),'pypdf':version('pypdf'),'checks':checks,'documents':documents,'network':network,'page_errors':errors,'csp':policy,'scope':'Owned browser; synthetic inputs; production build served at /nested/demo/ with no PDF server.'}
+ report={'ok':True,'checked_at':datetime.now(timezone.utc).isoformat(),'browser_engine':args.browser,'browser':browser.version,'playwright':version('playwright'),'pypdf':version('pypdf'),'checks':checks,'documents':documents,'source_downloads':sources,'network':network,'page_errors':errors,'csp':policy,'scope':'Owned browser; synthetic inputs; production build served at /nested/demo/ with no PDF server; exact source downloads including unsaved edits and failed renders.'}
(out/'verification.json').write_text(json.dumps(report,indent=2)+'\n',encoding='utf-8')
except Exception:
page.screenshot(path=str(out/'failure.png'),full_page=True)
diff --git a/verification/browser-template-downloads.json b/verification/browser-template-downloads.json
new file mode 100644
index 0000000..0cc1a83
--- /dev/null
+++ b/verification/browser-template-downloads.json
@@ -0,0 +1,160 @@
+{
+ "checked_at": "2026-10-15T01:36:11.749432+00:00",
+ "scope": "Browser starter source downloads and existing PDF rendering/recovery; no SDK or engine change.",
+ "package_version": "0.4.1",
+ "engine_version": "2.5.22",
+ "browser": "155.0.8059.40",
+ "playwright": "1.63.0",
+ "checks": [
+ "runtime resolves beneath a nested deployment path",
+ "bundled UI font loads",
+ "invoice: download has the expected pages and text",
+ "1440px: no horizontal overflow",
+ "390px: no horizontal overflow",
+ "320px: no horizontal overflow",
+ "color-only: download has the expected pages and text",
+ "changing only ink color changes the rendered PDF",
+ "changed inputs clear the previous preview",
+ "customized: download has the expected pages and text",
+ "customized bytes differ from the default invoice",
+ "source can be saved before rendering pending edits",
+ "edited-invoice: source download preserves the edited UTF-8 bytes and template filename",
+ "edited-invoice: source download preserves the edited UTF-8 bytes and template filename",
+ "edited: download has the expected pages and text",
+ "edited print CSS changes the PDF page size",
+ "document script does not run on the page",
+ "edited template uses unembedded standard Helvetica faces",
+ "edited standard-font preview contains visible text",
+ "390px: expanded editor and source downloads fit the viewport",
+ "320px: expanded editor and source downloads fit the viewport",
+ "engine-counter-sibling-reset: downloaded PDF has the expected page count",
+ "engine-counter-sibling-reset: sibling counter resets produce the required labels",
+ "engine-overflow-nested-clips: downloaded PDF has the expected page count",
+ "engine-overflow-nested-clips: downloaded PDF has the expected dimensions",
+ "engine-overflow-nested-clips: downloaded PDF matches independent color probes",
+ "engine-overflow-nested-clips: displayed preview has the expected dimensions",
+ "engine-overflow-nested-clips: displayed preview matches independent color probes",
+ "engine-border-center-zero-top-falls-back-bottom-svg: downloaded PDF has the expected page count",
+ "engine-border-center-zero-top-falls-back-bottom-svg: downloaded PDF has the expected dimensions",
+ "engine-border-center-zero-top-falls-back-bottom-svg: downloaded PDF matches independent color probes",
+ "engine-border-center-zero-top-falls-back-bottom-svg: displayed preview has the expected dimensions",
+ "engine-border-center-zero-top-falls-back-bottom-svg: displayed preview matches independent color probes",
+ "engine-first-letter-decorated-left: downloaded PDF has the expected page count",
+ "engine-first-letter-decorated-left: decorated initial preserves the paragraph text",
+ "engine-first-letter-decorated-left: downloaded PDF has the expected dimensions",
+ "engine-first-letter-decorated-left: downloaded PDF matches independent color probes",
+ "engine-first-letter-decorated-left: displayed preview has the expected dimensions",
+ "engine-first-letter-decorated-left: displayed preview matches independent color probes",
+ "edited-report: source download preserves the edited UTF-8 bytes and template filename",
+ "edited-report: source download preserves the edited UTF-8 bytes and template filename",
+ "report: download has the expected pages and text",
+ "multi-page preview displays all report pages",
+ "each design preserves its template edits",
+ "invoice-after-switch: source download preserves the edited UTF-8 bytes and template filename",
+ "reset restores only the selected template",
+ "invalid input displays an error without a stale download",
+ "empty-html-after-error: source download preserves the edited UTF-8 bytes and template filename",
+ "cancellation restores controls without a stale download",
+ "recovered: download has the expected pages and text",
+ "no document uploads or cross-origin runtime requests",
+ "no uncaught page errors"
+ ],
+ "source_downloads": [
+ {
+ "name": "edited-invoice",
+ "filename": "invoice.html",
+ "bytes": 156,
+ "sha256": "d5b529705775dd9bd524d8aac1ccd3aac2d57456c93e72d4f419c6d8c3e977ae"
+ },
+ {
+ "name": "edited-invoice",
+ "filename": "invoice.css",
+ "bytes": 131,
+ "sha256": "a757ddab08467b8b23a98158fef5eb7715286e2c42cbe8c843d41312697cd59b"
+ },
+ {
+ "name": "edited-report",
+ "filename": "report.html",
+ "bytes": 7783,
+ "sha256": "904c7079213638d6166c1cdf1b6e7cb3820a3824a084af7634e843f4291304b3"
+ },
+ {
+ "name": "edited-report",
+ "filename": "report.css",
+ "bytes": 4277,
+ "sha256": "519591ae75c239ec602324f008dd99108ebeae0b9306df7215fa5f0442905473"
+ },
+ {
+ "name": "invoice-after-switch",
+ "filename": "invoice.html",
+ "bytes": 156,
+ "sha256": "d5b529705775dd9bd524d8aac1ccd3aac2d57456c93e72d4f419c6d8c3e977ae"
+ },
+ {
+ "name": "empty-html-after-error",
+ "filename": "invoice.html",
+ "bytes": 0,
+ "sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ }
+ ],
+ "documents": [
+ {
+ "name": "invoice",
+ "filename": "invoice-NS-1042.pdf",
+ "pages": 1,
+ "sha256": "3078d155f30536f5ad721f7be3c241cd78661ae41249131fc0fd65f37f23e408"
+ },
+ {
+ "name": "color-only",
+ "filename": "invoice-NS-1042.pdf",
+ "pages": 1,
+ "sha256": "a2b69ba41f7ad4c9bcbd38d803a46e70f0b9ccacd90ffbab5b0f7d87ffca5e4b"
+ },
+ {
+ "name": "customized",
+ "filename": "invoice-TEST-2042.pdf",
+ "pages": 1,
+ "sha256": "31ff314b44a6f13534cc383e0cf24929f0695c1dd3d84769dedf8828c6486a93"
+ },
+ {
+ "name": "edited",
+ "filename": "invoice-TEST-2042.pdf",
+ "pages": 1,
+ "sha256": "f5c6c1927fc6c636cee592a3ebc7bb7b3e91dd645ab34a9370590571506d278b"
+ },
+ {
+ "name": "report",
+ "filename": "community-report.pdf",
+ "pages": 3,
+ "sha256": "139df89824e6a73c13ff62f1237bb8eb9f71e790dcbd051b1bb713ed2bc47802"
+ },
+ {
+ "name": "recovered",
+ "filename": "invoice-TEST-2042.pdf",
+ "pages": 1,
+ "sha256": "31ff314b44a6f13534cc383e0cf24929f0695c1dd3d84769dedf8828c6486a93"
+ }
+ ],
+ "example_files": {
+ "examples/browser/.gitignore": "c023fec2bf12476cb47709e859f1fbb785aeb1e03281e52141e0bee1ee7dad72",
+ "examples/browser/LICENSE": "36a2115938f6e68cf0d87eccff7a6a3e57c17017e909cbd640d51aff1fa0c5a9",
+ "examples/browser/README.md": "5b2b8a4462e2fadf836a0f8022cae13b7824883f351c3297a6aea6d9c9643613",
+ "examples/browser/index.html": "8714cd15f58623edf29fc4f725f280af35f5749e9fa1ebd9bb6f71985dbd1f3a",
+ "examples/browser/package-lock.json": "cb74c22fe1b49cf8f31c979a56975dbd1e390dd899d8bc4cf2186a819482a2f6",
+ "examples/browser/package.json": "b67511c1509889ac90928909dc8a268ddc96c763828611409c779f1b73ef673a",
+ "examples/browser/public/favicon.svg": "3eed6dcf1013e48bc64886fed4cf815c609ed06b404742bdacdc44c1df8d3118",
+ "examples/browser/src/invoice.css": "0b310ce0f2964fd6b9b68e4afafa9307b4ff8c6b2f1fc753367dc755620ce427",
+ "examples/browser/src/invoice.html": "1f065ad6694021649e61b3f666cba6dc01aacdcbd8e80e97425b383e60d7d2fe",
+ "examples/browser/src/main.js": "3ffcbe09c7117e635c21c39ad9be1aa3e7af16be7fc325683a7baaecae04d275",
+ "examples/browser/src/report.css": "519591ae75c239ec602324f008dd99108ebeae0b9306df7215fa5f0442905473",
+ "examples/browser/src/report.html": "46b0e975d43914c8b4492218feae09ea0ad684091a9f9ee3b18f66a87202e521",
+ "examples/browser/src/style.css": "131426c760df6f64f5890d03f2b3301bf2003f63008b7f90aea311c400db6f17",
+ "examples/browser/vite.config.js": "ec725922705a3c5ba2df191a564c54c0cac6e8997ecd3d00b8a42c647bec8e74"
+ },
+ "visual_review": {
+ "1440px": "Source actions and complete-project links readable beneath the editor.",
+ "390px_and_320px": "Source actions wrap without overlap or horizontal overflow; project links readable."
+ },
+ "local_evidence": "Fullbleed maintainer workspace: target/growth/browser-template-downloads-20261015/chrome-final",
+ "limitations": "Chrome locally; CI supplies Firefox and Playwright WebKit coverage. This is not branded Safari certification."
+}