@typfolio/core 0.1.0 → 0.2.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/README.md +16 -5
- package/dist/capture/dom.d.ts +8 -3
- package/dist/capture/dom.js +5 -9
- package/dist/capture/svg.d.ts +19 -6
- package/dist/capture/svg.js +67 -48
- package/dist/definition.d.ts +156 -0
- package/dist/definition.js +161 -0
- package/dist/engine.js +32 -10
- package/dist/explain.d.ts +2 -0
- package/dist/explain.js +42 -0
- package/dist/index.d.ts +18 -2
- package/dist/index.js +67 -4
- package/dist/internal.d.ts +2 -0
- package/dist/internal.js +2 -0
- package/dist/request.d.ts +3 -1
- package/dist/request.js +20 -12
- package/dist/types.d.ts +15 -2
- package/dist/types.js +2 -0
- package/dist/typst-package.d.ts +2 -0
- package/dist/typst-package.generated.d.ts +2 -2
- package/dist/typst-package.generated.js +2 -2
- package/dist/typst-package.js +2 -0
- package/package.json +11 -2
- package/typst-packages/local/typfolio/0.1.0/lib.typ +14 -5
- package/typst-packages/local/typfolio/0.1.0/sample.typ +58 -20
package/README.md
CHANGED
|
@@ -41,11 +41,22 @@ The font name in the template must match the supplied font. Fonts and WASM are l
|
|
|
41
41
|
- `copyAssets` (a `createCompiler` option, default `true`): by default `compile` copies every asset when it captures the request, so mutating, reusing, or detaching a buffer after calling `compile` cannot change the result. `copyAssets: false` mounts the caller's `Uint8Array` views as they are and skips that copy, which matters for multi-megabyte assets. The contract then is: the caller must not mutate, detach, or reuse an asset's buffer until the promise returned by `compile` settles. Validation of paths, types, reserved paths and JSON input is identical either way. `@typfolio/workers` sets it inside its workers, where the request arrives as a private transferred copy; set it yourself only when you own the buffers for the duration of the call.
|
|
42
42
|
- `pdf`: zero or one standard, tags, and creation timestamp in Unix seconds. Read `compiler.capabilities` for current support. Unsupported options are rejected.
|
|
43
43
|
- `compile(request, { signal })`: Promise of PDF bytes, diagnostics, and title metadata.
|
|
44
|
+
- A template made with `defineTemplate({ entrypoint?, files, input?, partials? })` takes `{ template, input, partials, assets?, pdf? }`, typed by its schemas (`TemplateRequest<typeof template>`). `defineInput(schema, path)` pairs a [Standard Schema](https://standardschema.dev) (Zod, Valibot, ArkType, …) with the path of its JSON Schema file; `definePartial({ files, input, partials? })` is Typst code with runtime data of its own. Each piece of data is checked against its schema before compiling (`INVALID_INPUT`, with the issues; an asynchronous schema is awaited) and mounted at its schema's path, where its file reads it with `input(json.with(...))`. Such a template compiles only the files it declares; a file it imports without declaring it fails with a message that says what to do. See `docs/template-authoring.md`.
|
|
45
|
+
- `createAttachments()`: `add(bytes, extension)` returns a path such as `/attachments/0.png` to put in the data, and `assets` holds the files for the request. The same bytes added twice share one path.
|
|
44
46
|
- `dispose()`: idempotent; stops new jobs and waits for accepted work.
|
|
45
47
|
|
|
48
|
+
`createLazyCompiler(options)` takes the same options but is synchronous and returns a `PdfClient`: nothing is downloaded until its first `compile`, which loads the WASM and fonts once for every later call. A failed load is not kept, so the next call tries again, and calls run one at a time in order, so callers never see `BUSY`. Create it once at module level and pass it to `PdfProvider`, or as `usePdf`'s `client`:
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
import { createLazyCompiler } from '@typfolio/core';
|
|
52
|
+
import wasmUrl from '@typfolio/wasm/compiler.wasm?url';
|
|
53
|
+
|
|
54
|
+
export const pdfClient = createLazyCompiler({ wasm: wasmUrl, fonts: ['/fonts/report.otf'] });
|
|
55
|
+
```
|
|
56
|
+
|
|
46
57
|
One active call is allowed per compiler. Concurrent calls reject with `TypstPdfError.code === 'BUSY'`. Core does not schedule jobs. A signal can cancel before synchronous execution; hard cancellation requires a worker. Upstream WASM initialization is shared per JS realm: reuse the same `wasm` value for multiple compilers in that realm.
|
|
47
58
|
|
|
48
|
-
`TypstPdfError` provides `code`, `message`, `diagnostics`, and (when relevant) `cause`. Compile diagnostic ranges use upstream zero-based line/column notation. Export diagnostics can have only text.
|
|
59
|
+
`TypstPdfError` provides `code`, `message`, `diagnostics`, and (when relevant) `cause`. A diagnostic Typfolio recognises carries a `code` of its own (`file-not-found` with `file`, `missing-glyph` with `text`) and a one-line message that links to what to do; switch on the codes, not the messages. [docs/errors.md](../../docs/errors.md) lists them all. Compile diagnostic ranges use upstream zero-based line/column notation. Export diagnostics can have only text.
|
|
49
60
|
|
|
50
61
|
Remote Typst packages (everything except the bundled one), page ranges, experimental feature flags, and simultaneous PDF standards are not implemented. The pinned upstream builder requires dynamic function construction, so a strict CSP that forbids `unsafe-eval` needs a future binding change. Importing the JS module alone does not instantiate WASM or fetch resources.
|
|
51
62
|
|
|
@@ -58,7 +69,7 @@ Remote Typst packages (everything except the bundled one), page ranges, experime
|
|
|
58
69
|
#let data = input(json.with("./report.schema.json"))
|
|
59
70
|
```
|
|
60
71
|
|
|
61
|
-
Under Typfolio `input()` returns `request.input` and never reads the schema. In an editor (where nothing sets `sys.inputs`) it imports the sample builder from `sample.typ`, inside the function body, and builds a sample from the JSON Schema so completion works; `--input typfolio-sample=minimal` previews the sparse sample instead of the default `full` one. The runtime embeds only `lib.typ`. `compiler.capabilities.typstPackages` is `['@local/typfolio:0.1.0']`; any other `@namespace/name:version` import fails with a `COMPILATION` error that names the supported package. The directory layout is the one Tinymist expects for `--package-path`; see `docs/template-authoring.md` for the editor setting. The shipped `.typ`/`.toml` files are the source of truth: `tools/generate-typst-package.
|
|
72
|
+
Under Typfolio `input()` returns `request.input` and never reads the schema; in a template made with `defineTemplate`, it returns the data mounted at the schema's path. In an editor (where nothing sets `sys.inputs`) it imports the sample builder from `sample.typ`, inside the function body, and builds a sample from the JSON Schema so completion works; `--input typfolio-sample=minimal` previews the sparse sample instead of the default `full` one. The runtime embeds only `lib.typ`. `compiler.capabilities.typstPackages` is `['@local/typfolio:0.1.0']`; any other `@namespace/name:version` import fails with a `COMPILATION` error that names the supported package. The directory layout is the one Tinymist expects for `--package-path`; see `docs/template-authoring.md` for the editor setting. The shipped `.typ`/`.toml` files are the source of truth: `tools/generate-typst-package.ts` (the first step of `npm run build`) generates `src/typst-package.generated.ts` (`typst.toml` and `lib.typ`, the runtime module) and `packages/dev-tools/src/sample-source.generated.ts` (`sample.typ`, imported only by `@typfolio/dev-tools`). Neither is edited by hand.
|
|
62
73
|
|
|
63
74
|
## Dev-time tools
|
|
64
75
|
|
|
@@ -69,8 +80,8 @@ Tools used while building and testing templates, not while serving requests, liv
|
|
|
69
80
|
Browser-only helpers live behind separate subpath entries so Node, worker, and server code that imports `@typfolio/core` never loads DOM code. The main entry exports none of them, and none touch browser globals at import time.
|
|
70
81
|
|
|
71
82
|
- `@typfolio/core/capture`: `captureProps`, `captureAssets`, `TypfolioCaptureError`, and the adapter and result types.
|
|
72
|
-
- `@typfolio/core/capture/svg`: `svgCapture`, a dependency-free adapter that serializes one inline SVG and preserves vector output.
|
|
73
|
-
- `@typfolio/core/capture/dom`: `domPngCapture`, an opt-in PNG fallback for arbitrary DOM. It loads `html2canvas` by dynamic import only when it first runs. `html2canvas` is an optional peer dependency: install it only if you use this adapter. If it is missing when the default rasterizer runs, the capture rejects with `CAPTURE_FAILED` and the import error as `cause`; passing your own `rasterize` never loads it.
|
|
83
|
+
- `@typfolio/core/capture/svg`: `svgCapture`, a dependency-free adapter that serializes one inline SVG and preserves vector output, and `serializeSvg(svg, { fontFamily?, beforeSerialize? })`, the same serializer for an SVG element you already hold. It inlines the computed paint and text styles (chart libraries often style SVGs from page CSS), rewrites `url(#id)` references to be local, writes the drawn size, and returns the file as bytes. The SVG must be in a document and have a rendered size. `beforeSerialize(svg)` runs on the copy just before it is written (remove a tooltip, add a `<title>`); the live SVG is never passed to it. `@typfolio/react`'s `usePdf` uses it for charts.
|
|
84
|
+
- `@typfolio/core/capture/dom`: `domPngCapture`, an opt-in PNG fallback for arbitrary DOM. It loads `html2canvas` by dynamic import only when it first runs. `html2canvas` is an optional peer dependency: install it only if you use this adapter. If it is missing when the default rasterizer runs, the capture rejects with `CAPTURE_FAILED` and the import error as `cause`; passing your own `rasterize` never loads it. `beforeRasterize(element)` runs on html2canvas's copy of the element before it is drawn, never on the live page.
|
|
74
85
|
|
|
75
86
|
Each marked region owns its adapter `kind` and its alt text. `captureProps` returns the data attributes (`data-typfolio-capture`, `data-typfolio-capture-kind`, and `data-typfolio-capture-alt` or `data-typfolio-capture-decorative`) to spread onto any element. They add no role or accessible name. `@typfolio/react/capture` offers a `CaptureRegion` component over the same attributes; plain DOM and other frameworks can spread `captureProps` directly.
|
|
76
87
|
|
|
@@ -89,7 +100,7 @@ for (const [name, value] of Object.entries(captureProps({
|
|
|
89
100
|
|
|
90
101
|
const captured = await captureAssets(document.querySelector('#report'), {
|
|
91
102
|
adapters: {
|
|
92
|
-
svg: svgCapture({
|
|
103
|
+
svg: svgCapture({ beforeSerialize: svg => svg.querySelector('.chart-tooltip')?.remove() }),
|
|
93
104
|
png: domPngCapture({ pixelRatio: 2, backgroundColor: '#fff' }),
|
|
94
105
|
},
|
|
95
106
|
require: ['performance-chart'],
|
package/dist/capture/dom.d.ts
CHANGED
|
@@ -5,7 +5,8 @@ export interface DomPngRasterizeOptions {
|
|
|
5
5
|
readonly useCORS: boolean;
|
|
6
6
|
readonly imageTimeoutMs?: number;
|
|
7
7
|
readonly logging: boolean;
|
|
8
|
-
|
|
8
|
+
/** Runs on the rasterizer's clone of the element, never on the live page. */
|
|
9
|
+
readonly beforeRasterize?: (element: HTMLElement) => void;
|
|
9
10
|
}
|
|
10
11
|
export type DomPngRasterizer = (element: HTMLElement, options: DomPngRasterizeOptions) => Promise<HTMLCanvasElement>;
|
|
11
12
|
export interface DomPngCaptureOptions {
|
|
@@ -16,8 +17,12 @@ export interface DomPngCaptureOptions {
|
|
|
16
17
|
readonly useCORS?: boolean;
|
|
17
18
|
readonly imageTimeoutMs?: number;
|
|
18
19
|
readonly logging?: boolean;
|
|
19
|
-
|
|
20
|
+
/**
|
|
21
|
+
* Runs on html2canvas's clone of the element before it is drawn: remove transient parts such
|
|
22
|
+
* as tooltips or focus rings. The live element on the page is never passed here.
|
|
23
|
+
*/
|
|
24
|
+
readonly beforeRasterize?: (element: HTMLElement) => void;
|
|
20
25
|
/** Custom-host and test escape hatch. */
|
|
21
26
|
readonly rasterize?: DomPngRasterizer;
|
|
22
27
|
}
|
|
23
|
-
export declare function domPngCapture({ pixelRatio, backgroundColor, useCORS, imageTimeoutMs, logging,
|
|
28
|
+
export declare function domPngCapture({ pixelRatio, backgroundColor, useCORS, imageTimeoutMs, logging, beforeRasterize, rasterize, }?: DomPngCaptureOptions): CaptureAdapter;
|
package/dist/capture/dom.js
CHANGED
|
@@ -47,10 +47,7 @@ async function defaultRasterizer(element, options, context) {
|
|
|
47
47
|
imageTimeout: options.imageTimeoutMs,
|
|
48
48
|
logging: options.logging,
|
|
49
49
|
onclone(_document, clonedElement) {
|
|
50
|
-
|
|
51
|
-
for (const removable of clonedElement.querySelectorAll(selector))
|
|
52
|
-
removable.remove();
|
|
53
|
-
}
|
|
50
|
+
options.beforeRasterize?.(clonedElement);
|
|
54
51
|
},
|
|
55
52
|
scale: options.pixelRatio,
|
|
56
53
|
useCORS: options.useCORS,
|
|
@@ -77,17 +74,16 @@ function pngBytes(canvas, context) {
|
|
|
77
74
|
}
|
|
78
75
|
});
|
|
79
76
|
}
|
|
80
|
-
export function domPngCapture({ pixelRatio, backgroundColor, useCORS = false, imageTimeoutMs, logging = false,
|
|
77
|
+
export function domPngCapture({ pixelRatio, backgroundColor, useCORS = false, imageTimeoutMs, logging = false, beforeRasterize, rasterize, } = {}) {
|
|
81
78
|
if (pixelRatio !== undefined && (!Number.isFinite(pixelRatio) || pixelRatio <= 0)) {
|
|
82
79
|
throw new TypfolioCaptureError('INVALID_OPTIONS', 'DOM pixelRatio must be greater than zero');
|
|
83
80
|
}
|
|
84
81
|
if (imageTimeoutMs !== undefined && (!Number.isInteger(imageTimeoutMs) || imageTimeoutMs < 0)) {
|
|
85
82
|
throw new TypfolioCaptureError('INVALID_OPTIONS', 'DOM imageTimeoutMs must be a non-negative integer');
|
|
86
83
|
}
|
|
87
|
-
if (
|
|
88
|
-
throw new TypfolioCaptureError('INVALID_OPTIONS', 'DOM
|
|
84
|
+
if (beforeRasterize !== undefined && typeof beforeRasterize !== 'function') {
|
|
85
|
+
throw new TypfolioCaptureError('INVALID_OPTIONS', 'DOM beforeRasterize must be a function');
|
|
89
86
|
}
|
|
90
|
-
const selectors = Object.freeze([...removeSelectors]);
|
|
91
87
|
return Object.freeze({
|
|
92
88
|
name: 'DOM PNG capture',
|
|
93
89
|
extensions: Object.freeze(['.png']),
|
|
@@ -107,7 +103,7 @@ export function domPngCapture({ pixelRatio, backgroundColor, useCORS = false, im
|
|
|
107
103
|
useCORS,
|
|
108
104
|
imageTimeoutMs,
|
|
109
105
|
logging,
|
|
110
|
-
|
|
106
|
+
beforeRasterize,
|
|
111
107
|
};
|
|
112
108
|
const canvas = rasterize === undefined
|
|
113
109
|
? await defaultRasterizer(element, rasterizeOptions, context)
|
package/dist/capture/svg.d.ts
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import { type CaptureAdapter } from '../capture.js';
|
|
2
|
-
export interface
|
|
2
|
+
export interface SvgSerializeOptions {
|
|
3
|
+
/** Overrides text/tspan fonts only in the serialized clone. */
|
|
4
|
+
readonly fontFamily?: string;
|
|
5
|
+
/**
|
|
6
|
+
* Runs on the clone just before it is serialized, after its styles are inlined and its size
|
|
7
|
+
* is set: remove transient parts such as tooltips, add a `<title>`, rewrite labels. The live
|
|
8
|
+
* SVG on the page is never passed here.
|
|
9
|
+
*/
|
|
10
|
+
readonly beforeSerialize?: (svg: SVGSVGElement) => void;
|
|
11
|
+
}
|
|
12
|
+
export interface SvgCaptureOptions extends SvgSerializeOptions {
|
|
3
13
|
/** Selects exactly one SVG inside the marked region. Defaults to `svg`. */
|
|
4
14
|
readonly selector?: string;
|
|
5
|
-
/** Overrides text/tspan fonts only in the exported clone. */
|
|
6
|
-
readonly fontFamily?: string;
|
|
7
|
-
/** Removes transient descendants such as chart tooltips from the clone. */
|
|
8
|
-
readonly removeSelectors?: readonly string[];
|
|
9
15
|
}
|
|
10
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Serializes a drawn SVG as a standalone file: a clone with the computed paint and text styles
|
|
18
|
+
* inlined (chart libraries often style SVGs from document CSS, which a standalone file does not
|
|
19
|
+
* have), local `url(#id)` references, and the size it was drawn at. The SVG must be in a
|
|
20
|
+
* document and have a rendered size.
|
|
21
|
+
*/
|
|
22
|
+
export declare function serializeSvg(svg: SVGSVGElement, options?: SvgSerializeOptions): Uint8Array;
|
|
23
|
+
export declare function svgCapture({ selector, fontFamily, beforeSerialize, }?: SvgCaptureOptions): CaptureAdapter;
|
package/dist/capture/svg.js
CHANGED
|
@@ -47,69 +47,88 @@ function findSvg(element, selector, context) {
|
|
|
47
47
|
}
|
|
48
48
|
return matches[0];
|
|
49
49
|
}
|
|
50
|
-
|
|
51
|
-
if (!selector.trim()) {
|
|
52
|
-
throw new TypfolioCaptureError('INVALID_OPTIONS', 'SVG selector must be non-empty');
|
|
53
|
-
}
|
|
50
|
+
function checkSerializeOptions({ fontFamily, beforeSerialize }) {
|
|
54
51
|
if (fontFamily !== undefined && !fontFamily.trim()) {
|
|
55
52
|
throw new TypfolioCaptureError('INVALID_OPTIONS', 'SVG fontFamily must be non-empty');
|
|
56
53
|
}
|
|
57
|
-
if (
|
|
58
|
-
throw new TypfolioCaptureError('INVALID_OPTIONS', 'SVG
|
|
54
|
+
if (beforeSerialize !== undefined && typeof beforeSerialize !== 'function') {
|
|
55
|
+
throw new TypfolioCaptureError('INVALID_OPTIONS', 'SVG beforeSerialize must be a function');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Serializes a drawn SVG as a standalone file: a clone with the computed paint and text styles
|
|
60
|
+
* inlined (chart libraries often style SVGs from document CSS, which a standalone file does not
|
|
61
|
+
* have), local `url(#id)` references, and the size it was drawn at. The SVG must be in a
|
|
62
|
+
* document and have a rendered size.
|
|
63
|
+
*/
|
|
64
|
+
export function serializeSvg(svg, options = {}) {
|
|
65
|
+
checkSerializeOptions(options);
|
|
66
|
+
const { fontFamily, beforeSerialize } = options;
|
|
67
|
+
const view = svg.ownerDocument.defaultView;
|
|
68
|
+
if (view === null) {
|
|
69
|
+
throw new TypfolioCaptureError('NOT_READY', 'The SVG document is no longer available');
|
|
59
70
|
}
|
|
60
|
-
const
|
|
71
|
+
const bounds = svg.getBoundingClientRect();
|
|
72
|
+
if (!(bounds.width > 0) || !(bounds.height > 0)) {
|
|
73
|
+
throw new TypfolioCaptureError('NOT_READY', 'The SVG has no rendered size');
|
|
74
|
+
}
|
|
75
|
+
const cloned = svg.cloneNode(true);
|
|
76
|
+
if (!isSvg(cloned)) {
|
|
77
|
+
throw new TypfolioCaptureError('CAPTURE_FAILED', 'The SVG could not be cloned');
|
|
78
|
+
}
|
|
79
|
+
const originals = [svg, ...svg.querySelectorAll('*')];
|
|
80
|
+
const copies = [cloned, ...cloned.querySelectorAll('*')];
|
|
81
|
+
originals.forEach((original, index) => {
|
|
82
|
+
const copy = copies[index];
|
|
83
|
+
const computed = view.getComputedStyle(original);
|
|
84
|
+
const isText = original.localName === 'text' || original.localName === 'tspan';
|
|
85
|
+
if (hasStyle(copy)) {
|
|
86
|
+
for (const property of svgStyles) {
|
|
87
|
+
const value = computed.getPropertyValue(property);
|
|
88
|
+
if (value)
|
|
89
|
+
copy.style.setProperty(property, value);
|
|
90
|
+
}
|
|
91
|
+
if (fontFamily && isText)
|
|
92
|
+
copy.style.setProperty('font-family', fontFamily);
|
|
93
|
+
}
|
|
94
|
+
if (fontFamily && isText)
|
|
95
|
+
copy.setAttribute('font-family', fontFamily);
|
|
96
|
+
for (const attribute of copy.attributes) {
|
|
97
|
+
copy.setAttribute(attribute.name, attribute.value.replace(/url\(["']?[^)#]*#([^)"' ]+)["']?\)/g, 'url(#$1)'));
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
for (const removable of cloned.querySelectorAll('script, foreignObject'))
|
|
101
|
+
removable.remove();
|
|
102
|
+
cloned.setAttribute('width', String(bounds.width));
|
|
103
|
+
cloned.setAttribute('height', String(bounds.height));
|
|
104
|
+
if (!cloned.hasAttribute('viewBox')) {
|
|
105
|
+
cloned.setAttribute('viewBox', `0 0 ${bounds.width} ${bounds.height}`);
|
|
106
|
+
}
|
|
107
|
+
beforeSerialize?.(cloned);
|
|
108
|
+
// The serializer declares the SVG namespace itself. An `xmlns` attribute on the clone (chart
|
|
109
|
+
// libraries such as Highcharts set one) can be written a second time: invalid XML.
|
|
110
|
+
cloned.removeAttribute('xmlns');
|
|
111
|
+
return encoder.encode(new view.XMLSerializer().serializeToString(cloned));
|
|
112
|
+
}
|
|
113
|
+
export function svgCapture({ selector = 'svg', fontFamily, beforeSerialize, } = {}) {
|
|
114
|
+
if (!selector.trim()) {
|
|
115
|
+
throw new TypfolioCaptureError('INVALID_OPTIONS', 'SVG selector must be non-empty');
|
|
116
|
+
}
|
|
117
|
+
const serializeOptions = { fontFamily, beforeSerialize };
|
|
118
|
+
checkSerializeOptions(serializeOptions);
|
|
61
119
|
return Object.freeze({
|
|
62
120
|
name: 'SVG capture',
|
|
63
121
|
extensions: Object.freeze(['.svg']),
|
|
64
122
|
capture(element, context) {
|
|
65
123
|
const svg = findSvg(element, selector, context);
|
|
66
|
-
|
|
67
|
-
if (view === null) {
|
|
124
|
+
if (svg.ownerDocument.defaultView === null) {
|
|
68
125
|
throw new TypfolioCaptureError('NOT_READY', 'The SVG document is no longer available', context.captureId, context.path);
|
|
69
126
|
}
|
|
70
127
|
const bounds = svg.getBoundingClientRect();
|
|
71
128
|
if (!(bounds.width > 0) || !(bounds.height > 0)) {
|
|
72
129
|
throw new TypfolioCaptureError('NOT_READY', `Capture ${context.captureId} has no rendered size`, context.captureId, context.path);
|
|
73
130
|
}
|
|
74
|
-
|
|
75
|
-
if (!isSvg(cloned)) {
|
|
76
|
-
throw new TypfolioCaptureError('CAPTURE_FAILED', `Capture ${context.captureId} could not clone its SVG`, context.captureId, context.path);
|
|
77
|
-
}
|
|
78
|
-
const originals = [svg, ...svg.querySelectorAll('*')];
|
|
79
|
-
const copies = [cloned, ...cloned.querySelectorAll('*')];
|
|
80
|
-
originals.forEach((original, index) => {
|
|
81
|
-
const copy = copies[index];
|
|
82
|
-
const computed = view.getComputedStyle(original);
|
|
83
|
-
if (hasStyle(copy)) {
|
|
84
|
-
for (const property of svgStyles) {
|
|
85
|
-
const value = computed.getPropertyValue(property);
|
|
86
|
-
if (value)
|
|
87
|
-
copy.style.setProperty(property, value);
|
|
88
|
-
}
|
|
89
|
-
if (fontFamily && (original.localName === 'text' || original.localName === 'tspan')) {
|
|
90
|
-
copy.style.setProperty('font-family', fontFamily);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
if (fontFamily && (original.localName === 'text' || original.localName === 'tspan')) {
|
|
94
|
-
copy.setAttribute('font-family', fontFamily);
|
|
95
|
-
}
|
|
96
|
-
for (const attribute of copy.attributes) {
|
|
97
|
-
copy.setAttribute(attribute.name, attribute.value.replace(/url\(["']?[^)#]*#([^)"' ]+)["']?\)/g, 'url(#$1)'));
|
|
98
|
-
}
|
|
99
|
-
});
|
|
100
|
-
for (const removable of cloned.querySelectorAll('script, foreignObject'))
|
|
101
|
-
removable.remove();
|
|
102
|
-
for (const removableSelector of selectors) {
|
|
103
|
-
for (const removable of cloned.querySelectorAll(removableSelector))
|
|
104
|
-
removable.remove();
|
|
105
|
-
}
|
|
106
|
-
cloned.setAttribute('xmlns', svgNamespace);
|
|
107
|
-
cloned.setAttribute('width', String(bounds.width));
|
|
108
|
-
cloned.setAttribute('height', String(bounds.height));
|
|
109
|
-
if (!cloned.hasAttribute('viewBox')) {
|
|
110
|
-
cloned.setAttribute('viewBox', `0 0 ${bounds.width} ${bounds.height}`);
|
|
111
|
-
}
|
|
112
|
-
return encoder.encode(new view.XMLSerializer().serializeToString(cloned));
|
|
131
|
+
return serializeSvg(svg, serializeOptions);
|
|
113
132
|
},
|
|
114
133
|
});
|
|
115
134
|
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { type PdfOptions, type PdfRequest } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The Standard Schema interface (https://standardschema.dev), declared here so core depends on no
|
|
4
|
+
* schema library: Zod, Valibot, ArkType and others implement it.
|
|
5
|
+
*/
|
|
6
|
+
export interface StandardSchemaV1<Input = unknown, Output = Input> {
|
|
7
|
+
readonly '~standard': {
|
|
8
|
+
readonly version: 1;
|
|
9
|
+
readonly vendor: string;
|
|
10
|
+
readonly validate: (value: unknown) => StandardSchemaResult<Output> | Promise<StandardSchemaResult<Output>>;
|
|
11
|
+
readonly types?: {
|
|
12
|
+
readonly input: Input;
|
|
13
|
+
readonly output: Output;
|
|
14
|
+
} | undefined;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export type StandardSchemaResult<Output> = {
|
|
18
|
+
readonly value: Output;
|
|
19
|
+
readonly issues?: undefined;
|
|
20
|
+
} | {
|
|
21
|
+
readonly issues: readonly StandardSchemaIssue[];
|
|
22
|
+
};
|
|
23
|
+
export interface StandardSchemaIssue {
|
|
24
|
+
readonly message: string;
|
|
25
|
+
readonly path?: readonly (PropertyKey | {
|
|
26
|
+
readonly key: PropertyKey;
|
|
27
|
+
})[] | undefined;
|
|
28
|
+
}
|
|
29
|
+
/** What a schema accepts: the data a caller passes. */
|
|
30
|
+
export type InferSchemaInput<S> = S extends StandardSchemaV1<infer Input, unknown> ? Input : unknown;
|
|
31
|
+
/**
|
|
32
|
+
* A runtime input of a template or a partial: the data, and where its JSON Schema file sits in the
|
|
33
|
+
* template's file tree. The Typst file reads it with `input(json.with("./<file>.schema.json"))`: in
|
|
34
|
+
* an editor that file is the schema (for a sample), and when Typfolio compiles, the data is mounted
|
|
35
|
+
* at the same path. The path is the input's identity: nothing else names it.
|
|
36
|
+
*/
|
|
37
|
+
export interface TemplateInput<T = unknown> {
|
|
38
|
+
/** Virtual path of the schema file, relative to the template root, e.g. `common/header.schema.json`. */
|
|
39
|
+
readonly path: string;
|
|
40
|
+
/** Validates the data before compiling, and types it. Optional. */
|
|
41
|
+
readonly schema?: StandardSchemaV1<T, unknown>;
|
|
42
|
+
/** Type-only. */
|
|
43
|
+
readonly '~type'?: T;
|
|
44
|
+
}
|
|
45
|
+
/** Pairs a schema with the virtual path of its JSON Schema file. */
|
|
46
|
+
export declare function defineInput<S extends StandardSchemaV1>(schema: S, path: string): TemplateInput<InferSchemaInput<S>>;
|
|
47
|
+
/** Typst code that takes runtime data of its own: its files, its input, and the partials it uses. */
|
|
48
|
+
export interface PartialDefinition<T = unknown, P extends PartialsMap = PartialsMap> {
|
|
49
|
+
readonly kind: 'typfolio-partial';
|
|
50
|
+
readonly files: Readonly<Record<string, string>>;
|
|
51
|
+
readonly input: TemplateInput<T>;
|
|
52
|
+
readonly partials: P;
|
|
53
|
+
}
|
|
54
|
+
export type PartialsMap = Readonly<Record<string, PartialDefinition<any, any>>>;
|
|
55
|
+
type UnionToIntersection<U> = (U extends unknown ? (arg: U) => void : never) extends (arg: infer I) => void ? I : never;
|
|
56
|
+
/** The data a map of partials needs, nested partials included, by key. */
|
|
57
|
+
export type PartialsData<P extends PartialsMap> = {
|
|
58
|
+
[K in keyof P]: P[K] extends PartialDefinition<infer T, PartialsMap> ? T : never;
|
|
59
|
+
} & UnionToIntersection<{
|
|
60
|
+
[K in keyof P]: P[K] extends PartialDefinition<unknown, infer N> ? PartialsData<N> : never;
|
|
61
|
+
}[keyof P]>;
|
|
62
|
+
export declare function definePartial<T, P extends PartialsMap = Record<never, never>>(definition: {
|
|
63
|
+
readonly files: Readonly<Record<string, string>>;
|
|
64
|
+
readonly input: TemplateInput<T>;
|
|
65
|
+
readonly partials?: P;
|
|
66
|
+
}): PartialDefinition<T, P>;
|
|
67
|
+
/** One runtime input of a compiled template: `key` is `null` for the template's own input. */
|
|
68
|
+
export interface TemplateInputSlot {
|
|
69
|
+
readonly key: string | null;
|
|
70
|
+
readonly path: string;
|
|
71
|
+
readonly schema?: StandardSchemaV1;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* A template: its Typst files (its own and every partial's), its entrypoint, and its runtime inputs.
|
|
75
|
+
* `Input` and `Partials` type a request for it.
|
|
76
|
+
*/
|
|
77
|
+
export interface TemplateDefinition<Input = undefined, Partials = Record<never, never>> {
|
|
78
|
+
readonly kind: 'typfolio-template';
|
|
79
|
+
readonly entrypoint: string;
|
|
80
|
+
readonly files: Readonly<Record<string, string>>;
|
|
81
|
+
readonly inputs: readonly TemplateInputSlot[];
|
|
82
|
+
/** Type-only. */
|
|
83
|
+
readonly '~types'?: {
|
|
84
|
+
readonly input: Input;
|
|
85
|
+
readonly partials: Partials;
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
export declare function defineTemplate<I = undefined, P extends PartialsMap = Record<never, never>>(definition: {
|
|
89
|
+
/** Defaults to `main.typ`. */
|
|
90
|
+
readonly entrypoint?: string;
|
|
91
|
+
readonly files: Readonly<Record<string, string>>;
|
|
92
|
+
readonly input?: TemplateInput<I>;
|
|
93
|
+
readonly partials?: P;
|
|
94
|
+
}): TemplateDefinition<I, PartialsData<P>>;
|
|
95
|
+
export declare function isTemplateDefinition(value: unknown): value is TemplateDefinition<unknown, unknown>;
|
|
96
|
+
/** A request for a defined template, typed by it: its input and its partials' data. */
|
|
97
|
+
export type TemplateRequest<T> = T extends TemplateDefinition<infer I, infer P> ? {
|
|
98
|
+
template: T;
|
|
99
|
+
assets?: Readonly<Record<string, Uint8Array>>;
|
|
100
|
+
pdf?: PdfOptions;
|
|
101
|
+
} & ([I] extends [undefined] ? {
|
|
102
|
+
input?: undefined;
|
|
103
|
+
} : unknown extends I ? {
|
|
104
|
+
input?: unknown;
|
|
105
|
+
} : {
|
|
106
|
+
input: I;
|
|
107
|
+
}) & ([keyof P] extends [never] ? {
|
|
108
|
+
partials?: Record<never, never>;
|
|
109
|
+
} : {
|
|
110
|
+
partials: P;
|
|
111
|
+
}) : never;
|
|
112
|
+
/**
|
|
113
|
+
* What `compile` checks a request against: one for a template made with `defineTemplate` must be its
|
|
114
|
+
* `TemplateRequest`; any other request is accepted as it is.
|
|
115
|
+
*/
|
|
116
|
+
export type CheckedRequest<R> = R extends {
|
|
117
|
+
template: infer T;
|
|
118
|
+
} ? T extends TemplateDefinition<unknown, unknown> ? TemplateRequest<T> : unknown : unknown;
|
|
119
|
+
/** Any request a client accepts: a plain one, one for a defined template, or the low-level form. */
|
|
120
|
+
export type AnyPdfRequest = PdfRequest<unknown> | DocumentsRequest | {
|
|
121
|
+
template: TemplateDefinition<unknown, unknown>;
|
|
122
|
+
input?: unknown;
|
|
123
|
+
partials?: Readonly<Record<string, unknown>>;
|
|
124
|
+
assets?: Readonly<Record<string, Uint8Array>>;
|
|
125
|
+
pdf?: PdfOptions;
|
|
126
|
+
};
|
|
127
|
+
/** The low-level form of a request: documents mounted at their schema paths. */
|
|
128
|
+
export interface DocumentsRequest {
|
|
129
|
+
template: {
|
|
130
|
+
entrypoint: string;
|
|
131
|
+
files: Readonly<Record<string, string>>;
|
|
132
|
+
};
|
|
133
|
+
input: null;
|
|
134
|
+
documents: Readonly<Record<string, unknown>>;
|
|
135
|
+
assets?: Readonly<Record<string, Uint8Array>>;
|
|
136
|
+
pdf?: PdfOptions;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Turns a request for a defined template into its low-level form, checking each document against its
|
|
140
|
+
* schema. A schema that validates synchronously throws here, before anything is captured; one that
|
|
141
|
+
* returns a promise is checked by the returned `validated`, which the caller awaits before compiling.
|
|
142
|
+
* A plain request passes through.
|
|
143
|
+
*/
|
|
144
|
+
export declare function resolveRequest(request: AnyPdfRequest): {
|
|
145
|
+
request: PdfRequest<unknown> | DocumentsRequest;
|
|
146
|
+
validated?: Promise<void>;
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* Files a request carries, referenced from its data by path: `add(bytes, "png")` returns
|
|
150
|
+
* `/attachments/0.png` and keeps the bytes for `assets`. The same bytes added twice share one path.
|
|
151
|
+
*/
|
|
152
|
+
export declare function createAttachments(directory?: string): {
|
|
153
|
+
add(bytes: Uint8Array, extension: string): string;
|
|
154
|
+
readonly assets: Readonly<Record<string, Uint8Array>>;
|
|
155
|
+
};
|
|
156
|
+
export {};
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { TypstPdfError } from './types.js';
|
|
2
|
+
/** Pairs a schema with the virtual path of its JSON Schema file. */
|
|
3
|
+
export function defineInput(schema, path) {
|
|
4
|
+
if (!isStandardSchema(schema))
|
|
5
|
+
throw new TypeError('defineInput: schema must implement Standard Schema (~standard)');
|
|
6
|
+
return Object.freeze({ path: relativePath(path), schema: schema });
|
|
7
|
+
}
|
|
8
|
+
export function definePartial(definition) {
|
|
9
|
+
const { files, input, partials = {} } = definition ?? {};
|
|
10
|
+
if (!input || typeof input.path !== 'string')
|
|
11
|
+
throw new TypeError('definePartial: input must come from defineInput(schema, path)');
|
|
12
|
+
return Object.freeze({ kind: 'typfolio-partial', files: Object.freeze({ ...files }), input, partials: Object.freeze({ ...partials }) });
|
|
13
|
+
}
|
|
14
|
+
export function defineTemplate(definition) {
|
|
15
|
+
const { entrypoint = 'main.typ', files, input, partials = {} } = definition ?? {};
|
|
16
|
+
const merged = { ...files };
|
|
17
|
+
const slots = [];
|
|
18
|
+
const paths = new Map();
|
|
19
|
+
const claim = (path, owner) => {
|
|
20
|
+
const previous = paths.get(path);
|
|
21
|
+
if (previous !== undefined)
|
|
22
|
+
throw new TypeError(`defineTemplate: ${owner} and ${previous} both read their input from ${path}`);
|
|
23
|
+
paths.set(path, owner);
|
|
24
|
+
};
|
|
25
|
+
if (input !== undefined) {
|
|
26
|
+
if (typeof input.path !== 'string')
|
|
27
|
+
throw new TypeError('defineTemplate: input must come from defineInput(schema, path)');
|
|
28
|
+
const path = relativePath(input.path);
|
|
29
|
+
claim(path, 'the template');
|
|
30
|
+
slots.push({ key: null, path, ...(input.schema ? { schema: input.schema } : {}) });
|
|
31
|
+
}
|
|
32
|
+
// Partials, nested ones included, each once: the same definition under one key may be reached twice.
|
|
33
|
+
const byKey = new Map();
|
|
34
|
+
const visit = (map, trail) => {
|
|
35
|
+
for (const [key, partial] of Object.entries(map)) {
|
|
36
|
+
if (!partial || partial.kind !== 'typfolio-partial')
|
|
37
|
+
throw new TypeError(`defineTemplate: ${trail}${key} is not a definePartial(...)`);
|
|
38
|
+
const known = byKey.get(key);
|
|
39
|
+
if (known === partial)
|
|
40
|
+
continue;
|
|
41
|
+
if (known !== undefined)
|
|
42
|
+
throw new TypeError(`defineTemplate: two different partials are both named "${key}"`);
|
|
43
|
+
byKey.set(key, partial);
|
|
44
|
+
const path = relativePath(partial.input.path);
|
|
45
|
+
claim(path, `partial "${key}"`);
|
|
46
|
+
slots.push({ key, path, ...(partial.input.schema ? { schema: partial.input.schema } : {}) });
|
|
47
|
+
for (const [path, source] of Object.entries(partial.files)) {
|
|
48
|
+
if (path in merged && merged[path] !== source)
|
|
49
|
+
throw new TypeError(`defineTemplate: ${path} is given twice with different contents (partial "${key}")`);
|
|
50
|
+
merged[path] = source;
|
|
51
|
+
}
|
|
52
|
+
visit(partial.partials, `${trail}${key}.`);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
visit(partials, '');
|
|
56
|
+
for (const path of paths.keys()) {
|
|
57
|
+
if (Object.keys(merged).some(file => relativePath(file) === path)) {
|
|
58
|
+
throw new TypeError(`defineTemplate: ${path} is an input path; do not list the schema file in files (it is not shipped, the data is mounted there)`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return Object.freeze({
|
|
62
|
+
kind: 'typfolio-template',
|
|
63
|
+
entrypoint,
|
|
64
|
+
files: Object.freeze(merged),
|
|
65
|
+
inputs: Object.freeze(slots),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export function isTemplateDefinition(value) {
|
|
69
|
+
return typeof value === 'object' && value !== null && value.kind === 'typfolio-template';
|
|
70
|
+
}
|
|
71
|
+
function invalidInput(slot, issues) {
|
|
72
|
+
const owner = slot.key === null ? 'The template input' : `The data of partial "${slot.key}"`;
|
|
73
|
+
const lines = issues.slice(0, 10).map(issue => {
|
|
74
|
+
const path = (issue.path ?? []).map(part => (typeof part === 'object' ? part.key : part)).map(String).join('.');
|
|
75
|
+
return `- ${path ? `${path}: ` : ''}${issue.message}`;
|
|
76
|
+
});
|
|
77
|
+
const more = issues.length > 10 ? `\n- … ${issues.length - 10} more` : '';
|
|
78
|
+
return Object.assign(new TypstPdfError('INVALID_INPUT', `${owner} does not match its schema (${slot.path}):\n${lines.join('\n')}${more}`, [], { cause: issues }), { issues });
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Turns a request for a defined template into its low-level form, checking each document against its
|
|
82
|
+
* schema. A schema that validates synchronously throws here, before anything is captured; one that
|
|
83
|
+
* returns a promise is checked by the returned `validated`, which the caller awaits before compiling.
|
|
84
|
+
* A plain request passes through.
|
|
85
|
+
*/
|
|
86
|
+
export function resolveRequest(request) {
|
|
87
|
+
if (!request || !isTemplateDefinition(request.template))
|
|
88
|
+
return { request: request };
|
|
89
|
+
const { template, partials = {} } = request;
|
|
90
|
+
for (const key of Object.keys(partials)) {
|
|
91
|
+
if (!template.inputs.some(slot => slot.key === key)) {
|
|
92
|
+
throw new TypstPdfError('INVALID_REQUEST', `partials.${key}: this template has no partial "${key}"; it has ${template.inputs.filter(slot => slot.key !== null).map(slot => `"${slot.key}"`).join(', ') || 'none'}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const documents = {};
|
|
96
|
+
const pending = [];
|
|
97
|
+
for (const slot of template.inputs) {
|
|
98
|
+
const value = slot.key === null ? request.input : partials[slot.key];
|
|
99
|
+
if (value === undefined) {
|
|
100
|
+
throw new TypstPdfError('INVALID_REQUEST', slot.key === null
|
|
101
|
+
? `The template reads its input from ${slot.path}, but the request has no input`
|
|
102
|
+
: `Partial "${slot.key}" reads its input from ${slot.path}, but the request has no partials.${slot.key}`);
|
|
103
|
+
}
|
|
104
|
+
documents[slot.path] = value;
|
|
105
|
+
if (slot.schema) {
|
|
106
|
+
const result = slot.schema['~standard'].validate(value);
|
|
107
|
+
if (result instanceof Promise) {
|
|
108
|
+
pending.push(result.then(settled => { if (settled.issues)
|
|
109
|
+
throw invalidInput(slot, settled.issues); }));
|
|
110
|
+
}
|
|
111
|
+
else if (result.issues) {
|
|
112
|
+
throw invalidInput(slot, result.issues);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const resolved = {
|
|
117
|
+
template: { entrypoint: template.entrypoint, files: template.files },
|
|
118
|
+
input: null,
|
|
119
|
+
documents,
|
|
120
|
+
...(request.assets === undefined ? {} : { assets: request.assets }),
|
|
121
|
+
...(request.pdf === undefined ? {} : { pdf: request.pdf }),
|
|
122
|
+
};
|
|
123
|
+
return pending.length ? { request: resolved, validated: Promise.all(pending).then(() => undefined) } : { request: resolved };
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Files a request carries, referenced from its data by path: `add(bytes, "png")` returns
|
|
127
|
+
* `/attachments/0.png` and keeps the bytes for `assets`. The same bytes added twice share one path.
|
|
128
|
+
*/
|
|
129
|
+
export function createAttachments(directory = 'attachments') {
|
|
130
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(directory) || directory.endsWith('/') || directory.includes('..')) {
|
|
131
|
+
throw new TypeError(`createAttachments: directory must be a relative path, got ${JSON.stringify(directory)}`);
|
|
132
|
+
}
|
|
133
|
+
const assets = {};
|
|
134
|
+
const seen = new Map();
|
|
135
|
+
return {
|
|
136
|
+
add(bytes, extension) {
|
|
137
|
+
if (!(bytes instanceof Uint8Array))
|
|
138
|
+
throw new TypeError('attach: bytes must be a Uint8Array');
|
|
139
|
+
const ext = String(extension).replace(/^\./, '').toLowerCase();
|
|
140
|
+
if (!/^[a-z0-9]+$/.test(ext))
|
|
141
|
+
throw new TypeError(`attach: the extension must be letters and digits, got ${JSON.stringify(extension)}`);
|
|
142
|
+
const known = seen.get(bytes);
|
|
143
|
+
if (known !== undefined)
|
|
144
|
+
return known;
|
|
145
|
+
const name = `${directory}/${seen.size}.${ext}`;
|
|
146
|
+
seen.set(bytes, `/${name}`);
|
|
147
|
+
assets[name] = bytes;
|
|
148
|
+
return `/${name}`;
|
|
149
|
+
},
|
|
150
|
+
get assets() {
|
|
151
|
+
return assets;
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function isStandardSchema(value) {
|
|
156
|
+
return typeof value === 'object' && value !== null && typeof value['~standard']?.validate === 'function';
|
|
157
|
+
}
|
|
158
|
+
// Paths are checked when compiling, where every file and input path is; here they are only compared.
|
|
159
|
+
function relativePath(path) {
|
|
160
|
+
return String(path).replace(/^\/+/, '');
|
|
161
|
+
}
|