Skip to content

Commit ca7bb61

Browse files
authored
Merge pull request #185 from fullbleed-engine/docs/component-template-install
Open local documents and assets in the PDF playground
2 parents a63a24f + a007d47 commit ca7bb61

23 files changed

Lines changed: 852 additions & 49 deletions

‎.github/workflows/docs.yml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -923,6 +923,7 @@ jobs:
923923
- name: Verify downloadable playground projects
924924
run: |
925925
python -m pip install fullbleed==2.5.40 pypdf==6.19.0 pypdfium2==5.14.0 Pillow==12.3.0
926+
node tools/test_playground_local_files.mjs
926927
node tools/verify_project_export.mjs
927928
python -I tools/verify_project_export.py
928929
python -I tools/verify_node_project_export.py
@@ -1214,6 +1215,16 @@ jobs:
12141215
retention-days: 14
12151216
- name: Verify the built playground PDF and project downloads
12161217
run: python tools/check_playground_ui.py --site site --out output/playground-ui --browser chrome
1218+
- name: Import local assets and replay both downloaded projects
1219+
run: python tools/check_playground_local_files.py --site site --out output/playground-local-files
1220+
- uses: actions/upload-artifact@v4
1221+
if: always()
1222+
with:
1223+
name: playground-local-files-verification
1224+
path: |
1225+
output/playground-local-files/
1226+
!output/playground-local-files/**/node_modules/**
1227+
retention-days: 14
12171228
- uses: actions/upload-artifact@v4
12181229
if: always()
12191230
with:

‎docs/assets/playground/app.js‎

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,24 @@ import { prepareExample } from './examples.js';
33

44
const assetGeneration = new URL(import.meta.url).search;
55
const { buildProject } = await import(`./project-export.js${assetGeneration}`);
6+
const { readSelection, validateSource } = await import(`./local-files.js${assetGeneration}`);
67

78
const el = id => document.getElementById(`pg-${id}`);
89
const html = el('html'), css = el('css'), picker = el('example');
910
const projectLanguage = el('project-language');
1011
const originals = new Map(), edits = new Map();
1112
let current = 'invoice', worker = null, deadline = null, pageIndex = 0, pages = [], pdfUrl = null;
1213
let renderedSource = null;
14+
let localAssets = [], assetVersion = 0;
1315

1416
function status(message, error = false) {
1517
el('status').textContent = message;
1618
el('status').dataset.error = String(error);
1719
}
1820

1921
function changedSinceRender() {
20-
return !renderedSource || renderedSource.html !== html.value || renderedSource.css !== css.value;
22+
return !renderedSource || renderedSource.html !== html.value || renderedSource.css !== css.value
23+
|| renderedSource.assetVersion !== assetVersion;
2124
}
2225

2326
function setDownload(enabled) {
@@ -71,7 +74,7 @@ function fail(message) {
7174

7275
function startRender() {
7376
if (worker) finish();
74-
const source = { html: html.value, css: css.value };
77+
const source = { html: html.value, css: css.value, assets: localAssets, assetVersion };
7578
if (new TextEncoder().encode(source.html + source.css).length > 200000) {
7679
status('This playground accepts up to 200 KB of HTML and CSS combined.', true);
7780
return;
@@ -113,7 +116,7 @@ function startRender() {
113116
finish();
114117
setDownload(!changedSinceRender());
115118
if (changedSinceRender()) status('Source changed during rendering. Render again to update the PDF.');
116-
else if (data.report.missing_glyphs) status(`PDF created, but ${data.report.missing_glyphs} character(s) have no glyph in the bundled fonts. Review the output before using it.`, true);
119+
else if (data.report.missing_glyphs) status(`PDF created, but ${data.report.missing_glyphs} character(s) have no glyph in the available fonts. Review the output before using it.`, true);
117120
else status('Your PDF is ready. Change the design, or download it and keep building.');
118121
};
119122
worker.postMessage(source);
@@ -138,6 +141,70 @@ function saveText(name, content, type) {
138141
setTimeout(() => URL.revokeObjectURL(url), 1000);
139142
}
140143

144+
function showAssets() {
145+
const list = el('assets');
146+
list.replaceChildren();
147+
el('asset-count').textContent = `${localAssets.length} local ${localAssets.length === 1 ? 'asset' : 'assets'}`;
148+
el('asset-help').hidden = localAssets.length === 0;
149+
for (const asset of localAssets) {
150+
const row = document.createElement('li'), name = document.createElement('code'), remove = document.createElement('button');
151+
name.textContent = `assets/${asset.name}`;
152+
remove.textContent = 'Remove';
153+
remove.setAttribute('aria-label', `Remove ${asset.name}`);
154+
remove.addEventListener('click', () => {
155+
localAssets = localAssets.filter(item => item !== asset);
156+
assetVersion++;
157+
showAssets();
158+
setDownload(!changedSinceRender());
159+
status('Asset removed. Render again to update the PDF.');
160+
el('open-files').focus();
161+
});
162+
row.append(name, remove);
163+
list.append(row);
164+
}
165+
}
166+
167+
el('open-files').addEventListener('click', () => el('files').click());
168+
el('files').addEventListener('change', async () => {
169+
const files = Array.from(el('files').files);
170+
if (!files.length) return;
171+
const snapshot = { current, html: html.value, css: css.value, assetVersion };
172+
el('open-files').disabled = true;
173+
status('Opening selected files on your device…');
174+
try {
175+
const selected = await readSelection(files, localAssets);
176+
if (current !== snapshot.current || html.value !== snapshot.html || css.value !== snapshot.css || assetVersion !== snapshot.assetVersion) {
177+
throw new Error('Your document changed while the files were opening. Choose the files again to apply them.');
178+
}
179+
const hasSource = selected.html !== null || selected.css !== null;
180+
const nextHtml = selected.html ?? html.value;
181+
const nextCss = selected.css ?? (selected.html !== null ? '' : css.value);
182+
if (hasSource) validateSource(nextHtml, nextCss);
183+
if (worker) finish();
184+
if (hasSource) {
185+
edits.set(current, { html: html.value, css: css.value });
186+
current = 'custom';
187+
originals.set(current, { html: nextHtml, css: nextCss });
188+
edits.delete(current);
189+
if (!picker.querySelector('option[value="custom"]')) picker.add(new Option('Your document', 'custom'));
190+
picker.value = current;
191+
html.value = nextHtml; css.value = nextCss;
192+
el('reset').textContent = 'Reset document';
193+
selectTab(selected.html !== null ? 'html' : 'css');
194+
}
195+
localAssets = [...localAssets, ...selected.assets];
196+
assetVersion++;
197+
showAssets();
198+
clearOutput();
199+
status('Files opened locally. Use assets/filename for images and font files, then Render PDF.');
200+
} catch (error) {
201+
status(error.message || 'Could not open the selected files.', true);
202+
} finally {
203+
el('files').value = '';
204+
el('open-files').disabled = false;
205+
}
206+
});
207+
141208
for (const language of ['html', 'css']) {
142209
el(`${language}-tab`).addEventListener('click', () => selectTab(language));
143210
el(`${language}-tab`).addEventListener('keydown', event => {
@@ -168,7 +235,7 @@ el('next').addEventListener('click', () => showPage(pageIndex + 1));
168235
el('project').addEventListener('click', async () => {
169236
const button = el('project'), message = el('project-status');
170237
// Capture edits before downloading assets, even if the user keeps typing or switches examples.
171-
const snapshot = { name: current, html: html.value, css: css.value, language: projectLanguage?.value ?? 'python' };
238+
const snapshot = { name: current, html: html.value, css: css.value, language: projectLanguage?.value ?? 'python', assets: localAssets, assetVersion };
172239
button.disabled = true;
173240
button.textContent = 'Preparing project…';
174241
message.hidden = false;
@@ -177,7 +244,8 @@ el('project').addEventListener('click', async () => {
177244
try {
178245
const bytes = await buildProject(snapshot);
179246
saveText(`fullbleed-${snapshot.name}-${snapshot.language === 'node' ? 'node-' : ''}project.zip`, bytes, 'application/zip');
180-
const newerEdits = current !== snapshot.name || html.value !== snapshot.html || css.value !== snapshot.css || (projectLanguage?.value ?? 'python') !== snapshot.language;
247+
const newerEdits = current !== snapshot.name || html.value !== snapshot.html || css.value !== snapshot.css
248+
|| assetVersion !== snapshot.assetVersion || (projectLanguage?.value ?? 'python') !== snapshot.language;
181249
message.textContent = newerEdits
182250
? 'Project downloaded with the source and language from when you clicked. Download again to include newer changes.'
183251
: 'Project downloaded. Extract the ZIP and follow its README to render locally.';
@@ -199,6 +267,7 @@ picker.addEventListener('change', () => {
199267
if (worker) finish();
200268
edits.set(current, { html: html.value, css: css.value });
201269
current = picker.value;
270+
el('reset').textContent = current === 'custom' ? 'Reset document' : 'Reset example';
202271
const source = edits.get(current) || originals.get(current);
203272
html.value = source.html; css.value = source.css;
204273
clearOutput(); startRender();
@@ -218,7 +287,7 @@ try {
218287
if (originals.has(hashExample)) current = hashExample;
219288
picker.value = current;
220289
html.value = originals.get(current).html; css.value = originals.get(current).css;
221-
for (const control of [html, css, picker, el('reset'), el('render'), el('project'), projectLanguage].filter(Boolean)) control.disabled = false;
290+
for (const control of [html, css, picker, el('reset'), el('render'), el('project'), el('open-files'), projectLanguage].filter(Boolean)) control.disabled = false;
222291
for (const language of ['html', 'css']) { el(`save-${language}`).href = '#'; el(`save-${language}`).setAttribute('aria-disabled', 'false'); }
223292
startRender();
224293
} catch (error) { status(error.message, true); document.querySelector('.pg-output').setAttribute('aria-busy', 'false'); }
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// SPDX-License-Identifier: MIT
2+
// Selected files remain in tab memory and the worker's virtual filesystem.
3+
export const limits = Object.freeze({ source: 200000, file: 8 * 1024 * 1024, assets: 20 * 1024 * 1024, count: 16 });
4+
const sourceExtensions = new Set(['html', 'htm', 'css']);
5+
const assetExtensions = new Set(['png', 'jpg', 'jpeg', 'svg', 'ttf', 'otf', 'txt']);
6+
const encoder = new TextEncoder();
7+
const decoder = new TextDecoder('utf-8', { fatal: true });
8+
9+
export const assetKey = name => name.normalize('NFC').toLowerCase();
10+
export const isFont = name => /\.(ttf|otf)$/i.test(name);
11+
12+
export function validateName(name) {
13+
if (typeof name !== 'string' || !name || name.length > 120 || /^[. ]|[. ]$/.test(name)
14+
|| /[<>:"/\\|?*\x00-\x1f\x7f]/.test(name)
15+
|| /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)) {
16+
throw new Error('Use a filename of up to 120 characters without folders or reserved filename characters.');
17+
}
18+
return name.split('.').at(-1).toLowerCase();
19+
}
20+
21+
function text(bytes, name) {
22+
try { return decoder.decode(bytes); }
23+
catch { throw new Error(`${name} must be a UTF-8 text file.`); }
24+
}
25+
26+
function validateAsset(asset) {
27+
const ext = validateName(asset.name);
28+
if (!assetExtensions.has(ext)) throw new Error('Choose PNG, JPEG, SVG, TTF, OTF, or TXT assets.');
29+
const bytes = asset.bytes;
30+
if (!(bytes instanceof Uint8Array) || !bytes.length || bytes.length > limits.file) {
31+
throw new Error(`Each asset must contain data and be no larger than 8 MiB: ${asset.name}`);
32+
}
33+
const starts = values => values.every((value, i) => bytes[i] === value);
34+
if (ext === 'png' && !starts([137, 80, 78, 71, 13, 10, 26, 10])) throw new Error(`${asset.name} is not a PNG file.`);
35+
if (['jpg', 'jpeg'].includes(ext) && !starts([255, 216, 255])) throw new Error(`${asset.name} is not a JPEG file.`);
36+
if (ext === 'ttf' && !starts([0, 1, 0, 0]) && !starts([116, 114, 117, 101])) throw new Error(`${asset.name} is not a TrueType font.`);
37+
if (ext === 'otf' && !starts([79, 84, 84, 79])) throw new Error(`${asset.name} is not an OpenType font.`);
38+
if (ext === 'svg' && !/<svg(?:\s|\/?>)/i.test(text(bytes, asset.name))) throw new Error(`${asset.name} is not an SVG file.`);
39+
if (ext === 'txt') text(bytes, asset.name);
40+
}
41+
42+
export function validateAssets(assets = []) {
43+
if (!Array.isArray(assets) || assets.length > limits.count) throw new Error('This playground accepts up to 16 local assets.');
44+
let size = 0;
45+
const names = new Set();
46+
for (const asset of assets) {
47+
validateAsset(asset);
48+
const key = assetKey(asset.name);
49+
if (names.has(key)) throw new Error(`An asset named ${asset.name} is already present. Remove it before adding its replacement.`);
50+
names.add(key);
51+
size += asset.bytes.length;
52+
}
53+
if (size > limits.assets) throw new Error('Local assets must total no more than 20 MiB.');
54+
return assets;
55+
}
56+
57+
export function validateSource(html, css) {
58+
if (typeof html !== 'string' || typeof css !== 'string' || encoder.encode(html + css).length > limits.source) {
59+
throw new Error('This playground accepts up to 200 KB of HTML and CSS combined.');
60+
}
61+
}
62+
63+
// Validate the entire selection before the UI changes any source or asset state.
64+
export async function readSelection(files, currentAssets = []) {
65+
if (!files.length || files.length > limits.count + 2) throw new Error('Choose one HTML file, one CSS file, and up to 16 assets.');
66+
const selected = [];
67+
let html = null, css = null;
68+
let assetCount = currentAssets.length, assetBytes = currentAssets.reduce((sum, asset) => sum + asset.bytes.length, 0);
69+
const sourceTypes = new Set();
70+
for (const file of files) {
71+
const ext = validateName(file.name);
72+
const kind = ext === 'htm' ? 'html' : ext;
73+
if (!sourceExtensions.has(ext) && !assetExtensions.has(ext)) throw new Error(`Unsupported file: ${file.name}`);
74+
const cap = sourceExtensions.has(ext) ? limits.source : limits.file;
75+
if (!Number.isSafeInteger(file.size) || file.size < 0 || file.size > cap) {
76+
throw new Error(`${file.name} exceeds the ${sourceExtensions.has(ext) ? '200 KB source' : '8 MiB asset'} limit.`);
77+
}
78+
if (sourceExtensions.has(ext)) {
79+
if (sourceTypes.has(kind)) throw new Error('Choose at most one HTML file and one CSS file at a time.');
80+
sourceTypes.add(kind);
81+
} else {
82+
assetCount++;
83+
assetBytes += file.size;
84+
}
85+
selected.push({ file, kind });
86+
}
87+
if (assetCount > limits.count) throw new Error('This playground accepts up to 16 local assets.');
88+
if (assetBytes > limits.assets) throw new Error('Local assets must total no more than 20 MiB.');
89+
const added = [];
90+
for (const { file, kind } of selected) {
91+
const bytes = new Uint8Array(await file.arrayBuffer());
92+
if (bytes.length !== file.size) throw new Error(`Could not read all of ${file.name}. Choose the file again.`);
93+
if (kind === 'html') html = text(bytes, file.name);
94+
else if (kind === 'css') css = text(bytes, file.name);
95+
else added.push({ name: file.name, bytes });
96+
}
97+
validateAssets([...currentAssets, ...added]);
98+
return { html, css, assets: added };
99+
}

‎docs/assets/playground/playground.css‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎docs/assets/playground/project-export.js‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { storeZip } from './zip-store.js';
33

44
const encoder = new TextEncoder();
55
const decoder = new TextDecoder();
6-
const examples = new Set(['invoice', 'report', 'notice']);
6+
const examples = new Set(['invoice', 'report', 'notice', 'custom']);
7+
const { validateAssets, isFont } = await import(`./local-files.js${new URL(import.meta.url).search}`);
78

89
async function fetchBytes(path) {
910
let response;
@@ -20,11 +21,14 @@ async function sha256(bytes) {
2021
return Array.from(new Uint8Array(digest), value => value.toString(16).padStart(2, '0')).join('');
2122
}
2223

23-
export async function buildProject({ name, html, css, language = 'python' }, load = fetchBytes) {
24+
export async function buildProject({ name, html, css, language = 'python', assets = [] }, load = fetchBytes) {
2425
if (!examples.has(name) || typeof html !== 'string' || typeof css !== 'string') {
2526
throw new Error('Choose a playground example before downloading a project.');
2627
}
2728
if (!['python', 'node'].includes(language)) throw new Error('Choose Python or Node.js for your project.');
29+
validateAssets(assets);
30+
// Snapshot before awaiting downloads so callers can keep editing their document.
31+
assets = assets.map(asset => ({ name: asset.name, bytes: asset.bytes.slice() }));
2832
const node = language === 'node';
2933
const [buildBytes, fontBytes, runner, readme, license] = await Promise.all([
3034
load('build.json'), load('font-sources.json'), load(node ? 'project/node/render.mjs' : 'project/render.py'),
@@ -64,13 +68,15 @@ export async function buildProject({ name, html, css, language = 'python' }, loa
6468
{ name: 'LICENSE.txt', bytes: license },
6569
{ name: 'fonts/font-sources.json', bytes: fontBytes },
6670
...fonts,
71+
...assets.map(asset => ({ name: `assets/${asset.name}`, bytes: asset.bytes })),
6772
];
6873
const manifest = {
6974
format: 1, source: 'https://docs.fullbleed.dev/playground/', example: name,
7075
engine: { name: 'fullbleed', version },
7176
files: await Promise.all(files.map(async file => ({ path: file.name, bytes: file.bytes.length, sha256: await sha256(file.bytes) }))),
7277
};
7378
if (node) manifest.runtime = { name: 'node', minimumVersion: '22', package: { name: 'fullbleed', version: packageVersion } };
79+
if (assets.length) manifest.localAssets = assets.map(asset => ({ path: `assets/${asset.name}`, font: isFont(asset.name) }));
7480
files.push({ name: 'project.json', bytes: encoder.encode(JSON.stringify(manifest, null, 2) + '\n') });
7581
return storeZip(files);
7682
}

‎docs/assets/playground/project/README.txt‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
YOUR FULLBLEED PROJECT
22

33
This ZIP contains the HTML and CSS that were in the playground when you clicked
4-
Download project, the bundled fonts and licenses, and a Python renderer.
4+
Download project, the bundled fonts and licenses, selected local assets, and a Python renderer.
55

66
RUN IT
77

@@ -17,8 +17,10 @@ Edit input.html or style.css, then run python render.py again.
1717
The requirement pins Fullbleed {{ENGINE_VERSION}}, matching the browser demo.
1818
Installing it needs an internet connection. Rendering with these bundled assets
1919
runs locally; your HTML and CSS are not uploaded. No browser or system fonts are
20-
needed by this Python project. Additional assets you reference must be supplied
21-
separately. The local package can handle jobs beyond the browser demo's limits.
20+
needed by this Python project. Files you selected in the playground are in assets/;
21+
reference them with paths such as assets/logo.png. Selected TTF/OTF fonts are
22+
registered by the runner. Other assets you reference must be supplied separately.
23+
The local package can handle jobs beyond the browser demo's limits.
2224

2325
KEEP BUILDING
2426

@@ -38,3 +40,5 @@ LICENSES
3840
Starter templates and render.py: MIT (LICENSE.txt).
3941
Fonts: SIL Open Font License 1.1 (the *-OFL.txt files in fonts/).
4042
Font attribution and exact source revisions: fonts/font-sources.json.
43+
Imported documents and assets keep their original licenses. Selected TXT license
44+
files are preserved in assets/. The starter's MIT license does not relicense them.

0 commit comments

Comments
 (0)