pyreon

@pyreon/document-primitives ships 18 rocketstyle-based document componentsDocDocument, DocPage, DocSection, DocRow, DocColumn, DocHeading, DocText, DocLink, DocImage, DocTable, DocList, DocListItem, DocCode, DocDivider, DocSpacer, DocButton, DocQuote, DocPageBreak. The same JSX tree renders in the browser AND exports to 14+ formats (PDF, DOCX, XLSX, PPTX, HTML, Markdown, email, Slack, Teams, and more) through the @pyreon/document pipeline.

@pyreon/document-primitivesstable

Installation

npm install @pyreon/document-primitives
bun add @pyreon/document-primitives
pnpm add @pyreon/document-primitives
yarn add @pyreon/document-primitives

You also need the renderer to turn a tree into bytes:

bun add @pyreon/document

Quick Start

import { DocDocument, DocPage, DocHeading, DocText, extractDocNode } from '@pyreon/document-primitives'
import { download } from '@pyreon/document'

// 1. Build a document with primitives — this is a normal Pyreon component.
function Report() {
  return (
    <DocDocument title="Quarterly Report" author="Aisha">
      <DocPage>
        <DocHeading level="h1">Q4 Results</DocHeading>
        <DocText>Revenue grew 23% YoY.</DocText>
      </DocPage>
    </DocDocument>
  )
}

// 2. The SAME tree renders in the browser for a live preview…
function Preview() {
  return <Report />
}

// 3. …and extracts to a format-neutral DocNode for export.
const tree = extractDocNode(() => <Report />)
await download(tree, 'report.pdf')
await download(tree, 'report.docx')
await download(tree, 'report.html')
await download(tree, 'report.md')

The Dual-Render Model

The defining idea of this package is that one component tree powers both a live browser preview and a multi-format export — what the user sees on screen is exactly what they download. There is no second "export template" to keep in sync.

This works because every primitive is a @pyreon/rocketstyle component built on top of @pyreon/elements, so it renders to real DOM in the browser. At the same time, each primitive carries a static _documentType marker ("document", "page", "heading", "table", …) and stamps any export-relevant metadata onto a _documentProps field. A bridge then walks that tree to build a format-neutral DocNode, which the renderer turns into PDF/DOCX/HTML/etc.

            ┌─────────────────────────────────────────────┐
            │  Your JSX tree (DocDocument > DocPage > …)    │
            │  rocketstyle components, _documentType marks  │
            └───────────────┬───────────────┬───────────────┘
                            │               │
              renders to    │               │   extractDocNode(fn)
              real DOM       │               │   walks the tree
              (live preview) ▼               ▼
            ┌───────────────────┐   ┌─────────────────────────────┐
            │  Browser preview   │   │  DocNode (format-neutral)    │
            │  (rocketstyle CSS) │   │  via @pyreon/connector-      │
            └───────────────────┘   │  document's extractDocumentTree
                                     └───────────────┬─────────────┘
                                                     │  render() / download()

                                     ┌─────────────────────────────┐
                                     │  @pyreon/document renderer    │
                                     │  PDF · DOCX · XLSX · PPTX ·   │
                                     │  HTML · Markdown · email ·    │
                                     │  Slack · Teams · …  (14+)     │
                                     └─────────────────────────────┘

Three packages collaborate:

PackageRole
@pyreon/document-primitivesThis package. The 18 styled building blocks you author with.
@pyreon/connector-documentThe bridgeextractDocumentTree() walks a primitive tree, resolves its rocketstyle styles, and produces a DocNode.
@pyreon/documentThe renderer — takes a DocNode and emits any of the 14+ output formats via render() / download().

Authoring a Document

Compose primitives the same way you'd write any JSX. The tree below renders to DOM in a browser and exports to any format:

import {
  DocDocument, DocPage, DocSection, DocHeading, DocText,
  DocTable, DocDivider, DocList, DocListItem,
} from '@pyreon/document-primitives'

function Invoice(props: { id: string; date: string; items: Item[]; total: string }) {
  return (
    <DocDocument title={`Invoice #${props.id}`} author="Acme Inc.">
      <DocPage size="A4" orientation="portrait">
        <DocSection>
          <DocHeading level="h1">Invoice #{props.id}</DocHeading>
          <DocText>Issued {props.date}</DocText>
        </DocSection>

        <DocDivider color="#e5e7eb" thickness={1} />

        <DocTable
          caption="Line items"
          bordered
          striped
          columns={[
            { key: 'name', label: 'Item', align: 'left' },
            { key: 'qty', label: 'Qty', align: 'center' },
            { key: 'price', label: 'Price', align: 'right' },
          ]}
          rows={props.items.map((i) => ({ name: i.name, qty: i.qty, price: `$${i.price}` }))}
        />

        <DocText variant="label">Total: {props.total}</DocText>
      </DocPage>
    </DocDocument>
  )
}

