@bezda/rhp-mcp 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/skill/SKILL.md +5 -0
- package/skill/recipes/area.html +199 -0
- package/skill/recipes/bar.html +199 -0
- package/skill/recipes/box-plot.html +199 -0
- package/skill/recipes/bubble.html +199 -0
- package/skill/recipes/bullet.html +199 -0
- package/skill/recipes/candlestick.html +199 -0
- package/skill/recipes/column.html +199 -0
- package/skill/recipes/dial.html +199 -0
- package/skill/recipes/diverging-bars.html +199 -0
- package/skill/recipes/donut.html +199 -0
- package/skill/recipes/dumbbell.html +199 -0
- package/skill/recipes/gantt.html +199 -0
- package/skill/recipes/grouped-bars.html +199 -0
- package/skill/recipes/heatmap.html +199 -0
- package/skill/recipes/histogram.html +199 -0
- package/skill/recipes/line.html +199 -0
- package/skill/recipes/live.html +199 -0
- package/skill/recipes/log-line.html +199 -0
- package/skill/recipes/lollipop.html +199 -0
- package/skill/recipes/multi-line.html +199 -0
- package/skill/recipes/pyramid.html +199 -0
- package/skill/recipes/race.html +199 -0
- package/skill/recipes/radial-bars.html +199 -0
- package/skill/recipes/scatter.html +199 -0
- package/skill/recipes/silhouettes.html +199 -0
- package/skill/recipes/slope.html +199 -0
- package/skill/recipes/sparklines.html +199 -0
- package/skill/recipes/stacked-100.html +199 -0
- package/skill/recipes/stacked-bars.html +199 -0
- package/skill/recipes/strip.html +199 -0
- package/skill/recipes/unit-stack.html +199 -0
- package/skill/recipes/violin.html +199 -0
- package/skill/recipes/waffle.html +199 -0
- package/skill/recipes/waterfall.html +199 -0
- package/skill/references/download.md +218 -0
- package/skill/references/environments.md +1 -0
- package/src/server.js +1 -1
|
@@ -123,5 +123,204 @@ function ProfitBridge() {
|
|
|
123
123
|
|
|
124
124
|
render(() => html`<${ProfitBridge} />`, document.getElementById("chart"));
|
|
125
125
|
</script>
|
|
126
|
+
<style id="poster-download-styles">
|
|
127
|
+
.poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
|
|
128
|
+
.poster-download { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 44px; height: 44px; padding: 10px; border: 0; border-radius: 4px; background: transparent; color: inherit; cursor: pointer; }
|
|
129
|
+
.poster-download svg { display: block; width: 20px; height: 20px; }
|
|
130
|
+
.poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
131
|
+
.poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
132
|
+
.poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
|
|
133
|
+
.poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
|
|
134
|
+
@media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
|
|
135
|
+
</style>
|
|
136
|
+
<script type="module" id="poster-download-script">
|
|
137
|
+
function addPosterDownloads() {
|
|
138
|
+
document.querySelectorAll('.poster').forEach((poster, index) => {
|
|
139
|
+
const navigation = index === 0 && document.querySelector('.recipe-navigation');
|
|
140
|
+
const tools = navigation || document.createElement('div');
|
|
141
|
+
if (tools.querySelector('.poster-download')) return;
|
|
142
|
+
if (!navigation) {
|
|
143
|
+
tools.className = 'poster-download-tools';
|
|
144
|
+
poster.before(tools);
|
|
145
|
+
const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
146
|
+
new ResizeObserver(align).observe(poster);
|
|
147
|
+
align();
|
|
148
|
+
const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
|
|
149
|
+
if (rgb) {
|
|
150
|
+
const light = rgb.slice(0, 3).map(Number).map(v => {
|
|
151
|
+
v /= 255;
|
|
152
|
+
return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
|
|
153
|
+
});
|
|
154
|
+
tools.style.color = light[0] * .2126 + light[1] * .7152 + light[2] * .0722 < .179 ? '#eef2f5' : '#1d1b17';
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const button = document.createElement('button');
|
|
158
|
+
button.type = 'button';
|
|
159
|
+
button.className = 'poster-download';
|
|
160
|
+
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
161
|
+
button.title = 'Download poster as PNG';
|
|
162
|
+
button.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M5 16v4h14v-4"/></svg>';
|
|
163
|
+
const error = document.createElement('p');
|
|
164
|
+
error.className = 'poster-download-error';
|
|
165
|
+
error.setAttribute('role', 'alert');
|
|
166
|
+
error.hidden = true;
|
|
167
|
+
tools.after(error);
|
|
168
|
+
// Download is part of the poster interaction, even though its control sits outside the image.
|
|
169
|
+
button.addEventListener('pointerdown', event => {
|
|
170
|
+
event.stopPropagation();
|
|
171
|
+
});
|
|
172
|
+
button.addEventListener('mousedown', event => {
|
|
173
|
+
event.stopPropagation();
|
|
174
|
+
});
|
|
175
|
+
poster.addEventListener('focusout', event => {
|
|
176
|
+
if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
|
|
177
|
+
}, true);
|
|
178
|
+
poster.addEventListener('pointerleave', event => {
|
|
179
|
+
if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
|
|
180
|
+
}, true);
|
|
181
|
+
for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
|
|
182
|
+
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
|
|
183
|
+
});
|
|
184
|
+
let exporter, retries = 0, busy = false;
|
|
185
|
+
button.addEventListener('click', async event => {
|
|
186
|
+
event.stopPropagation();
|
|
187
|
+
if (busy) return;
|
|
188
|
+
busy = true;
|
|
189
|
+
button.setAttribute('aria-disabled', 'true');
|
|
190
|
+
button.setAttribute('aria-busy', 'true');
|
|
191
|
+
button.setAttribute('aria-label', 'Preparing poster PNG');
|
|
192
|
+
error.hidden = true;
|
|
193
|
+
let url;
|
|
194
|
+
try {
|
|
195
|
+
exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
|
|
196
|
+
const { domToBlob } = await exporter;
|
|
197
|
+
await document.fonts.ready;
|
|
198
|
+
// Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
|
|
199
|
+
const autoMargins = new Set();
|
|
200
|
+
const marginRules = rules => [...rules].map(rule => {
|
|
201
|
+
if (rule.selectorText && rule.style) {
|
|
202
|
+
const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
|
|
203
|
+
if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
|
|
204
|
+
return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
|
|
205
|
+
}
|
|
206
|
+
if (!rule.cssRules) return '';
|
|
207
|
+
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
|
|
208
|
+
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
|
|
209
|
+
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
|
|
210
|
+
}).join('\n');
|
|
211
|
+
const margins = [...document.styleSheets].map(sheet => {
|
|
212
|
+
try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
|
|
213
|
+
}).join('\n');
|
|
214
|
+
const restoreMargins = (source, clone) => {
|
|
215
|
+
if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
|
|
216
|
+
[...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
|
|
217
|
+
[...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
|
|
218
|
+
}
|
|
219
|
+
const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName));
|
|
220
|
+
[...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
|
|
221
|
+
};
|
|
222
|
+
// Cross-origin font stylesheets are readable through fetch, but not through cssRules.
|
|
223
|
+
const fontFiles = new Map();
|
|
224
|
+
const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
|
|
225
|
+
let css;
|
|
226
|
+
try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
|
|
227
|
+
catch (cause) {
|
|
228
|
+
if (!sheet.href) return '';
|
|
229
|
+
const response = await fetch(sheet.href);
|
|
230
|
+
if (!response.ok) throw new Error('Font stylesheet could not be loaded');
|
|
231
|
+
css = await response.text();
|
|
232
|
+
}
|
|
233
|
+
const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
|
|
234
|
+
return (await Promise.all(rules.map(async rule => {
|
|
235
|
+
for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
|
|
236
|
+
const source = new URL(match[1], sheet.href || document.baseURI).href;
|
|
237
|
+
if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
|
|
238
|
+
const response = await fetch(source);
|
|
239
|
+
if (!response.ok) throw new Error('Font could not be loaded');
|
|
240
|
+
const blob = await response.blob();
|
|
241
|
+
return new Promise((resolve, reject) => {
|
|
242
|
+
const reader = new FileReader();
|
|
243
|
+
reader.onload = () => resolve(reader.result);
|
|
244
|
+
reader.onerror = reject;
|
|
245
|
+
reader.readAsDataURL(blob);
|
|
246
|
+
});
|
|
247
|
+
})());
|
|
248
|
+
rule = rule.replace(match[1], await fontFiles.get(source));
|
|
249
|
+
}
|
|
250
|
+
return rule;
|
|
251
|
+
}))).join('\n');
|
|
252
|
+
}));
|
|
253
|
+
// Rasterize SVG paper textures before nesting them inside the exporter's SVG.
|
|
254
|
+
// This keeps SVG filter fragment references local to their own image.
|
|
255
|
+
const textures = new Map();
|
|
256
|
+
const embedTexture = async (node) => {
|
|
257
|
+
if (!node.style) return;
|
|
258
|
+
let background = node.style.backgroundImage;
|
|
259
|
+
for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
|
|
260
|
+
if (!textures.has(source)) textures.set(source, (async () => {
|
|
261
|
+
const image = new Image();
|
|
262
|
+
image.src = source;
|
|
263
|
+
await image.decode();
|
|
264
|
+
const canvas = document.createElement('canvas');
|
|
265
|
+
canvas.width = image.naturalWidth;
|
|
266
|
+
canvas.height = image.naturalHeight;
|
|
267
|
+
canvas.getContext('2d').drawImage(image, 0, 0);
|
|
268
|
+
return canvas.toDataURL();
|
|
269
|
+
})());
|
|
270
|
+
background = background.replace(source, await textures.get(source));
|
|
271
|
+
}
|
|
272
|
+
node.style.backgroundImage = background;
|
|
273
|
+
};
|
|
274
|
+
const blob = await domToBlob(poster, {
|
|
275
|
+
scale: 2, style: { margin: '0' },
|
|
276
|
+
font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
|
|
277
|
+
onCloneNode: clone => {
|
|
278
|
+
restoreMargins(poster, clone);
|
|
279
|
+
const style = document.createElement('style');
|
|
280
|
+
style.textContent = margins;
|
|
281
|
+
clone.prepend(style);
|
|
282
|
+
},
|
|
283
|
+
// CSS content's alternative text follows a slash and must not be painted.
|
|
284
|
+
onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
|
|
285
|
+
style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
|
|
286
|
+
}),
|
|
287
|
+
});
|
|
288
|
+
if (!blob) throw new Error('No PNG was created');
|
|
289
|
+
url = URL.createObjectURL(blob);
|
|
290
|
+
const link = document.createElement('a');
|
|
291
|
+
const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
|
|
292
|
+
link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.png';
|
|
293
|
+
link.href = url;
|
|
294
|
+
link.addEventListener('click', event => event.stopPropagation(), { once: true });
|
|
295
|
+
document.body.append(link);
|
|
296
|
+
link.click();
|
|
297
|
+
link.remove();
|
|
298
|
+
} catch (cause) {
|
|
299
|
+
exporter = null;
|
|
300
|
+
retries++;
|
|
301
|
+
error.textContent = 'The PNG could not be saved. Check your connection and try again.';
|
|
302
|
+
error.hidden = false;
|
|
303
|
+
} finally {
|
|
304
|
+
if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
|
|
305
|
+
busy = false;
|
|
306
|
+
button.removeAttribute('aria-disabled');
|
|
307
|
+
button.removeAttribute('aria-busy');
|
|
308
|
+
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
tools.append(button);
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
// A chart module may await fonts or data after DOMContentLoaded has already fired.
|
|
315
|
+
if (document.querySelector('.poster')) addPosterDownloads();
|
|
316
|
+
else {
|
|
317
|
+
const observer = new MutationObserver(() => {
|
|
318
|
+
if (!document.querySelector('.poster')) return;
|
|
319
|
+
observer.disconnect();
|
|
320
|
+
addPosterDownloads();
|
|
321
|
+
});
|
|
322
|
+
observer.observe(document.getElementById('chart') || document.body, { childList: true, subtree: true });
|
|
323
|
+
}
|
|
324
|
+
</script>
|
|
126
325
|
</body>
|
|
127
326
|
</html>
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Download a poster as PNG
|
|
2
|
+
|
|
3
|
+
Every standalone HTML poster page includes an icon button above the poster, aligned with its right edge.
|
|
4
|
+
Copy the following block before `</body>`, after the chart's module script.
|
|
5
|
+
Keep it outside the poster so the saved PNG includes the whole poster, its headline and source note, without the download control or gallery navigation.
|
|
6
|
+
The button joins `.recipe-navigation` when the gallery supplies one, inheriting the gallery link's color.
|
|
7
|
+
Otherwise it creates its own row above each poster, sized to the poster on desktop and phones.
|
|
8
|
+
The exporter loads only when clicked, waits for fonts, and saves the current poster at twice its displayed resolution.
|
|
9
|
+
Activating the download control preserves the poster's selection, including keyboard focus transitions, and does not bubble into outside-click handlers.
|
|
10
|
+
Like the chart's CDN import, the first download needs a connection.
|
|
11
|
+
For an embedded chart inside an existing app, follow the app's export controls instead of adding page navigation.
|
|
12
|
+
|
|
13
|
+
```html
|
|
14
|
+
<style id="poster-download-styles">
|
|
15
|
+
.poster-download-tools { display: flex; justify-content: flex-end; align-items: center; margin: 0 auto 12px; max-width: 100%; }
|
|
16
|
+
.poster-download { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 44px; height: 44px; padding: 10px; border: 0; border-radius: 4px; background: transparent; color: inherit; cursor: pointer; }
|
|
17
|
+
.poster-download svg { display: block; width: 20px; height: 20px; }
|
|
18
|
+
.poster-download:hover { background: color-mix(in srgb, currentColor 8%, transparent); }
|
|
19
|
+
.poster-download:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
|
|
20
|
+
.poster-download[aria-disabled="true"] { cursor: progress; opacity: .5; }
|
|
21
|
+
.poster-download-error { margin: 8px auto; font: 14px/1.4 system-ui, sans-serif; color: inherit; }
|
|
22
|
+
@media print { .poster-download-tools, .poster-download, .poster-download-error { display: none !important; } }
|
|
23
|
+
</style>
|
|
24
|
+
<script type="module" id="poster-download-script">
|
|
25
|
+
function addPosterDownloads() {
|
|
26
|
+
document.querySelectorAll('.poster').forEach((poster, index) => {
|
|
27
|
+
const navigation = index === 0 && document.querySelector('.recipe-navigation');
|
|
28
|
+
const tools = navigation || document.createElement('div');
|
|
29
|
+
if (tools.querySelector('.poster-download')) return;
|
|
30
|
+
if (!navigation) {
|
|
31
|
+
tools.className = 'poster-download-tools';
|
|
32
|
+
poster.before(tools);
|
|
33
|
+
const align = () => { tools.style.width = `${poster.getBoundingClientRect().width}px`; };
|
|
34
|
+
new ResizeObserver(align).observe(poster);
|
|
35
|
+
align();
|
|
36
|
+
const rgb = getComputedStyle(document.body).backgroundColor.match(/[\d.]+/g);
|
|
37
|
+
if (rgb) {
|
|
38
|
+
const light = rgb.slice(0, 3).map(Number).map(v => {
|
|
39
|
+
v /= 255;
|
|
40
|
+
return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4;
|
|
41
|
+
});
|
|
42
|
+
tools.style.color = light[0] * .2126 + light[1] * .7152 + light[2] * .0722 < .179 ? '#eef2f5' : '#1d1b17';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const button = document.createElement('button');
|
|
46
|
+
button.type = 'button';
|
|
47
|
+
button.className = 'poster-download';
|
|
48
|
+
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
49
|
+
button.title = 'Download poster as PNG';
|
|
50
|
+
button.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3v12m-5-5 5 5 5-5M5 16v4h14v-4"/></svg>';
|
|
51
|
+
const error = document.createElement('p');
|
|
52
|
+
error.className = 'poster-download-error';
|
|
53
|
+
error.setAttribute('role', 'alert');
|
|
54
|
+
error.hidden = true;
|
|
55
|
+
tools.after(error);
|
|
56
|
+
// Download is part of the poster interaction, even though its control sits outside the image.
|
|
57
|
+
button.addEventListener('pointerdown', event => {
|
|
58
|
+
event.stopPropagation();
|
|
59
|
+
});
|
|
60
|
+
button.addEventListener('mousedown', event => {
|
|
61
|
+
event.stopPropagation();
|
|
62
|
+
});
|
|
63
|
+
poster.addEventListener('focusout', event => {
|
|
64
|
+
if (button.contains(event.relatedTarget) || button.matches(':active')) event.stopImmediatePropagation();
|
|
65
|
+
}, true);
|
|
66
|
+
poster.addEventListener('pointerleave', event => {
|
|
67
|
+
if (button.contains(event.relatedTarget) || document.activeElement === button) event.stopImmediatePropagation();
|
|
68
|
+
}, true);
|
|
69
|
+
for (const type of ['keydown', 'keyup']) button.addEventListener(type, event => {
|
|
70
|
+
if (event.key === 'Enter' || event.key === ' ') event.stopPropagation();
|
|
71
|
+
});
|
|
72
|
+
let exporter, retries = 0, busy = false;
|
|
73
|
+
button.addEventListener('click', async event => {
|
|
74
|
+
event.stopPropagation();
|
|
75
|
+
if (busy) return;
|
|
76
|
+
busy = true;
|
|
77
|
+
button.setAttribute('aria-disabled', 'true');
|
|
78
|
+
button.setAttribute('aria-busy', 'true');
|
|
79
|
+
button.setAttribute('aria-label', 'Preparing poster PNG');
|
|
80
|
+
error.hidden = true;
|
|
81
|
+
let url;
|
|
82
|
+
try {
|
|
83
|
+
exporter ||= import('https://cdn.jsdelivr.net/npm/modern-screenshot@4.7.0/+esm' + (retries ? `?retry=${retries}` : ''));
|
|
84
|
+
const { domToBlob } = await exporter;
|
|
85
|
+
await document.fonts.ready;
|
|
86
|
+
// Grid auto margins resolve to zero in getComputedStyle, so keep their author rules.
|
|
87
|
+
const autoMargins = new Set();
|
|
88
|
+
const marginRules = rules => [...rules].map(rule => {
|
|
89
|
+
if (rule.selectorText && rule.style) {
|
|
90
|
+
const margins = [...rule.style].filter(name => name === 'margin' || name.startsWith('margin-'));
|
|
91
|
+
if (margins.some(name => /\bauto\b/.test(rule.style.getPropertyValue(name)))) autoMargins.add(rule.selectorText);
|
|
92
|
+
return margins.length ? `${rule.selectorText} { ${margins.map(name => `${name}: ${rule.style.getPropertyValue(name)}${rule.style.getPropertyPriority(name) ? ' !important' : ''};`).join(' ')} }` : '';
|
|
93
|
+
}
|
|
94
|
+
if (!rule.cssRules) return '';
|
|
95
|
+
if (rule instanceof CSSMediaRule) return matchMedia(rule.conditionText).matches ? marginRules(rule.cssRules) : '';
|
|
96
|
+
if (rule instanceof CSSSupportsRule) return CSS.supports(rule.conditionText) ? marginRules(rule.cssRules) : '';
|
|
97
|
+
return rule.cssText.slice(0, rule.cssText.indexOf('{') + 1) + marginRules(rule.cssRules) + '}';
|
|
98
|
+
}).join('\n');
|
|
99
|
+
const margins = [...document.styleSheets].map(sheet => {
|
|
100
|
+
try { return marginRules(sheet.cssRules); } catch (cause) { return ''; }
|
|
101
|
+
}).join('\n');
|
|
102
|
+
const restoreMargins = (source, clone) => {
|
|
103
|
+
if (clone.style && source !== poster && [...autoMargins].some(selector => source.matches(selector))) {
|
|
104
|
+
[...clone.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.removeProperty(name));
|
|
105
|
+
[...source.style].filter(name => name === 'margin' || name.startsWith('margin-')).forEach(name => clone.style.setProperty(name, source.style.getPropertyValue(name), source.style.getPropertyPriority(name)));
|
|
106
|
+
}
|
|
107
|
+
const children = [...source.children].filter(node => !['STYLE', 'SCRIPT'].includes(node.tagName));
|
|
108
|
+
[...clone.children].forEach((child, i) => { if (children[i]) restoreMargins(children[i], child); });
|
|
109
|
+
};
|
|
110
|
+
// Cross-origin font stylesheets are readable through fetch, but not through cssRules.
|
|
111
|
+
const fontFiles = new Map();
|
|
112
|
+
const fontRules = await Promise.all([...document.styleSheets].map(async sheet => {
|
|
113
|
+
let css;
|
|
114
|
+
try { css = [...sheet.cssRules].map(rule => rule.cssText).join('\n'); }
|
|
115
|
+
catch (cause) {
|
|
116
|
+
if (!sheet.href) return '';
|
|
117
|
+
const response = await fetch(sheet.href);
|
|
118
|
+
if (!response.ok) throw new Error('Font stylesheet could not be loaded');
|
|
119
|
+
css = await response.text();
|
|
120
|
+
}
|
|
121
|
+
const rules = css.match(/@font-face\s*\{[^}]*\}/g) || [];
|
|
122
|
+
return (await Promise.all(rules.map(async rule => {
|
|
123
|
+
for (const match of [...rule.matchAll(/url\(["']?([^"')]+)["']?\)/g)]) {
|
|
124
|
+
const source = new URL(match[1], sheet.href || document.baseURI).href;
|
|
125
|
+
if (!fontFiles.has(source)) fontFiles.set(source, (async () => {
|
|
126
|
+
const response = await fetch(source);
|
|
127
|
+
if (!response.ok) throw new Error('Font could not be loaded');
|
|
128
|
+
const blob = await response.blob();
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const reader = new FileReader();
|
|
131
|
+
reader.onload = () => resolve(reader.result);
|
|
132
|
+
reader.onerror = reject;
|
|
133
|
+
reader.readAsDataURL(blob);
|
|
134
|
+
});
|
|
135
|
+
})());
|
|
136
|
+
rule = rule.replace(match[1], await fontFiles.get(source));
|
|
137
|
+
}
|
|
138
|
+
return rule;
|
|
139
|
+
}))).join('\n');
|
|
140
|
+
}));
|
|
141
|
+
// Rasterize SVG paper textures before nesting them inside the exporter's SVG.
|
|
142
|
+
// This keeps SVG filter fragment references local to their own image.
|
|
143
|
+
const textures = new Map();
|
|
144
|
+
const embedTexture = async (node) => {
|
|
145
|
+
if (!node.style) return;
|
|
146
|
+
let background = node.style.backgroundImage;
|
|
147
|
+
for (const [, source] of background.matchAll(/url\("(data:image\/svg\+xml[^"]+)"\)/g)) {
|
|
148
|
+
if (!textures.has(source)) textures.set(source, (async () => {
|
|
149
|
+
const image = new Image();
|
|
150
|
+
image.src = source;
|
|
151
|
+
await image.decode();
|
|
152
|
+
const canvas = document.createElement('canvas');
|
|
153
|
+
canvas.width = image.naturalWidth;
|
|
154
|
+
canvas.height = image.naturalHeight;
|
|
155
|
+
canvas.getContext('2d').drawImage(image, 0, 0);
|
|
156
|
+
return canvas.toDataURL();
|
|
157
|
+
})());
|
|
158
|
+
background = background.replace(source, await textures.get(source));
|
|
159
|
+
}
|
|
160
|
+
node.style.backgroundImage = background;
|
|
161
|
+
};
|
|
162
|
+
const blob = await domToBlob(poster, {
|
|
163
|
+
scale: 2, style: { margin: '0' },
|
|
164
|
+
font: { cssText: fontRules.join('\n') }, onCloneEachNode: embedTexture,
|
|
165
|
+
onCloneNode: clone => {
|
|
166
|
+
restoreMargins(poster, clone);
|
|
167
|
+
const style = document.createElement('style');
|
|
168
|
+
style.textContent = margins;
|
|
169
|
+
clone.prepend(style);
|
|
170
|
+
},
|
|
171
|
+
// CSS content's alternative text follows a slash and must not be painted.
|
|
172
|
+
onCreateForeignObjectSvg: svg => svg.querySelectorAll('style').forEach(style => {
|
|
173
|
+
style.textContent = style.textContent.replace(/(content:\s*')([^']*)(')/g, (_, start, text, end) => start + text.split(' / ')[0] + end);
|
|
174
|
+
}),
|
|
175
|
+
});
|
|
176
|
+
if (!blob) throw new Error('No PNG was created');
|
|
177
|
+
url = URL.createObjectURL(blob);
|
|
178
|
+
const link = document.createElement('a');
|
|
179
|
+
const title = poster.querySelector('.headline')?.textContent || document.title || 'poster';
|
|
180
|
+
link.download = (title.trim().toLowerCase().replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-|-$/g, '').slice(0, 100) || 'poster') + '.png';
|
|
181
|
+
link.href = url;
|
|
182
|
+
link.addEventListener('click', event => event.stopPropagation(), { once: true });
|
|
183
|
+
document.body.append(link);
|
|
184
|
+
link.click();
|
|
185
|
+
link.remove();
|
|
186
|
+
} catch (cause) {
|
|
187
|
+
exporter = null;
|
|
188
|
+
retries++;
|
|
189
|
+
error.textContent = 'The PNG could not be saved. Check your connection and try again.';
|
|
190
|
+
error.hidden = false;
|
|
191
|
+
} finally {
|
|
192
|
+
if (url) setTimeout(() => URL.revokeObjectURL(url), 60000);
|
|
193
|
+
busy = false;
|
|
194
|
+
button.removeAttribute('aria-disabled');
|
|
195
|
+
button.removeAttribute('aria-busy');
|
|
196
|
+
button.setAttribute('aria-label', 'Download poster as PNG');
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
tools.append(button);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
// A chart module may await fonts or data after DOMContentLoaded has already fired.
|
|
203
|
+
if (document.querySelector('.poster')) addPosterDownloads();
|
|
204
|
+
else {
|
|
205
|
+
const observer = new MutationObserver(() => {
|
|
206
|
+
if (!document.querySelector('.poster')) return;
|
|
207
|
+
observer.disconnect();
|
|
208
|
+
addPosterDownloads();
|
|
209
|
+
});
|
|
210
|
+
observer.observe(document.getElementById('chart') || document.body, { childList: true, subtree: true });
|
|
211
|
+
}
|
|
212
|
+
</script>
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
The block above is the maintained source for the recipe controls.
|
|
216
|
+
After changing it, run `node scripts/sync-poster-download.mjs` from the rhp repository to update every recipe.
|
|
217
|
+
Test an actual PNG download on a desktop and a phone, including a poster taller than the viewport, and verify that the image includes the bottom note and excludes the controls.
|
|
218
|
+
The implementation uses [modern-screenshot](https://github.com/qq15725/modern-screenshot) to preserve computed styles, pseudo-elements, images and embedded fonts.
|
|
@@ -121,6 +121,7 @@ Each section says where these tags go:
|
|
|
121
121
|
Use it when there is no project, in a chatbot's answer, or when the user asks for an HTML file.
|
|
122
122
|
rhp comes from jsDelivr through an import map, so the page needs no install and no build step.
|
|
123
123
|
Its module script has the sections every recipe has: data, slat types, the chart, then `render`.
|
|
124
|
+
For a poster page, copy the complete [PNG download block](download.md) before `</body>`, after the chart's module script.
|
|
124
125
|
|
|
125
126
|
```html
|
|
126
127
|
<!doctype html>
|
package/src/server.js
CHANGED
|
@@ -185,7 +185,7 @@ server.registerTool("rhp_guide", {
|
|
|
185
185
|
|
|
186
186
|
server.registerTool("rhp_reference", {
|
|
187
187
|
title: "rhp reference",
|
|
188
|
-
description: "Returns one of rhp's references by name: api (every component, prop, helper and CSS variable, exactly), design (art direction from the subject, the editorial poster, color, type, layout), looks (eight looks written out in full, as examples), interaction (which interaction for which story, with code), environments (plain HTML, Solid, React, Next.js, Vue, Svelte, Angular, Astro), forms (the subject's own form, which chart for which data and story), pitfalls (mistakes and their fixes). Look props up here instead of guessing. A long reference comes back as its list of sections: call again with section to get the one you need.",
|
|
188
|
+
description: "Returns one of rhp's references by name: api (every component, prop, helper and CSS variable, exactly), design (art direction from the subject, the editorial poster, color, type, layout), looks (eight looks written out in full, as examples), interaction (which interaction for which story, with code), download (the PNG download icon for standalone poster pages), environments (plain HTML, Solid, React, Next.js, Vue, Svelte, Angular, Astro), forms (the subject's own form, which chart for which data and story), pitfalls (mistakes and their fixes). Look props up here instead of guessing. A long reference comes back as its list of sections: call again with section to get the one you need.",
|
|
189
189
|
inputSchema: {
|
|
190
190
|
name: z.string().describe("The reference: api, design, environments, forms, interaction, looks or pitfalls"),
|
|
191
191
|
section: z.string().optional().describe('One "##" section of the reference, by its number or title, for example "4" or "Plot"'),
|