@jasy/react 0.0.0-stage → 1.0.0-beta.1

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 Florian Heuberger
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.
package/README.md CHANGED
@@ -1,3 +1,153 @@
1
- # Temporary Holding Version
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/jasy-pdf/jasy/main/docs/logo.png" width="120" alt="jasy">
3
+ </p>
2
4
 
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.
5
+ <h1 align="center">@jasy/react</h1>
6
+
7
+ <p align="center">
8
+ <b>Author PDFs as React components - and render them right in the browser.</b>
9
+ </p>
10
+
11
+ A thin React renderer over the [`@jasy/pdf`](https://npmx.dev/@jasy/pdf) engine. You write a component
12
+ tree, you get a real PDF: no headless browser, no server round-trip, no Java.
13
+
14
+ ```tsx
15
+ import { Document, Page, Row, Text, Table, TableRow, TableCell, renderToPdf } from "@jasy/react";
16
+
17
+ function Invoice({ items }: { items: { desc: string; amount: string }[] }) {
18
+ return (
19
+ <Document size={11}>
20
+ <Page size="A4" gap={16} footer={<Text size={9}>Acme GmbH · Berlin</Text>}>
21
+ <Row justify="between">
22
+ <Text size={22} bold color="#0a2348">
23
+ Acme GmbH
24
+ </Text>
25
+ <Text size={22} bold color="#1450aa">
26
+ INVOICE
27
+ </Text>
28
+ </Row>
29
+ <Table columns={["1fr", 120]} cellBorder="#e2e8f0" cellPadding={8}>
30
+ <TableRow header>
31
+ <TableCell>Description</TableCell>
32
+ <TableCell>Amount (EUR)</TableCell>
33
+ </TableRow>
34
+ {items.map((item) => (
35
+ <TableRow key={item.desc}>
36
+ <TableCell>{item.desc}</TableCell>
37
+ <TableCell>{item.amount}</TableCell>
38
+ </TableRow>
39
+ ))}
40
+ </Table>
41
+ </Page>
42
+ </Document>
43
+ );
44
+ }
45
+
46
+ const items = [
47
+ { desc: "Design work", amount: "1,200.00" },
48
+ { desc: "Development", amount: "3,400.00" },
49
+ ];
50
+ const bytes = await renderToPdf(<Invoice items={items} />); // in the browser or in Node
51
+ ```
52
+
53
+ ## Why
54
+
55
+ - **Runs in the browser.** `renderToPdf` produces the PDF bytes client-side. No `/api/render`, no Node
56
+ on the request path. The same call works in Node.
57
+ - **Real layout, real pagination.** `Row`/`Column` flex, wrapping, `%` sizes, a table whose header row
58
+ repeats on every page, newspaper columns. Not a drawing API you hand absolute coordinates.
59
+ - **React, all of it.** Hooks, context, lists with `key`, your own components. A component that
60
+ throws fails the render with that error.
61
+ - **Typed by the engine.** Every prop type IS the engine's option type, so a new engine option is a
62
+ typed prop the day it ships, and a wrong value goes red in your editor.
63
+ - **The same bytes.** A document in JSX renders byte-for-byte like the same document written with the
64
+ `@jasy/pdf` functions. The test suite holds every component to that.
65
+
66
+ ## Install
67
+
68
+ ```bash
69
+ pnpm add @jasy/react @jasy/pdf react
70
+ ```
71
+
72
+ React 19.
73
+
74
+ ## Components
75
+
76
+ | Group | Components |
77
+ | ---------- | ------------------------------------------------------------------------------------------------------ |
78
+ | Structure | `Document` · `Page` · `PageBreak` · `KeepTogether` · `DefaultTextStyle` · `PageBuilder` |
79
+ | Layout | `Column` · `Row` · `Box` · `Padding` · `Expanded` · `Spacer` · `Positioned` · `Rotated` · `RotatedBox` |
80
+ | Text | `Text` · `Paragraph` · `Span` · `PageNumber` · `PageCount` · `SubPageNumber` · `SubPageCount` |
81
+ | Content | `Image` · `Svg` · `Canvas` · `Divider` · `Table` · `TableRow` · `TableCell` |
82
+ | Flow | `Flow` · `Region` · `ColumnBreak` · `SplitView` |
83
+ | Navigation | `Link` · `Anchor` · `Bookmark` |
84
+ | Forms | `TextField` · `Checkbox` · `RadioGroup` · `Dropdown` · `ListBox` · `PushButton` · `SignatureField` |
85
+
86
+ The props are the options of the matching `@jasy/pdf` function, documented at [jasy.dev](https://jasy.dev).
87
+ Where React has its own way, it is used:
88
+
89
+ - **Text is children.** `<Text bold>Total: {sum}</Text>`, and `<Span>` styles part of a line.
90
+ - **A form field's label is its children.** `<Checkbox name="agree">I agree</Checkbox>`.
91
+ - **Repeating areas are props.** `<Page header={<Logo />} footer={<PageNumber />}>`: laid out once and
92
+ repeated on every page the content flows onto, with the space they need reserved.
93
+ - **What depends on the page is a function child**, built for every page it lands on:
94
+ `<PageBuilder>{({ pageNumber, pageCount }) => <Text>Page {pageNumber} of {pageCount}</Text>}</PageBuilder>`.
95
+ - **A Flow's page is a render prop**, rendered in place, so hooks and context work in it:
96
+
97
+ ```tsx
98
+ <Flow
99
+ page={({ index }) => (
100
+ <Row gap={14}>
101
+ <Expanded>
102
+ <Region />
103
+ </Expanded>
104
+ <Region width={index === 0 ? 150 : 100} />
105
+ </Row>
106
+ )}
107
+ >
108
+ {paragraphs.map((p) => (
109
+ <Text key={p}>{p}</Text>
110
+ ))}
111
+ </Flow>
112
+ ```
113
+
114
+ ## Fonts and images
115
+
116
+ Both load as bytes, so the same code runs in the browser and in Node:
117
+
118
+ ```tsx
119
+ <Document fonts={{ GreatVibes: fontBytes }}>
120
+ <Page>
121
+ <Text font="GreatVibes" size={44}>
122
+ Jasy Atelier
123
+ </Text>
124
+ <Image src={photoBytes} width={84} height={84} fit="cover" radius={42} />
125
+ </Page>
126
+ </Document>
127
+ ```
128
+
129
+ ## API
130
+
131
+ - `renderToPdf(element, options?) => Promise<Uint8Array>` - the PDF bytes. Browser or Node.
132
+ - `renderToPdfString(element, options?) => Promise<string>` - the raw PDF string.
133
+ - `options` are the `@jasy/pdf` `RenderOptions`, e.g. `{ encrypt: { userPassword: "secret" } }` for
134
+ AES-256, `{ accessible: true }` for a tagged PDF, `onDiagnostic` for what the engine has to say.
135
+ - `toDocumentDescriptor(element)` - the engine descriptor of the document, a plain object. Before
136
+ posting it as JSON, encode font and image bytes yourself (JSON has no `Uint8Array`) and decode them on
137
+ the other side. Functions do not survive JSON at all, so a document with a `Canvas`, a `Flow` or a
138
+ `PageBuilder` goes through `renderToPdf`.
139
+
140
+ ## Try it
141
+
142
+ ```bash
143
+ git clone https://github.com/jasy-pdf/jasy && cd jasy && pnpm install && pnpm run build
144
+ pnpm --filter @jasy/react play
145
+ ```
146
+
147
+ The playground renders twelve documents in the browser, side by side with their source: an invoice
148
+ that grows across pages, a newspaper, layout, typography, drawing, fonts, form fields, encryption.
149
+
150
+ ---
151
+
152
+ Part of [Jasy](https://jasy.dev) - a declarative, dependency-light PDF toolkit in pure TypeScript.
153
+ MIT, by Florian Heuberger.
@@ -0,0 +1,170 @@
1
+ import { type ReactNode } from "react";
2
+ import type { BoxOptions, CanvasOptions, CanvasPaint, CheckboxOptions, ChoiceOptionInput, DividerOptions, DocumentOptions, DropdownOptions, ExpandedOptions, FlowOptions, FlowPage, FontSource, ImageOptions, ImageSource, Insets, ListBoxOptions, PageNumberOptions, PageInfo, PageOptions, PositionedInsets, PushButtonOptions, RadioChoice, RadioGroupOptions, RegionOptions, SignatureFieldOptions, SplitViewOptions, StackOptions, SvgOptions, SvgSource, TableOptions, TextDefaults, TextFieldOptions, TextOptions, TextStyle } from "@jasy/pdf";
3
+ type WithChildren = {
4
+ children?: ReactNode;
5
+ };
6
+ /** Text a field draws as its label: `<Checkbox name="agree">I agree</Checkbox>`. */
7
+ type LabelText = string | number | Array<string | number>;
8
+ export type DocumentProps = DocumentOptions & WithChildren & {
9
+ /** Fonts by family name: a file path (Node), the file's bytes, a URL or a family of styles. */
10
+ fonts?: Record<string, FontSource>;
11
+ };
12
+ export type PageProps = Omit<PageOptions, "header" | "footer"> & WithChildren & {
13
+ /** Laid out at the top of every physical page the content flows onto. */
14
+ header?: ReactNode;
15
+ /** Laid out at the bottom of every physical page the content flows onto. */
16
+ footer?: ReactNode;
17
+ };
18
+ export type StackProps = StackOptions & WithChildren;
19
+ export type BoxProps = BoxOptions & WithChildren;
20
+ export type PaddingProps = WithChildren & {
21
+ insets?: Insets;
22
+ };
23
+ export type ExpandedProps = ExpandedOptions & WithChildren;
24
+ export type SpacerProps = {
25
+ flex?: number;
26
+ };
27
+ export type DividerProps = DividerOptions;
28
+ export type ImageProps = ImageOptions & {
29
+ src: ImageSource;
30
+ };
31
+ export type SvgProps = SvgOptions & {
32
+ src: SvgSource;
33
+ };
34
+ export type CanvasProps = CanvasOptions & {
35
+ paint: CanvasPaint;
36
+ };
37
+ export type TextProps = TextOptions & WithChildren;
38
+ export type SpanProps = TextStyle & WithChildren;
39
+ export type TableProps = Omit<TableOptions, "header"> & WithChildren;
40
+ export type TableRowProps = WithChildren & {
41
+ /** Repeat this row at the top of every page the table flows onto. */
42
+ header?: boolean;
43
+ };
44
+ export type FlowProps = Omit<FlowOptions, "template"> & WithChildren & {
45
+ /** One page of the Flow, per page: the `Region`s in it are where the content goes. */
46
+ page: (info: FlowPage) => ReactNode;
47
+ };
48
+ export type RegionProps = RegionOptions;
49
+ export type SplitViewProps = SplitViewOptions & WithChildren;
50
+ export type PositionedProps = PositionedInsets & WithChildren;
51
+ export type DefaultTextStyleProps = TextDefaults & WithChildren;
52
+ /** Exactly one target: `href` opens a URL, `to` jumps to an `Anchor` of that name. */
53
+ export type LinkProps = WithChildren & ({
54
+ href: string;
55
+ to?: never;
56
+ } | {
57
+ to: string;
58
+ href?: never;
59
+ });
60
+ export type AnchorProps = WithChildren & {
61
+ name: string;
62
+ };
63
+ export type BookmarkProps = WithChildren & {
64
+ title: string;
65
+ level?: number;
66
+ };
67
+ export type RotatedProps = WithChildren & {
68
+ angle: number;
69
+ };
70
+ export type RotatedBoxProps = WithChildren & {
71
+ turns: number;
72
+ };
73
+ export type PageNumberProps = PageNumberOptions;
74
+ export type PageBuilderProps = {
75
+ /** What to show, built from the page it lands on. */
76
+ children: (info: PageInfo) => ReactNode;
77
+ };
78
+ export type TextFieldProps = TextFieldOptions;
79
+ export type CheckboxProps = CheckboxOptions & {
80
+ children?: LabelText;
81
+ };
82
+ export type RadioGroupProps = RadioGroupOptions & {
83
+ /** The choices: `["S", "M"]`, or `{ value, label }` when the label differs. */
84
+ options: Array<string | RadioChoice>;
85
+ };
86
+ export type DropdownProps = DropdownOptions & {
87
+ options: ChoiceOptionInput[];
88
+ };
89
+ export type ListBoxProps = ListBoxOptions & {
90
+ options: ChoiceOptionInput[];
91
+ };
92
+ export type PushButtonProps = Omit<PushButtonOptions, "label"> & {
93
+ label?: string;
94
+ children?: LabelText;
95
+ };
96
+ export type SignatureFieldProps = SignatureFieldOptions & {
97
+ children?: LabelText;
98
+ };
99
+ export declare const Document: (props: DocumentProps) => ReactNode;
100
+ /** A page template; `header` and `footer` repeat on every physical page the content flows onto. */
101
+ export declare function Page({ header, footer, children, ...props }: PageProps): ReactNode;
102
+ export declare const Column: (props: StackProps) => ReactNode;
103
+ export declare const Row: (props: StackProps) => ReactNode;
104
+ export declare const Box: (props: BoxProps) => ReactNode;
105
+ export declare const Padding: (props: PaddingProps) => ReactNode;
106
+ export declare const Expanded: (props: ExpandedProps) => ReactNode;
107
+ export declare const Spacer: (props: SpacerProps) => ReactNode;
108
+ export declare const PageBreak: (props: object) => ReactNode;
109
+ /** Keeps its children on one page (CSS `break-inside: avoid`); also a `keepTogether` prop on stacks. */
110
+ export declare const KeepTogether: (props: WithChildren) => ReactNode;
111
+ export declare const Divider: (props: DividerOptions) => ReactNode;
112
+ export declare const Image: (props: ImageProps) => ReactNode;
113
+ export declare const Svg: (props: SvgProps) => ReactNode;
114
+ export declare const Canvas: (props: CanvasProps) => ReactNode;
115
+ export declare const Text: (props: TextProps) => ReactNode;
116
+ export declare const Paragraph: (props: TextProps) => ReactNode;
117
+ export declare const Span: (props: SpanProps) => ReactNode;
118
+ export declare const Table: (props: TableProps) => ReactNode;
119
+ export declare const TableRow: (props: TableRowProps) => ReactNode;
120
+ export declare const TableCell: (props: WithChildren) => ReactNode;
121
+ export declare const Region: (props: RegionOptions) => ReactNode;
122
+ export declare const ColumnBreak: (props: object) => ReactNode;
123
+ export declare const SplitView: (props: SplitViewProps) => ReactNode;
124
+ export declare const Positioned: (props: PositionedProps) => ReactNode;
125
+ export declare const DefaultTextStyle: (props: DefaultTextStyleProps) => ReactNode;
126
+ export declare const Link: (props: LinkProps) => ReactNode;
127
+ export declare const Anchor: (props: AnchorProps) => ReactNode;
128
+ export declare const Bookmark: (props: BookmarkProps) => ReactNode;
129
+ export declare const Rotated: (props: RotatedProps) => ReactNode;
130
+ export declare const RotatedBox: (props: RotatedBoxProps) => ReactNode;
131
+ export declare const PageNumber: (props: PageNumberOptions) => ReactNode;
132
+ export declare const PageCount: (props: PageNumberOptions) => ReactNode;
133
+ /** The page number within its `Page` (a section of the document), as text. */
134
+ export declare const SubPageNumber: (props: PageNumberOptions) => ReactNode;
135
+ /** How many pages its `Page` (a section of the document) produced, as text. */
136
+ export declare const SubPageCount: (props: PageNumberOptions) => ReactNode;
137
+ export declare const TextField: (props: TextFieldOptions) => ReactNode;
138
+ export declare const Checkbox: {
139
+ ({ children, label, ...props }: CheckboxProps): import("react").ReactElement<Omit<CheckboxProps, "children" | "label"> & {
140
+ label: string | undefined;
141
+ }, string | import("react").JSXElementConstructor<any>>;
142
+ displayName: string;
143
+ };
144
+ export declare const RadioGroup: (props: RadioGroupProps) => ReactNode;
145
+ export declare const Dropdown: (props: DropdownProps) => ReactNode;
146
+ export declare const ListBox: (props: ListBoxProps) => ReactNode;
147
+ export declare const PushButton: {
148
+ ({ children, label, ...props }: PushButtonProps): import("react").ReactElement<Omit<PushButtonProps, "children" | "label"> & {
149
+ label: string | undefined;
150
+ }, string | import("react").JSXElementConstructor<any>>;
151
+ displayName: string;
152
+ };
153
+ export declare const SignatureField: {
154
+ ({ children, label, ...props }: SignatureFieldProps): import("react").ReactElement<Omit<SignatureFieldProps, "children" | "label"> & {
155
+ label: string | undefined;
156
+ }, string | import("react").JSXElementConstructor<any>>;
157
+ displayName: string;
158
+ };
159
+ /**
160
+ * Content flowing through the `Region`s of `page`: the first Region full, then the next, then the next
161
+ * page. `page` is rendered for every page while the engine lays the document out.
162
+ */
163
+ export declare function Flow({ page, children, ...props }: FlowProps): ReactNode;
164
+ /**
165
+ * Content built from the page it lands on: `{({ pageNumber, pageCount }) => ...}` as its child. Place
166
+ * it anywhere - a header, a footer, the body, a table cell. What it builds is placed whole on its page;
167
+ * build nothing (`null`) to show it on some pages only.
168
+ */
169
+ export declare function PageBuilder({ children }: PageBuilderProps): ReactNode;
170
+ export {};
@@ -0,0 +1,104 @@
1
+ import { createElement as h, useContext, useRef, useState } from "react";
2
+ import { childDescriptors, flushNow, RenderSinkContext } from "./renderer.js";
3
+ /** A component that leaves one host node of `type` with its props and children. */
4
+ function host(type, name) {
5
+ const component = ({ children, ...props }) => h(type, props, children);
6
+ component.displayName = name;
7
+ return component;
8
+ }
9
+ /** A field whose children are its label: forwarded as the `label` prop the engine draws. */
10
+ function labelled(type, name) {
11
+ const component = ({ children, label, ...props }) => {
12
+ const text = [children ?? []].flat().join("").trim();
13
+ return h(type, { ...props, label: text || label });
14
+ };
15
+ component.displayName = name;
16
+ return component;
17
+ }
18
+ export const Document = host("document", "Document");
19
+ /** A page template; `header` and `footer` repeat on every physical page the content flows onto. */
20
+ export function Page({ header, footer, children, ...props }) {
21
+ return h("page", props, header != null && h("page-header", null, header), footer != null && h("page-footer", null, footer), children);
22
+ }
23
+ export const Column = host("column", "Column");
24
+ export const Row = host("row", "Row");
25
+ export const Box = host("box", "Box");
26
+ export const Padding = host("padding", "Padding");
27
+ export const Expanded = host("expanded", "Expanded");
28
+ export const Spacer = host("spacer", "Spacer");
29
+ export const PageBreak = host("page-break", "PageBreak");
30
+ /** Keeps its children on one page (CSS `break-inside: avoid`); also a `keepTogether` prop on stacks. */
31
+ export const KeepTogether = host("keep-together", "KeepTogether");
32
+ export const Divider = host("divider", "Divider");
33
+ export const Image = host("image", "Image");
34
+ export const Svg = host("svg", "Svg");
35
+ export const Canvas = host("canvas", "Canvas");
36
+ export const Text = host("text", "Text");
37
+ export const Paragraph = host("paragraph", "Paragraph");
38
+ export const Span = host("span", "Span");
39
+ export const Table = host("table", "Table");
40
+ export const TableRow = host("table-row", "TableRow");
41
+ export const TableCell = host("table-cell", "TableCell");
42
+ export const Region = host("region", "Region");
43
+ export const ColumnBreak = host("column-break", "ColumnBreak");
44
+ export const SplitView = host("split-view", "SplitView");
45
+ export const Positioned = host("positioned", "Positioned");
46
+ export const DefaultTextStyle = host("default-text-style", "DefaultTextStyle");
47
+ export const Link = host("link", "Link");
48
+ export const Anchor = host("anchor", "Anchor");
49
+ export const Bookmark = host("bookmark", "Bookmark");
50
+ export const Rotated = host("rotated", "Rotated");
51
+ export const RotatedBox = host("rotated-box", "RotatedBox");
52
+ export const PageNumber = host("page-number", "PageNumber");
53
+ export const PageCount = host("page-count", "PageCount");
54
+ /** The page number within its `Page` (a section of the document), as text. */
55
+ export const SubPageNumber = host("sub-page-number", "SubPageNumber");
56
+ /** How many pages its `Page` (a section of the document) produced, as text. */
57
+ export const SubPageCount = host("sub-page-count", "SubPageCount");
58
+ export const TextField = host("text-field", "TextField");
59
+ export const Checkbox = labelled("checkbox", "Checkbox");
60
+ export const RadioGroup = host("radio-group", "RadioGroup");
61
+ export const Dropdown = host("dropdown", "Dropdown");
62
+ export const ListBox = host("list-box", "ListBox");
63
+ export const PushButton = labelled("push-button", "PushButton");
64
+ export const SignatureField = labelled("signature-field", "SignatureField");
65
+ /**
66
+ * Renders `render(info)` in place, in the tree, whenever the engine asks during layout - so hooks and
67
+ * context work in it as anywhere else. Each request carries the `render` of the render that made it, so
68
+ * a callback the engine kept from an earlier page still draws what that page rendered.
69
+ */
70
+ function useRenderedOnRequest(component, render) {
71
+ const [asked, setAsked] = useState();
72
+ const self = useRef(null);
73
+ const sink = useContext(RenderSinkContext);
74
+ const request = (info) => {
75
+ if (!self.current || !sink) {
76
+ throw new Error(`@jasy/react: a <${component}> renders while the PDF is laid out - render the document ` +
77
+ "with renderToPdf, which keeps it mounted until then.");
78
+ }
79
+ flushNow(() => setAsked({ info, render }));
80
+ if (sink.failure)
81
+ throw sink.failure;
82
+ const marker = self.current.children.find((c) => c.type === "rendered-on-request");
83
+ return marker ? childDescriptors(marker) : [];
84
+ };
85
+ const rendered = asked && h("rendered-on-request", null, asked.render(asked.info));
86
+ return { ref: self, request, rendered };
87
+ }
88
+ /**
89
+ * Content flowing through the `Region`s of `page`: the first Region full, then the next, then the next
90
+ * page. `page` is rendered for every page while the engine lays the document out.
91
+ */
92
+ export function Flow({ page, children, ...props }) {
93
+ const { ref, request, rendered } = useRenderedOnRequest("Flow", page);
94
+ return h("flow", { ...props, page: request, ref }, rendered, children);
95
+ }
96
+ /**
97
+ * Content built from the page it lands on: `{({ pageNumber, pageCount }) => ...}` as its child. Place
98
+ * it anywhere - a header, a footer, the body, a table cell. What it builds is placed whole on its page;
99
+ * build nothing (`null`) to show it on some pages only.
100
+ */
101
+ export function PageBuilder({ children }) {
102
+ const { ref, request, rendered } = useRenderedOnRequest("PageBuilder", children);
103
+ return h("page-builder", { build: request, ref }, rendered);
104
+ }
@@ -0,0 +1,13 @@
1
+ import type { ReactNode } from "react";
2
+ import { type Descriptor, type RenderOptions } from "@jasy/pdf";
3
+ export * from "./components.js";
4
+ /** Render an element whose root is a `<Document>` to PDF bytes - browser or Node. */
5
+ export declare function renderToPdf(element: ReactNode, options?: RenderOptions): Promise<Uint8Array<ArrayBuffer>>;
6
+ /** Render an element to the raw PDF string - browser or Node. */
7
+ export declare function renderToPdfString(element: ReactNode, options?: RenderOptions): Promise<string>;
8
+ /**
9
+ * The engine descriptor for an element's `<Document>`, a plain object. As JSON it loses what JSON cannot
10
+ * hold: bytes (encode fonts and images yourself) and functions - a `Canvas`'s paint, and a `Flow` or
11
+ * `PageBuilder`, which render during the PDF render. Those documents go through `renderToPdf`.
12
+ */
13
+ export declare function toDocumentDescriptor(element: ReactNode): Descriptor;
package/dist/index.js ADDED
@@ -0,0 +1,31 @@
1
+ import { buildDocument, renderPdf, renderToBytes, } from "@jasy/pdf";
2
+ import { mountDocument } from "./renderer.js";
3
+ export * from "./components.js";
4
+ /** Mounted until the PDF is done: a `Flow` renders its page while the engine lays the pages out. */
5
+ async function rendered(element, render) {
6
+ const { descriptor, unmount } = mountDocument(element);
7
+ try {
8
+ return await render(buildDocument(descriptor));
9
+ }
10
+ finally {
11
+ unmount();
12
+ }
13
+ }
14
+ /** Render an element whose root is a `<Document>` to PDF bytes - browser or Node. */
15
+ export function renderToPdf(element, options) {
16
+ return rendered(element, (doc) => renderToBytes(doc, options));
17
+ }
18
+ /** Render an element to the raw PDF string - browser or Node. */
19
+ export function renderToPdfString(element, options) {
20
+ return rendered(element, (doc) => renderPdf(doc, options));
21
+ }
22
+ /**
23
+ * The engine descriptor for an element's `<Document>`, a plain object. As JSON it loses what JSON cannot
24
+ * hold: bytes (encode fonts and images yourself) and functions - a `Canvas`'s paint, and a `Flow` or
25
+ * `PageBuilder`, which render during the PDF render. Those documents go through `renderToPdf`.
26
+ */
27
+ export function toDocumentDescriptor(element) {
28
+ const { descriptor, unmount } = mountDocument(element);
29
+ unmount();
30
+ return descriptor;
31
+ }
@@ -0,0 +1,34 @@
1
+ import { type ReactNode } from "react";
2
+ import type { Descriptor, DescriptorChild } from "@jasy/pdf";
3
+ /**
4
+ * A host node in our tree. It is shaped like a `Descriptor` already: the reconciler builds this tree,
5
+ * and it maps 1:1 onto the engine's descriptor seam. Text nodes carry `text`; `parent` is bookkeeping
6
+ * the reconciler needs (insert/remove), stripped on the way out.
7
+ */
8
+ export interface JNode {
9
+ type: string;
10
+ props: Record<string, any>;
11
+ children: JNode[];
12
+ text?: string;
13
+ parent: JNode | null;
14
+ }
15
+ /** Where a render reports an error thrown in the tree - also while a `Flow` re-renders its page. */
16
+ export interface RenderSink {
17
+ failure?: unknown;
18
+ }
19
+ export declare const RenderSinkContext: import("react").Context<RenderSink | null>;
20
+ /**
21
+ * A node's children as descriptors - also what a `Flow` reads its rendered page from. `Total: {sum}` is
22
+ * two text nodes in React; merged back into one string they measure and kern as the one run they are.
23
+ */
24
+ export declare function childDescriptors(n: JNode): DescriptorChild[];
25
+ /**
26
+ * Mounts an element and returns the engine descriptor for its `<Document>` root, with the tree still
27
+ * mounted: a `Flow` renders its `page` for every page while the engine lays the document out.
28
+ */
29
+ export declare function mountDocument(element: ReactNode): {
30
+ descriptor: Descriptor;
31
+ unmount: () => void;
32
+ };
33
+ /** Synchronously apply a state update made outside React's render, so its result can be read back. */
34
+ export declare function flushNow(update: () => void): void;
@@ -0,0 +1,162 @@
1
+ import Reconciler from "react-reconciler";
2
+ import { DefaultEventPriority, LegacyRoot } from "react-reconciler/constants.js";
3
+ import { createContext, createElement } from "react";
4
+ const node = (type, props = {}) => ({
5
+ type,
6
+ props,
7
+ children: [],
8
+ parent: null,
9
+ });
10
+ // `children` and `ref` are React's, not props the engine reads - the tree carries the children.
11
+ function ownProps({ children: _children, ref: _ref, ...rest }) {
12
+ return rest;
13
+ }
14
+ function insert(parent, child, before) {
15
+ detach(child);
16
+ child.parent = parent;
17
+ const i = before ? parent.children.indexOf(before) : -1;
18
+ if (i >= 0)
19
+ parent.children.splice(i, 0, child);
20
+ else
21
+ parent.children.push(child);
22
+ }
23
+ function detach(child) {
24
+ const siblings = child.parent?.children;
25
+ const i = siblings?.indexOf(child) ?? -1;
26
+ if (siblings && i >= 0)
27
+ siblings.splice(i, 1);
28
+ child.parent = null;
29
+ }
30
+ let updatePriority = DefaultEventPriority;
31
+ // The host config, as react-reconciler 0.31 (React 19) calls it - see react-reconciler.d.ts.
32
+ const hostConfig = {
33
+ supportsMutation: true,
34
+ supportsPersistence: false,
35
+ supportsHydration: false,
36
+ isPrimaryRenderer: false,
37
+ noTimeout: -1,
38
+ scheduleTimeout: setTimeout,
39
+ cancelTimeout: clearTimeout,
40
+ supportsMicrotasks: true,
41
+ scheduleMicrotask: queueMicrotask,
42
+ createInstance: (type, props) => node(type, ownProps(props)),
43
+ createTextInstance: (text) => Object.assign(node("#text"), { text }),
44
+ appendInitialChild: insert,
45
+ appendChild: insert,
46
+ appendChildToContainer: insert,
47
+ insertBefore: insert,
48
+ insertInContainerBefore: insert,
49
+ removeChild: (_parent, child) => detach(child),
50
+ removeChildFromContainer: (_parent, child) => detach(child),
51
+ clearContainer: (container) => {
52
+ container.children = [];
53
+ },
54
+ commitUpdate: (n, _type, _prev, next) => {
55
+ n.props = ownProps(next);
56
+ },
57
+ commitTextUpdate: (n, _prev, next) => {
58
+ n.text = next;
59
+ },
60
+ finalizeInitialChildren: () => false,
61
+ shouldSetTextContent: () => false,
62
+ getRootHostContext: () => ({}),
63
+ getChildHostContext: (parent) => parent,
64
+ getPublicInstance: (n) => n,
65
+ prepareForCommit: () => null,
66
+ resetAfterCommit: () => { },
67
+ preparePortalMount: () => { },
68
+ detachDeletedInstance: () => { },
69
+ hideInstance: () => { },
70
+ unhideInstance: () => { },
71
+ hideTextInstance: () => { },
72
+ unhideTextInstance: () => { },
73
+ // Scheduling: a PDF is rendered in one synchronous pass, there are no events to prioritize.
74
+ getCurrentEventPriority: () => DefaultEventPriority,
75
+ getCurrentUpdatePriority: () => updatePriority,
76
+ setCurrentUpdatePriority: (p) => {
77
+ updatePriority = p;
78
+ },
79
+ resolveUpdatePriority: () => updatePriority,
80
+ resolveEventType: () => null,
81
+ resolveEventTimeStamp: () => -1.1,
82
+ shouldAttemptEagerTransition: () => false,
83
+ trackSchedulerEvent: () => { },
84
+ requestPostPaintCallback: () => { },
85
+ getInstanceFromNode: () => null,
86
+ beforeActiveInstanceBlur: () => { },
87
+ afterActiveInstanceBlur: () => { },
88
+ prepareScopeUpdate: () => { },
89
+ getInstanceFromScope: () => null,
90
+ maySuspendCommit: () => false,
91
+ preloadInstance: () => true,
92
+ startSuspendingCommit: () => { },
93
+ suspendInstance: () => { },
94
+ waitForCommitToBeReady: () => null,
95
+ NotPendingTransition: null,
96
+ HostTransitionContext: createContext(null),
97
+ resetFormInstance: () => { },
98
+ };
99
+ const reconciler = Reconciler(hostConfig);
100
+ export const RenderSinkContext = createContext(null);
101
+ // Text and Paragraph read their children as text, so whitespace there is content (`{" "}` between two
102
+ // spans). Everywhere else a whitespace-only string would become an empty Text with a stray `gap`.
103
+ const TEXT_PARENTS = new Set(["text", "paragraph", "span"]);
104
+ /** Strip our tree down to the engine's descriptor: adjacent strings merge, empty ones are dropped. */
105
+ function toDescriptor(n) {
106
+ if (n.type === "#text")
107
+ return n.text ?? "";
108
+ return { type: n.type, props: n.props, children: childDescriptors(n) };
109
+ }
110
+ /**
111
+ * A node's children as descriptors - also what a `Flow` reads its rendered page from. `Total: {sum}` is
112
+ * two text nodes in React; merged back into one string they measure and kern as the one run they are.
113
+ */
114
+ export function childDescriptors(n) {
115
+ const out = [];
116
+ for (const c of n.children) {
117
+ const d = toDescriptor(c);
118
+ const last = out[out.length - 1];
119
+ if (typeof d === "string" && typeof last === "string")
120
+ out[out.length - 1] = last + d;
121
+ else
122
+ out.push(d);
123
+ }
124
+ return TEXT_PARENTS.has(n.type)
125
+ ? out.filter((d) => d !== "")
126
+ : out.filter((d) => typeof d !== "string" || d.trim() !== "");
127
+ }
128
+ /**
129
+ * Mounts an element and returns the engine descriptor for its `<Document>` root, with the tree still
130
+ * mounted: a `Flow` renders its `page` for every page while the engine lays the document out.
131
+ */
132
+ export function mountDocument(element) {
133
+ const container = node("#root");
134
+ const sink = {};
135
+ // An error no boundary caught fails the render; one a boundary caught is the author's to handle.
136
+ const fail = (error) => {
137
+ sink.failure ?? (sink.failure = error);
138
+ };
139
+ const report = (error) => console.error(error);
140
+ const root = reconciler.createContainer(container, LegacyRoot, null, false, null, "", fail, report, report, null);
141
+ const unmount = () => {
142
+ reconciler.updateContainerSync(null, root, null, null);
143
+ reconciler.flushSyncWork();
144
+ };
145
+ reconciler.updateContainerSync(createElement(RenderSinkContext.Provider, { value: sink }, element), root, null, null);
146
+ reconciler.flushSyncWork();
147
+ if (sink.failure) {
148
+ unmount();
149
+ throw sink.failure;
150
+ }
151
+ const doc = container.children.find((c) => c.type === "document");
152
+ if (!doc) {
153
+ unmount();
154
+ throw new Error("@jasy/react: the element must render a <Document>.");
155
+ }
156
+ return { descriptor: toDescriptor(doc), unmount };
157
+ }
158
+ /** Synchronously apply a state update made outside React's render, so its result can be read back. */
159
+ export function flushNow(update) {
160
+ reconciler.flushSyncFromReconciler(update);
161
+ reconciler.flushSyncWork();
162
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@jasy/react",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "1.0.0-beta.1",
4
+ "description": "Author PDFs as React components - a thin React renderer over @jasy/pdf.",
5
+ "keywords": [
6
+ "declarative",
7
+ "invoice",
8
+ "jasy",
9
+ "pdf",
10
+ "pdf-generation",
11
+ "react",
12
+ "renderer"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "Florian Heuberger",
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "type": "module",
20
+ "main": "dist/index.js",
21
+ "types": "dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ },
27
+ "./package.json": "./package.json"
28
+ },
29
+ "dependencies": {
30
+ "react-reconciler": "^0.31.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^25.9.3",
34
+ "@types/react": "^19.3.0",
35
+ "@types/react-dom": "^19.2.0",
36
+ "@vitejs/plugin-react": "^4.7.0",
37
+ "react": "^19.3.1",
38
+ "react-dom": "^19.3.1",
39
+ "typescript": "^5.6.0",
40
+ "vite": "^6.3.4",
41
+ "@jasy/pdf": "1.0.0-beta.5"
42
+ },
43
+ "peerDependencies": {
44
+ "react": "^19.0.0",
45
+ "@jasy/pdf": "^1.0.0-beta.5"
46
+ },
47
+ "scripts": {
48
+ "build": "tsc",
49
+ "play": "vite playground --open",
50
+ "test": "vitest run"
51
+ }
6
52
  }