Reactive Metadata

Components in Pyreon run once at mount. DocDocument's title, author, and subject props each accept either a plain string or a () => string accessor. Function values are stored in _documentProps and resolved by the bridge at extraction time — so every export click reads the live value from the underlying signal, without a const initial = get() workaround.

function ResumeTemplate(props: { resume: () => Resume }) {
  return (
    // title/author resolve at export time — each download reads the LIVE name
    <DocDocument
      title={() => `${props.resume().name} — Resume`}
      author={() => props.resume().name}
    >
      <DocPage>
        <DocSection>
          <DocHeading level="h1">{props.resume().name}</DocHeading>
          <DocText>{props.resume().headline}</DocText>
        </DocSection>
      </DocPage>
    </DocDocument>
  )
}

// Each export reads the live signal value:
const tree = extractDocNode(() => <ResumeTemplate resume={store.resume} />)
await download(tree, 'resume.pdf')

For reactive text content (not metadata), pass a signal accessor and read it inside the body via a per-text-node thunk, so only that node patches when the signal changes:

<DocText>{() => store.field()}</DocText>

Layout: direction, gap, alignX, alignY

These primitives are built on @pyreon/elements, so layout props live in .attrs() (the component definition), not in .theme(). The most important consequence for authors: the Element base accepts direction: 'inline' | 'rows' | 'reverseInline' | 'reverseRows'.

DocRow already configures direction: 'inline' with an 8px gap, and DocSection exposes direction as a dimension ('column' default, 'row'). You rarely set these by hand — the primitive picks the right value.

Rocketstyle Dimensions

Several primitives expose rocketstyle dimensions for visual variation. These are confirmed in the source:

PrimitiveDimensionValues
DocHeadinglevelh1 h2 h3 h4 h5 h6 (controls font size + semantic level)
DocTextvariantbody caption label
DocTextweightnormal bold
DocButtonvariantprimary secondary
DocTablevariant(custom variants via the definition)
<DocHeading level="h2">Section heading</DocHeading>
<DocText variant="caption">Supporting caption text.</DocText>
<DocButton variant="primary" href="/signup">Get started</DocButton>

Exporting to a Format

The one-step helper. Pass a template function that returns a primitive tree; get a DocNode you can hand to @pyreon/document's render() or download():

import { extractDocNode } from '@pyreon/document-primitives'
import { download, render } from '@pyreon/document'

const tree = extractDocNode(() => (
  <DocDocument title="Quarterly Report" author="Aisha">
    <DocPage>
      <DocHeading level="h1">Q4 Results</DocHeading>
      <DocText>Revenue grew 23% YoY.</DocText>
    </DocPage>
  </DocDocument>
))

// download triggers a browser download; render returns the bytes/string
await download(tree, 'report.pdf')
await download(tree, 'report.docx')
const html = await render(tree, 'html')

createDocumentExport (two-step, backward compat)

createDocumentExport(fn) returns a { getDocNode() } helper object — the wrapper-object form, kept for callers that want to pass a DocumentExport instance around. New code should prefer extractDocNode, which is the same operation in one call:

import { createDocumentExport } from '@pyreon/document-primitives'

const helper = createDocumentExport(() => <Resume name="Aisha" />)
const tree = helper.getDocNode()

Resolving CSS-variable themes at export time

Both helpers accept an optional second argument. Under an app running init({ cssVariables: true }), theme values become var(--px-…) references and mode(a, b) pairs that a PDF/DOCX renderer can't evaluate. Pass theme and mode so the bridge inlines them to raw values during extraction:

const tree = extractDocNode(() => <Report />, {
  theme: myThemeObject, // inlines theme-leaf var(--px-…) refs
  mode: 'dark',         // resolves mode(a, b) pairs (default 'light')
})

