@segnavia/render 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 modstudio
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,59 @@
1
+ import { Deck, PageInput } from "@segnavia/format";
2
+ import { PrintOptions } from "@segnavia/frame/print-document";
3
+ import { Page as Page$1 } from "playwright-core";
4
+ //#region src/index.d.ts
5
+ export declare const RENDER_BOUND_MS = 60000;
6
+ export declare const PAGE_READY_BOUND_MS = 10000;
7
+ export declare const CONTAINER_ARGS: string[];
8
+ export declare class RendererUnavailableError extends Error {
9
+ name: string;
10
+ }
11
+ export declare class RenderTimeoutError extends Error {
12
+ name: string;
13
+ }
14
+ export declare class RenderRefusedError extends Error {
15
+ name: string;
16
+ }
17
+ export interface Font {
18
+ family: string;
19
+ data: Uint8Array;
20
+ format: 'woff2' | 'woff' | 'truetype' | 'opentype';
21
+ weight?: number | string;
22
+ style?: 'normal' | 'italic';
23
+ }
24
+ export interface RenderLimits {
25
+ maxSheets?: number;
26
+ maxBytes?: number;
27
+ }
28
+ export interface RenderOptions {
29
+ css?: string;
30
+ fonts?: Font[];
31
+ limits?: RenderLimits;
32
+ boundMs?: number;
33
+ waitForPageReady?: boolean;
34
+ preferCssPageSize?: boolean;
35
+ beforeOutput?: (tab: Page$1) => Promise<void>;
36
+ }
37
+ export interface ImageOptions extends RenderOptions {
38
+ format?: 'png' | 'jpeg' | 'webp';
39
+ quality?: number[];
40
+ scale?: number;
41
+ width?: number;
42
+ }
43
+ export interface RendererOptions {
44
+ executablePath: string;
45
+ args?: string[];
46
+ concurrency?: number;
47
+ jsHeapMb?: number;
48
+ }
49
+ export interface Renderer {
50
+ pagePdf(page: PageInput, options?: RenderOptions): Promise<Uint8Array>;
51
+ pageImage(page: PageInput, options?: ImageOptions): Promise<Uint8Array>;
52
+ deckPdf(deck: Deck, options?: RenderOptions & PrintOptions): Promise<Uint8Array>;
53
+ slideImage(deck: Deck, index: number, options?: ImageOptions & Pick<PrintOptions, 'inline'>): Promise<Uint8Array>;
54
+ close(): Promise<void>;
55
+ }
56
+ export declare function fontFaces(fonts?: Font[]): string;
57
+ export declare function captureImage(tab: Page$1, options: Pick<ImageOptions, 'format' | 'quality' | 'limits'>, fullPage: boolean): Promise<Uint8Array>;
58
+ export declare function openRenderer(options: RendererOptions): Promise<Renderer>;
59
+ //#endregion
package/dist/index.js ADDED
@@ -0,0 +1,311 @@
1
+ import { PX_PER_MM, Page, sheetMm } from "@segnavia/format";
2
+ import { frameDocument, newNonce } from "@segnavia/frame/bridge";
3
+ import { printableDeck } from "@segnavia/frame/print-document";
4
+ import { PDFDocument } from "pdf-lib";
5
+ import { chromium } from "playwright-core";
6
+ //#region src/index.ts
7
+ const RENDER_BOUND_MS = 6e4;
8
+ const PAGE_READY_BOUND_MS = 1e4;
9
+ const FLUID_WIDTH = 1024;
10
+ const CONTAINER_ARGS = [
11
+ "--no-sandbox",
12
+ "--disable-setuid-sandbox",
13
+ "--disable-dev-shm-usage",
14
+ "--disable-gpu"
15
+ ];
16
+ const NO_NETWORK_ARGS = [
17
+ "--proxy-server=socks5://127.0.0.1:9",
18
+ "--proxy-bypass-list=<-loopback>",
19
+ "--force-webrtc-ip-handling-policy=disable_non_proxied_udp"
20
+ ];
21
+ var RendererUnavailableError = class extends Error {
22
+ name = "RendererUnavailableError";
23
+ };
24
+ var RenderTimeoutError = class extends Error {
25
+ name = "RenderTimeoutError";
26
+ };
27
+ var RenderRefusedError = class extends Error {
28
+ name = "RenderRefusedError";
29
+ };
30
+ function fontFaces(fonts = []) {
31
+ return fonts.map((font) => `@font-face{font-family:${JSON.stringify(font.family)};src:url(data:font/${font.format === "truetype" ? "ttf" : font.format === "opentype" ? "otf" : font.format};base64,${Buffer.from(font.data).toString("base64")}) format(${JSON.stringify(font.format)});font-weight:${font.weight ?? "normal"};font-style:${font.style ?? "normal"};font-display:block}`).join("");
32
+ }
33
+ async function withinBound(ms, work) {
34
+ let timer;
35
+ const bound = new Promise((_, reject) => {
36
+ timer = setTimeout(() => reject(new RenderTimeoutError(`The render did not finish within ${ms / 1e3}s.`)), ms);
37
+ });
38
+ try {
39
+ return await Promise.race([work, bound]);
40
+ } finally {
41
+ clearTimeout(timer);
42
+ }
43
+ }
44
+ async function sheets(pdf) {
45
+ return (await PDFDocument.load(pdf)).getPageCount();
46
+ }
47
+ async function enforce(pdf, limits) {
48
+ if (limits?.maxBytes !== void 0 && pdf.byteLength > limits.maxBytes) throw new RenderRefusedError(`The PDF is ${pdf.byteLength} bytes; the limit is ${limits.maxBytes}.`);
49
+ if (limits?.maxSheets !== void 0) {
50
+ const count = await sheets(pdf);
51
+ if (count > limits.maxSheets) throw new RenderRefusedError(`The PDF has ${count} sheets; the limit is ${limits.maxSheets}.`);
52
+ }
53
+ return pdf;
54
+ }
55
+ async function captureImage(tab, options, fullPage) {
56
+ const format = options.format ?? "png";
57
+ if (format !== "webp") return tab.screenshot({
58
+ type: format,
59
+ fullPage,
60
+ ...format === "jpeg" ? { quality: options.quality?.[0] ?? 90 } : {}
61
+ });
62
+ const session = await tab.context().newCDPSession(tab);
63
+ try {
64
+ const clip = await tab.evaluate((wholePage) => {
65
+ const scale = devicePixelRatio;
66
+ if (wholePage) return {
67
+ x: 0,
68
+ y: 0,
69
+ width: document.documentElement.scrollWidth,
70
+ height: document.documentElement.scrollHeight,
71
+ scale
72
+ };
73
+ return {
74
+ x: scrollX,
75
+ y: scrollY,
76
+ width: innerWidth,
77
+ height: innerHeight,
78
+ scale
79
+ };
80
+ }, fullPage);
81
+ for (const quality of options.quality ?? [
82
+ 90,
83
+ 80,
84
+ 70
85
+ ]) {
86
+ const { data } = await session.send("Page.captureScreenshot", {
87
+ format: "webp",
88
+ quality,
89
+ captureBeyondViewport: fullPage,
90
+ clip
91
+ });
92
+ const bytes = Buffer.from(data, "base64");
93
+ if (options.limits?.maxBytes === void 0 || bytes.byteLength <= options.limits.maxBytes) return bytes;
94
+ }
95
+ } finally {
96
+ await session.detach();
97
+ }
98
+ throw new RenderRefusedError(`The image is larger than ${options.limits?.maxBytes} bytes at every quality tried.`);
99
+ }
100
+ async function contentHeight(tab) {
101
+ const width = tab.viewportSize()?.width ?? FLUID_WIDTH;
102
+ await tab.setViewportSize({
103
+ width,
104
+ height: 1
105
+ });
106
+ const height = await tab.evaluate(() => Math.max(document.documentElement.scrollHeight, document.body?.scrollHeight ?? 0));
107
+ await tab.setViewportSize({
108
+ width,
109
+ height
110
+ });
111
+ return height;
112
+ }
113
+ async function openRenderer(options) {
114
+ let browser;
115
+ try {
116
+ browser = await chromium.launch({
117
+ executablePath: options.executablePath,
118
+ args: [
119
+ ...options.args ?? [],
120
+ ...NO_NETWORK_ARGS,
121
+ ...options.jsHeapMb ? [`--js-flags=--max-old-space-size=${options.jsHeapMb}`] : []
122
+ ]
123
+ });
124
+ } catch (error) {
125
+ throw new RendererUnavailableError(`Chromium at ${options.executablePath} could not start: ${error.message}`);
126
+ }
127
+ const limit = options.concurrency ?? 2;
128
+ let running = 0;
129
+ const waiting = [];
130
+ const acquire = () => {
131
+ if (running < limit) {
132
+ running++;
133
+ return Promise.resolve();
134
+ }
135
+ return new Promise((resolve) => waiting.push(resolve));
136
+ };
137
+ const release = () => {
138
+ const next = waiting.shift();
139
+ if (next) next();
140
+ else running--;
141
+ };
142
+ async function withTab(setup, body) {
143
+ await acquire();
144
+ let context;
145
+ try {
146
+ return await withinBound(setup.boundMs ?? 6e4, (async () => {
147
+ context = await browser.newContext({
148
+ viewport: setup.viewport,
149
+ deviceScaleFactor: setup.scale ?? 1,
150
+ javaScriptEnabled: setup.scripts,
151
+ offline: true
152
+ });
153
+ await context.route("**/*", (route) => route.request().url().startsWith("data:") ? route.continue() : route.abort("blockedbyclient"));
154
+ await context.routeWebSocket("**/*", (socket) => socket.close());
155
+ const tab = await context.newPage();
156
+ let ready;
157
+ const readyPromise = new Promise((resolve) => {
158
+ ready = resolve;
159
+ });
160
+ if (setup.waitForReady) await tab.exposeFunction("pageReady", () => ready?.());
161
+ await tab.setContent(setup.html, { waitUntil: "load" });
162
+ if (setup.waitForReady) await withinBound(PAGE_READY_BOUND_MS, readyPromise).catch(() => {
163
+ throw new RenderRefusedError(`The page did not call window.pageReady() within ${PAGE_READY_BOUND_MS / 1e3}s.`);
164
+ });
165
+ await tab.evaluate(() => document.fonts.ready.then(() => true)).catch(() => {});
166
+ await setup.beforeOutput?.(tab);
167
+ return body(tab);
168
+ })());
169
+ } finally {
170
+ await context?.close().catch(() => {});
171
+ release();
172
+ }
173
+ }
174
+ function pageDocument(page, options) {
175
+ const css = `${fontFaces(options.fonts)}${options.css ?? ""}`;
176
+ return frameDocument(page.html, {
177
+ scripts: page.scripts,
178
+ nonce: newNonce(),
179
+ dir: page.dir,
180
+ lang: page.lang,
181
+ css,
182
+ relayKeys: false,
183
+ reportHeight: false
184
+ });
185
+ }
186
+ function pageViewport(page, width) {
187
+ if (page.print) {
188
+ const mm = sheetMm(page.print);
189
+ return {
190
+ width: Math.round(mm.width * PX_PER_MM),
191
+ height: Math.round(mm.height * PX_PER_MM)
192
+ };
193
+ }
194
+ if (page.size === "fluid") return {
195
+ width: width ?? FLUID_WIDTH,
196
+ height: 800
197
+ };
198
+ return {
199
+ width: page.size.width,
200
+ height: page.size.height === "auto" ? 800 : page.size.height
201
+ };
202
+ }
203
+ const image = captureImage;
204
+ return {
205
+ async pagePdf(input, opts = {}) {
206
+ const page = Page.parse(input);
207
+ const viewport = pageViewport(page);
208
+ return enforce(await withTab({
209
+ viewport,
210
+ scripts: page.scripts === "sandboxed",
211
+ html: pageDocument(page, opts),
212
+ waitForReady: page.scripts === "sandboxed" && opts.waitForPageReady !== false,
213
+ boundMs: opts.boundMs,
214
+ beforeOutput: opts.beforeOutput
215
+ }, async (tab) => {
216
+ if (page.print) {
217
+ const mm = sheetMm(page.print);
218
+ const margin = page.print.margin;
219
+ return tab.pdf({
220
+ width: `${mm.width}mm`,
221
+ height: `${mm.height}mm`,
222
+ printBackground: true,
223
+ preferCSSPageSize: opts.preferCssPageSize ?? false,
224
+ margin: margin ? {
225
+ top: `${margin.top}mm`,
226
+ right: `${margin.right}mm`,
227
+ bottom: `${margin.bottom}mm`,
228
+ left: `${margin.left}mm`
229
+ } : void 0
230
+ });
231
+ }
232
+ const fixedHeight = typeof page.size === "object" && page.size.height !== "auto" ? page.size.height : null;
233
+ const height = fixedHeight ?? await contentHeight(tab);
234
+ return tab.pdf({
235
+ width: `${viewport.width}px`,
236
+ height: `${height}px`,
237
+ printBackground: true,
238
+ pageRanges: fixedHeight ? void 0 : "1"
239
+ });
240
+ }), opts.limits);
241
+ },
242
+ async pageImage(input, opts = {}) {
243
+ const page = Page.parse(input);
244
+ const viewport = pageViewport(page, opts.width);
245
+ const grows = page.size === "fluid" || typeof page.size === "object" && page.size.height === "auto";
246
+ return withTab({
247
+ viewport,
248
+ scripts: page.scripts === "sandboxed",
249
+ scale: opts.scale,
250
+ html: pageDocument(page, opts),
251
+ waitForReady: page.scripts === "sandboxed" && opts.waitForPageReady !== false,
252
+ boundMs: opts.boundMs,
253
+ beforeOutput: opts.beforeOutput
254
+ }, async (tab) => {
255
+ if (grows) await contentHeight(tab);
256
+ return image(tab, opts, false);
257
+ });
258
+ },
259
+ async deckPdf(deck, opts = {}) {
260
+ const css = `${fontFaces(opts.fonts)}${opts.css ?? ""}`;
261
+ const html = printableDeck(deck, {
262
+ ...opts,
263
+ css
264
+ });
265
+ return enforce(await withTab({
266
+ viewport: opts.layout === "notes" ? pageViewport({
267
+ html: "",
268
+ size: "fluid",
269
+ scripts: "refuse",
270
+ print: opts.notesPaper ?? {
271
+ paper: "letter",
272
+ orientation: "portrait"
273
+ }
274
+ }) : deck.size,
275
+ scripts: deck.scripts === "sandboxed",
276
+ html,
277
+ waitForReady: false,
278
+ boundMs: opts.boundMs,
279
+ beforeOutput: opts.beforeOutput
280
+ }, (tab) => tab.pdf({
281
+ preferCSSPageSize: true,
282
+ printBackground: true
283
+ })), opts.limits);
284
+ },
285
+ async slideImage(deck, index, opts = {}) {
286
+ const slide = deck.slides[index];
287
+ if (!slide) throw new RangeError(`The deck has no slide ${index + 1}.`);
288
+ const single = {
289
+ ...deck,
290
+ slides: [slide]
291
+ };
292
+ const css = `${fontFaces(opts.fonts)}${opts.css ?? ""}`;
293
+ const html = printableDeck(single, {
294
+ inline: opts.inline,
295
+ css
296
+ });
297
+ return withTab({
298
+ viewport: deck.size,
299
+ scripts: deck.scripts === "sandboxed",
300
+ scale: opts.scale,
301
+ html,
302
+ waitForReady: false,
303
+ boundMs: opts.boundMs,
304
+ beforeOutput: opts.beforeOutput
305
+ }, (tab) => image(tab, opts, false));
306
+ },
307
+ close: () => browser.close()
308
+ };
309
+ }
310
+ //#endregion
311
+ export { CONTAINER_ARGS, PAGE_READY_BOUND_MS, RENDER_BOUND_MS, RenderRefusedError, RenderTimeoutError, RendererUnavailableError, captureImage, fontFaces, openRenderer };
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "@segnavia/render",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/modstudio/segnavia.git",
9
+ "directory": "packages/render"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "sideEffects": false,
15
+ "engines": {
16
+ "node": ">=22"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "exports": {
22
+ ".": {
23
+ "@segnavia/source": "./src/index.ts",
24
+ "types": "./dist/index.d.ts",
25
+ "default": "./dist/index.js"
26
+ }
27
+ },
28
+ "dependencies": {
29
+ "@segnavia/format": "0.1.0",
30
+ "@segnavia/frame": "0.1.0",
31
+ "playwright-core": "1.55.1",
32
+ "pdf-lib": "^1.17.1"
33
+ }
34
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.