@asterdoc/render 0.0.0-stage → 0.9.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,25 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Paradoc AI, Inc.
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.
22
+
23
+ ---
24
+
25
+ **Trademark Notice**: This license does not grant permission to use the trade names, trademarks, service marks, logos, taglines, or product names of Asterdoc, except as required for reasonable and customary use in describing the origin of the Software.
package/README.md CHANGED
@@ -1,3 +1,265 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <a href="https://asterdoc.com?utm_source=github&utm_medium=render" target="_blank" rel="noopener noreferrer">
3
+ <picture>
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://assets.asterdoc.com/asterdoc-logo-400x400-dark.png" type="image/png">
5
+ <img src="https://assets.asterdoc.com/asterdoc-logo-400x400-dark.png" height="64" alt="Asterdoc logo">
6
+ </picture>
7
+ </a>
8
+ <br />
9
+ </p>
2
10
 
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.
11
+ <h1 align="center">@asterdoc/render</h1>
12
+
13
+ <div align="center">
14
+
15
+ [![Asterdoc documentation](https://img.shields.io/badge/Documentation-Asterdoc-red.svg)](https://docs.asterdoc.com?utm_source=github&utm_medium=render)
16
+ [![Follow on Twitter](https://img.shields.io/twitter/follow/asterdoc?style=social)](https://x.com/asterdoc)
17
+
18
+ </div>
19
+
20
+ Dependency-light text, PDF, and DOCX rendering for Asterdoc. It works in modern
21
+ browsers and on the server. Forms use the MIME-selected renderer automatically
22
+ and load only the selected layer engine.
23
+
24
+ ```ts
25
+ const output = await filled.render({ layer: 'final' })
26
+ ```
27
+
28
+ Pass `createLayerRenderer()` explicitly when configuring the built-in renderer or
29
+ provide any compatible custom renderer as an override.
30
+
31
+ ```ts
32
+ import { createLayerRenderer } from '@asterdoc/render'
33
+
34
+ const output = await filled.render({
35
+ renderer: createLayerRenderer({ serializers }),
36
+ layer: 'final'
37
+ })
38
+ ```
39
+
40
+ Use a format-specific entry point for inspection, overlays, or other
41
+ format-specific operations so an application only bundles that implementation.
42
+
43
+ ```ts
44
+ import { renderText } from '@asterdoc/render/text'
45
+ import { renderPdf } from '@asterdoc/render/pdf'
46
+ import { renderDocx } from '@asterdoc/render/docx'
47
+ ```
48
+
49
+ `createLayerRenderer()` supports `text/plain`, `text/markdown`, `text/html`,
50
+ `application/pdf`, and DOCX Office MIME types. It fails loudly for an unknown
51
+ MIME type rather than guessing.
52
+
53
+ ## Text templates
54
+
55
+ Text templates keep the block markers `{{#if}}`, `{{#unless}}`, `{{else}}`, and
56
+ `{{#each}}`. Everything inside a marker is an artifact expression from
57
+ `@asterdoc/expr`, the language field logic uses, so a condition means the same in
58
+ a template as in a rule. Templates do not execute arbitrary JavaScript.
59
+
60
+ ```text
61
+ {{#if fields.approved and fields.total.amount > 1000}}
62
+ Approved for {{parties.owner.name}}: {{fields.total}}
63
+ {{else}}
64
+ Pending
65
+ {{/if}}
66
+
67
+ {{#each fields.items}}
68
+ - {{index(item) + 1}}. {{item.name}}{{#unless last(item)}},{{/unless}}
69
+ {{/each}}
70
+ ```
71
+
72
+ Inside `{{#each}}`, `item` is the row and `parent` the enclosing row. A
73
+ condition must be boolean and a loop source a list. `checkTextTemplate()` and
74
+ `checkDocxTemplate()` check every expression against a type environment, and a
75
+ failed render throws `TemplateError` with the layer, position, and expression.
76
+
77
+ ## PDF forms and overlays
78
+
79
+ A PDF render can fill AcroForm fields, add coordinate-based text or images, or
80
+ combine all three.
81
+ Overlay page numbers are one-based and coordinates use PDF points measured from
82
+ the bottom-left corner.
83
+
84
+ ```ts
85
+ const output = await renderPdf({
86
+ template,
87
+ form,
88
+ data: { name: 'Ada Lovelace' },
89
+ bindings: { legal_name: 'name' },
90
+ overlays: [
91
+ { page: 1, x: 48, y: 72, text: 'Prepared for' },
92
+ { page: 1, x: 48, y: 54, field: 'name', fontSize: 11 },
93
+ {
94
+ page: 1,
95
+ x: 48,
96
+ y: 20,
97
+ width: 120,
98
+ height: 30,
99
+ image: signaturePng,
100
+ mediaType: 'image/png',
101
+ fit: 'contain',
102
+ },
103
+ ],
104
+ })
105
+ ```
106
+
107
+ `inspectAcroFormFields()` discovers fields without modifying a PDF.
108
+ `inspectPdf()` returns its page count and page dimensions. PNG overlays support
109
+ non-interlaced 8-bit grayscale, RGB, grayscale-alpha, and RGBA images; JPEG
110
+ overlays are also supported.
111
+
112
+ A filled field follows its own settings: declared font size or auto-size,
113
+ alignment, color, comb boxes, and multiline wrapping; a list box shows every
114
+ selected value. A value that does not fit shrinks to `MIN_FONT_SIZE` (6 points).
115
+ Past that, or with more characters than a comb field has boxes, the render throws
116
+ `PdfFieldFillError` with the field name, the `reason` (`overflow` or
117
+ `comb-length`), and the `limit`.
118
+
119
+ Each value, and each overlay text, uses the first font that can draw all of it:
120
+ the overlay's own `font` when it names one, then `font` (supplied at render
121
+ time), then `layerFont` (the font the artifact's PDF layer declares), then the
122
+ form's own embedded font for the field, then Helvetica for Latin-1 text. Fonts are TrueType programs given as `{ bytes, source }`, and
123
+ every font used is embedded. A character no font can draw fails with reason
124
+ `missing-glyph`; scripts that need shaping, such as Arabic or Devanagari, fail
125
+ with `unsupported-script` whatever font is available. A font that cannot be read,
126
+ is not TrueType, or forbids embedding fails with `PdfFontError` naming its
127
+ `source`.
128
+
129
+ `pdfFontMetrics(font)` measures text in a supplied font before you draw it:
130
+ `canDraw(text)` says whether overlays can draw every character, and
131
+ `width(text, size)` gives its width in points, so you can wrap overlay text
132
+ yourself.
133
+
134
+ ## Reading filled forms back
135
+
136
+ `extractPdfData()` reads a filled PDF's AcroForm fields back through a layer's
137
+ bindings into artifact data, with a report for each binding target. Combined
138
+ bindings are reported as not recoverable rather than split. Most callers use
139
+ `form.extract()` from `@asterdoc/core`, which also chooses the PDF layer.
140
+
141
+ ```ts
142
+ const { data, report } = await extractPdfData({ pdf: filled, form, bindings })
143
+ ```
144
+
145
+ ## Merging PDFs
146
+
147
+ `mergePdfs()` concatenates PDFs into one document, keeping every page in the
148
+ order given at its own size.
149
+
150
+ ```ts
151
+ const packet = await mergePdfs([purchaseOrder, filledW9, certificate])
152
+ ```
153
+
154
+ It writes a fresh document rather than an incremental update, because two
155
+ sources number their objects from one and an incremental update cannot hold
156
+ both. Every object a kept page can reach is copied with a new number and every
157
+ reference inside it rewritten; stream bytes are copied exactly as stored, so
158
+ nothing is re-encoded. The merge is deterministic: the same sources produce the
159
+ same bytes.
160
+
161
+ **What a merge drops.** Everything the old catalog held rather than the page:
162
+
163
+ | Dropped | Consequence |
164
+ | --- | --- |
165
+ | `AcroForm` | Field state is gone. Flatten a filled form with `flattenPdf()` first and its values arrive as page content. |
166
+ | `StructTreeRoot` and the role map | The pages are untagged. A copied page keeps its `StructParents` key, which then dangles; readers ignore it, screen readers lose the structure. |
167
+ | `Names` and `Dests` | A named destination no longer resolves, so a link that used one goes nowhere. |
168
+ | `OCProperties` | A page drawn with optional content loses the configuration deciding which layers are visible, and every layer paints. |
169
+ | `Lang` | The document declares no language. |
170
+ | `ViewerPreferences`, `PageMode`, `PageLabels` | Presentation hints are gone. |
171
+ | `Outlines` and `Metadata` | No bookmarks, no document metadata. |
172
+
173
+ None of those survive a print either, which is what makes the trade acceptable
174
+ for a packet assembled from documents that are already final. A caller who needs
175
+ any of them should say so rather than merge.
176
+
177
+ A single source is returned unchanged. Every failure names its source:
178
+ `PdfMergeError` carries the zero-based `source` index for a document with no
179
+ catalog, no pages, or bytes the parser cannot read at all.
180
+
181
+ An encrypted source throws `PdfEncryptedError`, as it does from every function
182
+ that reads or writes a PDF (`renderPdf`, `flattenPdf`, `selectPdfPages`,
183
+ `inspectPdf`, `extractPdfData`, and the rest). One check, in the parser every
184
+ entry point shares, decides: a trailer or cross-reference stream that declares
185
+ `/Encrypt`.
186
+
187
+ ## Digital signature fields
188
+
189
+ `preparePdfSignature()` appends an incremental update that adds an invisible
190
+ signature field, an AcroForm with `/SigFlags 3`, and a signature dictionary
191
+ with a reserved `/Contents`. Sign `signedBytes(prepared)` with your own keys and
192
+ write the container with `embedPdfSignature()`. `readPdfSignature()` reads the
193
+ newest signature dictionary back, and its `coversWholeFile` is false when any
194
+ byte outside the container is unsigned.
195
+
196
+ ```ts
197
+ const prepared = await preparePdfSignature(pdf, { contentsSize: 16384, signingTime: new Date(), name: 'Ada Lovelace' })
198
+ const signed = embedPdfSignature(prepared, createCmsSignature(signedBytes(prepared)))
199
+ ```
200
+
201
+ ## Signature placement
202
+
203
+ The `@asterdoc/render/pdf` subpath locates signature positions in converter-
204
+ produced PDFs without a PDF rendering engine. It reads the PDF text layer with
205
+ a built-in content-stream scanner, so it runs anywhere the renderer runs.
206
+
207
+ ```ts
208
+ import { locate, extractFieldsFromPdf, encode, FieldType } from '@asterdoc/render/pdf'
209
+
210
+ // Find invisible markers injected during rendering, and literal anchor text.
211
+ const hits = await locate(pdf, [
212
+ { id: 'client-sig', kind: 'marker', signerIndex: 0, fieldType: FieldType.SIGNATURE },
213
+ { id: 'witness-sig', kind: 'anchor', text: 'Witnessed by:' },
214
+ ])
215
+ // [{ id, page, x, y, width, height }] in PDF points, y from the top edge
216
+ ```
217
+
218
+ `locate()` is all-or-nothing: an unresolved or ambiguous query throws a
219
+ `LocateError` naming every failed id, so a seal pipeline can never proceed on
220
+ a silently incomplete signature map. Anchor text must be unique unless the
221
+ query picks an `occurrence`, a one-based integer; any other `occurrence` throws a
222
+ `RangeError`. `extractFieldsFromPdf()` returns every marker in
223
+ a document, and throws `UnknownMarkerError` naming each marker whose field type
224
+ is not signature or initials; `pageTextRuns()` exposes positioned text runs for verification and
225
+ layout tooling.
226
+
227
+ ## DOCX templates
228
+
229
+ DOCX rendering supports direct values, commands split across Word runs, custom
230
+ delimiters, line breaks, and structural commands:
231
+
232
+ ```text
233
+ {{FOR line IN fields.items}}
234
+ {{line.name}}
235
+ {{END-FOR line}}
236
+
237
+ {{IF fields.approved}}
238
+ Approved
239
+ {{ELSE}}
240
+ Pending
241
+ {{END-IF}}
242
+ ```
243
+
244
+ Conditions, loop sources, and placeholders are artifact expressions, as in text
245
+ templates. DOCX templates also support the signing directives `signature`,
246
+ `initials`, `signatureDate`, `capacity`, and `printedName`, written
247
+ `{{signature(parties.client, "client-sign")}}`.
248
+
249
+ `capacity` prints the signatory's capacity and `printedName` the signer's name.
250
+ After a `capacity` or `printed_name` capture at that location, each prints the
251
+ captured text. When no value is known, they fall back to a placeholder:
252
+ `[CAPACITY]` and `[PRINTED NAME]` in text templates, underscores in DOCX
253
+ templates. The `placeholder` and `captured` signature options replace the
254
+ defaults, but a known capacity or name wins over `placeholder`.
255
+
256
+ ## Benchmarking
257
+
258
+ From a repository checkout, run `pnpm benchmark` from this package to record
259
+ current text, PDF, and DOCX timings. The benchmark is
260
+ intentionally separate from the unit-test gate, so timing variance does not
261
+ make ordinary tests flaky. Set
262
+ `ASTERDOC_BENCHMARK_ITERATIONS` to change the number of measured iterations.
263
+ Set `ASTERDOC_BINARY_BENCHMARK_ITERATIONS` for PDF and DOCX iterations. Use
264
+ `pnpm size-report` to verify browser bundles, server imports, and the exact npm
265
+ tarball contents and sizes without publishing.
@@ -0,0 +1,69 @@
1
+ import { TypeEnv } from '@asterdoc/expr';
2
+
3
+ /**
4
+ * Template diagnostics: where an expression sits in a layer, and what replaces
5
+ * syntax the single expression language no longer accepts.
6
+ */
7
+ /** A position inside a text template (1-based line and column). */
8
+ interface TemplatePosition {
9
+ line: number;
10
+ column: number;
11
+ }
12
+ /** One problem found in a template, at authoring validation or render. */
13
+ interface TemplateDiagnostic {
14
+ /** Stable category: `syntax`, `removed-syntax`, `markers`, a checker code, or an evaluation code. */
15
+ code: string;
16
+ message: string;
17
+ /** The expression source, when the problem is inside one. */
18
+ expression?: string;
19
+ /** Line and column in a text template, or in the paragraph of a DOCX part. */
20
+ position?: TemplatePosition;
21
+ /** The DOCX part and paragraph, such as `word/document.xml paragraph 12`. */
22
+ location?: string;
23
+ }
24
+ /** A template that cannot render: malformed markers, a failed expression, or removed syntax. */
25
+ declare class TemplateError extends Error {
26
+ readonly code: string;
27
+ readonly layer?: string;
28
+ readonly expression?: string;
29
+ readonly position?: TemplatePosition;
30
+ readonly location?: string;
31
+ readonly diagnostic: TemplateDiagnostic;
32
+ constructor(diagnostic: TemplateDiagnostic, layer?: string);
33
+ /** The same error, naming the layer it came from. */
34
+ inLayer(layer: string | undefined): TemplateError;
35
+ }
36
+
37
+ /**
38
+ * Authoring checks for template expressions.
39
+ *
40
+ * Every condition, loop source, placeholder, and directive argument is parsed
41
+ * and type-checked against the artifact's type environment, the one its field
42
+ * logic is checked against. A condition must be boolean and a loop source a
43
+ * list; `item`, `parent`, a DOCX loop's name, and the loop-only functions
44
+ * resolve only inside a loop.
45
+ */
46
+
47
+ /** Check every expression in a text, Markdown, or HTML template. */
48
+ declare function checkTextTemplate(template: string, env: TypeEnv): TemplateDiagnostic[];
49
+ interface DocxCheckOptions {
50
+ /** Command delimiters, when the template does not use `{{` and `}}`. */
51
+ cmdDelimiter?: [string, string];
52
+ }
53
+ /** Check every control command and expression in a Word template. */
54
+ declare function checkDocxTemplate(template: Uint8Array, env: TypeEnv, options?: DocxCheckOptions): TemplateDiagnostic[];
55
+ /** A signing directive a template writes, such as `signature(parties.tenant, "tenant-sign")`. */
56
+ interface SigningDirectiveUse {
57
+ /** The directive name: `signature`, `initials`, `signatureDate`, `capacity`, or `printedName`. */
58
+ directive: string;
59
+ /** The location the directive places, when written as a string literal; undefined when computed. */
60
+ location?: string;
61
+ }
62
+ /**
63
+ * The signing directives a text, Markdown, or HTML template writes, including
64
+ * those inside blocks. Undefined when the template's markers do not parse, so
65
+ * which directives it writes is unknown.
66
+ */
67
+ declare function textTemplateSigningDirectives(template: string): SigningDirectiveUse[] | undefined;
68
+
69
+ export { type DocxCheckOptions as D, type SigningDirectiveUse as S, type TemplateDiagnostic as T, TemplateError as a, type TemplatePosition as b, checkDocxTemplate as c, checkTextTemplate as d, textTemplateSigningDirectives as t };
@@ -0,0 +1,98 @@
1
+ // src/layer-bindings.ts
2
+ function isPdfLayer(layer) {
3
+ return layer.mimeType?.toLowerCase() === "application/pdf";
4
+ }
5
+ function resolveLayerBindings(layers, layer) {
6
+ if (!isPdfLayer(layer)) return void 0;
7
+ if (layer.bindings || !layer.bindingsFrom) return layer.bindings;
8
+ const source = layers[layer.bindingsFrom];
9
+ if (!source) {
10
+ throw new Error(`bindingsFrom "${layer.bindingsFrom}" references unknown layer. Available: ${Object.keys(layers).join(", ")}`);
11
+ }
12
+ return isPdfLayer(source) ? source.bindings : void 0;
13
+ }
14
+ var PdfBindingSyntaxError = class extends Error {
15
+ binding;
16
+ constructor(binding, problem) {
17
+ super(`Binding ${JSON.stringify(binding)} ${problem}.`);
18
+ this.name = "PdfBindingSyntaxError";
19
+ this.binding = binding;
20
+ }
21
+ };
22
+ function parseBinding(binding) {
23
+ const parts = binding.split(",").map((text) => {
24
+ const separator = text.indexOf(":");
25
+ const source = (separator === -1 ? text : text.slice(0, separator)).trim();
26
+ if (!source) throw new PdfBindingSyntaxError(binding, "has an empty path");
27
+ if (separator === -1) return { source, path: bindingDataPath(source) };
28
+ const qualifier = text.slice(separator + 1).trim();
29
+ if (!qualifier) throw new PdfBindingSyntaxError(binding, `has an empty qualifier after "${source}:"`);
30
+ return { source, path: bindingDataPath(source), qualifier };
31
+ });
32
+ if (parts.length > 1 && parts.some((part) => part.qualifier !== void 0)) {
33
+ throw new PdfBindingSyntaxError(binding, "qualifies a part of a joined binding; a joined binding reads whole values");
34
+ }
35
+ return parts;
36
+ }
37
+ function parseBindings(bindings) {
38
+ return Object.entries(bindings).map(([pdfName, binding]) => [pdfName, parseBinding(binding)]);
39
+ }
40
+ function splitPartIndex(qualifier) {
41
+ return /^[1-9]\d*$/.test(qualifier) ? Number(qualifier) - 1 : void 0;
42
+ }
43
+ var PdfBindingKeyError = class extends Error {
44
+ /** The binding keys the template has no field for. */
45
+ keys;
46
+ constructor(keys, templateFields) {
47
+ super(
48
+ `PDF bindings name form fields the template does not have: ${keys.map((key) => JSON.stringify(key)).join(", ")}. ` + (templateFields.length > 0 ? `Template fields: ${templateFields.join(", ")}` : "The template has no form fields")
49
+ );
50
+ this.name = "PdfBindingKeyError";
51
+ this.keys = keys;
52
+ }
53
+ };
54
+ function bindingDataPath(path) {
55
+ const trimmed = path.trim();
56
+ return trimmed.startsWith("fields.") ? trimmed.slice("fields.".length) : trimmed;
57
+ }
58
+ function renderDataPath(data, path) {
59
+ const trimmed = path.trim();
60
+ const hasFieldsRoot = data.fields !== null && typeof data.fields === "object" && !Array.isArray(data.fields);
61
+ if (!hasFieldsRoot) return bindingDataPath(trimmed);
62
+ if (trimmed.startsWith("fields.")) return trimmed;
63
+ const root = trimmed.split(".", 1)[0];
64
+ return root === "parties" || root === "annexes" || root === "defs" || root === "_signers" || root === "_captures" ? trimmed : `fields.${trimmed}`;
65
+ }
66
+
67
+ // src/path.ts
68
+ var blockedSegments = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
69
+ function pathSegments(path) {
70
+ const normalized = path.replace(/\[(\d+)\]/g, ".$1");
71
+ if (normalized.includes("[") || normalized.includes("]")) return [];
72
+ const segments = normalized.split(".");
73
+ return segments.some((segment) => segment.length === 0) ? [] : segments;
74
+ }
75
+ function getPath(value, path) {
76
+ const segments = pathSegments(path);
77
+ if (segments.length === 0) return void 0;
78
+ let current = value;
79
+ for (const segment of segments) {
80
+ if (blockedSegments.has(segment) || current === null || current === void 0) return void 0;
81
+ if (typeof current !== "object" && typeof current !== "function") return void 0;
82
+ current = current[segment];
83
+ }
84
+ return current;
85
+ }
86
+
87
+ export {
88
+ resolveLayerBindings,
89
+ PdfBindingSyntaxError,
90
+ parseBinding,
91
+ parseBindings,
92
+ splitPartIndex,
93
+ PdfBindingKeyError,
94
+ bindingDataPath,
95
+ renderDataPath,
96
+ pathSegments,
97
+ getPath
98
+ };