When the app is on the classic (non-CSS-variable) path, most primitives emit raw literals already, so the resolver is just a cheap safety net — it only rewrites strings that contain var(.

Live Preview

Because every primitive renders to real DOM, the export tree can also drive an interactive preview. The package ships a DocumentPreview wrapper for this — a paginated "page on a canvas" frame (A4/A3/A5/letter/legal) you can mount around your document in the browser:

import DocumentPreview from '@pyreon/document-primitives/dist/DocumentPreview'
// (or: import { DocumentPreview } from '@pyreon/document-primitives')

function Editor(props: { resume: () => Resume }) {
  return (
    <DocumentPreview size="A4">
      <ResumeTemplate resume={props.resume} />
    </DocumentPreview>
  )
}

DocumentPreview is a presentation wrapper for the browser only — it is not one of the 18 export primitives, and it isn't required to export. To get fine-grained per-text-node preview updates (instead of re-mounting the whole tree on every keystroke), pass signal accessors into your template and read them lazily in the body, as shown in Reactive Metadata.

Primitive Reference

All 18 export primitives, the components they map to, and the underlying HTML tag they render in the browser:

Primitive_documentTypeTagKey propsDescription
DocDocumentdocumentdivtitle?, author?, subject?Root container + metadata. Each prop is string | (() => string).
DocPagepagedivsize?, orientation?Page boundary. size ("A4", "Letter", …) + orientation configure paginated outputs.
DocSectionsectiondivdirection? ('column' | 'row')Semantic grouping; default column.
DocRowrowdivHorizontal inline layout, fixed 8px gap.
DocColumncolumndivwidth? (number | string)Column inside a row; width like "30%" / "1fr".
DocHeadingheadingh1h6level? ('h1''h6')Heading; level sets size and semantic level. Default h1.
DocTexttextpvariant?, weight?Paragraph/inline text. variant: body/caption/label.
DocLinklinkahref?Hyperlink. href defaults to "#".
DocImageimageimgsrc, alt?, width?, height?, caption?Embedded image; optional caption beneath.
DocTabletabletablecolumns, rows, headerStyle?, striped?, bordered?, caption?Tabular data; columns/rows are export-only (filtered from the DOM).
DocListlistul/olordered?Bulleted (default) or numbered list.
DocListItemlist-itemliA single list item; can nest a DocList.
DocCodecodeprelanguage?Monospace code block; language enables highlighting.
DocDividerdividerhrcolor?, thickness?Horizontal rule.
DocSpacerspacerdivheight? (default 16)Vertical whitespace, in pixels.
DocButtonbuttonahref?, variant?CTA button (mail-safe table in email, styled link in PDF/DOCX).
DocQuotequoteblockquoteborderColor?Block quote with an indented left border.
DocPageBreakpage-breakdivForces a new page in paginated outputs.

Working with Primitives

Headings & text

<DocHeading level="h1">Quarterly Report</DocHeading>
<DocHeading level="h2">Q4 Results</DocHeading>

<DocText>Plain paragraph content.</DocText>
<DocText variant="caption">Smaller, muted caption.</DocText>
<DocText variant="label" weight="bold">EMPHASIZED LABEL</DocText>

<DocText>
  Read more on
  <DocLink href="https://pyreon.dev">our blog</DocLink>.
</DocText>

Layout: rows, columns, sections

<DocSection direction="column">
  <DocHeading level="h2">Contact</DocHeading>
  <DocRow>
    <DocColumn width="30%">
      <DocText variant="label">Email</DocText>
    </DocColumn>
    <DocColumn width="70%">
      <DocText>aisha@example.com</DocText>
    </DocColumn>
  </DocRow>
</DocSection>

Lists (including nested)

<DocList>
  <DocListItem>Top-level item</DocListItem>
  <DocListItem>
    Item with a sublist
    <DocList ordered>
      <DocListItem>First step</DocListItem>
      <DocListItem>Second step</DocListItem>
    </DocList>
  </DocListItem>
</DocList>

Tables

<DocTable
  caption="Q4 results by region"
  bordered
  striped
  columns={[
    { key: 'region', label: 'Region', align: 'left' },
    { key: 'revenue', label: 'Revenue', align: 'right' },
    { key: 'growth', label: 'YoY Growth', align: 'right' },
  ]}
  rows={[
    { region: 'NA', revenue: '$12.4M', growth: '+23%' },
    { region: 'EU', revenue: '$8.7M', growth: '+18%' },
    { region: 'APAC', revenue: '$5.1M', growth: '+41%' },
  ]}
/>

Images, code, dividers, spacers

<DocImage
  src="/charts/q4-revenue.png"
  alt="Revenue grew 23% in Q4"
  width={600}
  height={400}
  caption="Figure 1: Quarterly revenue, 2024–2025"
/>

<DocCode language="typescript">{
`const flow = createFlow({
  nodes: [{ id: '1', position: { x: 0, y: 0 } }],
  edges: [],
})`
}</DocCode>

<DocText>Above the divider.</DocText>
<DocDivider color="#e5e7eb" thickness={1} />
<DocSpacer height={32} />
<DocText>Below, after extra space.</DocText>

Quotes & buttons

<DocQuote borderColor="#3b82f6">
  <DocText>"The best way to predict the future is to build it."</DocText>
  <DocText variant="caption">— Aisha Patel, Q4 keynote</DocText>
</DocQuote>

<DocButton variant="primary" href="https://pyreon.dev/signup">
  Get started
</DocButton>

Pages & explicit breaks

A DocPage is a page boundary in paginated outputs (PDF, DOCX); flow outputs (HTML, Markdown) render its contents inline. DocPageBreak forces a new page mid-content:

<DocDocument>
  <DocPage size="A4" orientation="portrait">
    <DocHeading level="h1">Section 1</DocHeading>
    <DocText>…long content…</DocText>
    <DocPageBreak />
    <DocHeading level="h1">Section 2 — new page</DocHeading>
  </DocPage>
  <DocPage size="A4" orientation="landscape">
    <DocHeading level="h1">Appendix (landscape)</DocHeading>
  </DocPage>
</DocDocument>

How Outputs Differ

The same tree maps to whatever a target format supports — paginated formats honor page boundaries; flow formats inline them:

PrimitivePaginated (PDF / DOCX)Flow (HTML / Markdown)Plain text / chat
DocPage / DocPageBreakReal page boundary / page breakInline, no boundaryOmitted or whitespace
DocHeadingHeading style / <h1><h6><h1><h6> / #######Uppercased / prefixed line
DocLinkClickable link<a href> / [text](href)text (href)
DocButtonLabeled linkStyled link / mail-safe buttonPlain label + URL
DocTableNative table<table> / pipe tableBest-effort columns
DocDividerHorizontal rule<hr> / ---A line of dashes

The renderer (@pyreon/document) owns these mappings — see its reference for the full per-format behavior and the complete list of supported output formats.

A Latent Framework Bug, Fixed (PR #197)

Before PR #197, the bridge (extractDocumentTree) only read _documentProps off the JSX vnode's direct props. But rocketstyle's .attrs() HOC stamps _documentProps after the component function runs — so every real primitive's metadata was silently dropped on export. The extractor now calls the component function to capture the post-.attrs() vnode and reads _documentProps from there.

This is purely background context for how the bridge resolves metadata — there's nothing to do in your code. It explains why primitives must be invoked (not merely inspected) during extraction.

API Reference

Helper functions

FunctionSignatureDescription
extractDocNodeextractDocNode(templateFn: () => VNode, options?: DocumentExportOptions): DocNodeOne-step extraction (recommended). Walks the template's tree into a format-neutral DocNode.
createDocumentExportcreateDocumentExport(templateFn: () => VNode, options?): { getDocNode(): DocNode }Two-step wrapper kept for backward compat; delegates to extractDocNode.
extractDocumentTreeextractDocumentTree(vnode, options?): DocNodeRe-exported from @pyreon/connector-document — the bridge that walks a primitive tree.
resolveStylesresolveStyles(...)Re-exported from @pyreon/connector-document — resolves a primitive's rocketstyle styles.

DocumentExportOptions

Passed as the optional 2nd argument to extractDocNode / createDocumentExport:

FieldTypeDescription
theme?Record<string, unknown>Theme to resolve CSS-variable (var(--px-…)) style values against. Needed for export under init({ cssVariables: true }).
mode?'light' | 'dark'Active mode for resolving mode(a, b) var pairs. Default 'light'.
resolveVar?VarResolver(from ExtractOptions) An explicit var resolver; takes precedence over theme / mode.

Components

The 18 export primitives are listed in the Primitive Reference table above. One extra component ships for browser preview only:

ComponentDescription
DocumentPreviewBrowser-only paginated preview frame (size: A4/A3/A5/letter/legal). Not part of the export pipeline.

Re-exported types

From @pyreon/connector-document: DocNode, DocChild, ExtractOptions, NodeType, ResolvedStyles. From this package: DocumentExport, DocumentExportOptions, DocumentTheme (+ the documentTheme token object).

  • @pyreon/connector-document — the bridge (extractDocumentTree) that turns a primitive tree into a DocNode.

  • @pyreon/document — the renderer that emits PDF / DOCX / HTML / Markdown / email / Slack / Teams / … from a DocNode.

  • @pyreon/rocketstyle — the multi-dimensional styling engine every primitive is built on.

  • @pyreon/elements — the layout base (Element, Text) the primitives wrap.

Document Primitives