Home  ›  Guides  ›  React use cases

React use cases

Three architectures built on pdfnative-react v1.3.0 (peer pdfnative ^1.8.0, React ^19.0.0, Node ≥ 22). Lint a document model in CI before any PDF exists; typeset long-form documents like a book from JSX; and render one component tree into a screen PDF/A file and a press PDF/X-4 file. Every prop, export and lint rule named below ships in v1.3.0.

This guide is the React companion to the ecosystem use cases, which sets the convention (problem, load-bearing code, gains, limits — with an architecture diagram where data crosses components) and covers the cross-surface architectures. The React guide is the reference for the components, the rendering entry points and the DocSpec authoring surface used here.

Case 1 — A DocSpec lint gate in CI, before any PDF exists#

Most document bugs are visible in the model long before a byte is written: a PDF/A claim with no embedded fonts, a chart with two series in a pie, a pdfx document whose output intent is a monitor profile. pdfnative-react compiles JSX and DocSpec to the same DocumentParams, and lintDocument / lintSpec run 37 rules on that model — 23 errors, 13 warnings, 1 info — without rendering. The twelve rules added in v1.3.0 cover the engine 1.8.0 surface: L_TYPOGRAPHY_INEFFECTIVE, L_PRINT_COLOUR_BARS, L_CMYK_INTENT_MISMATCH, L_OUTPUT_INTENT_PROFILE and the eight L_PDFX_* rules (L_PDFX_TARGET, L_PDFX_TAGGED_CONFLICT, L_PDFX_ENCRYPTED, L_PDFX_OUTPUT_INTENT, L_PDFX_TRAPPED_UNKNOWN, L_PDFX_BOXES, L_PDFX_NO_FONTS, L_PDFX_ANNOTATIONS).

Architecture: a pull request carries two kinds of input, a JSX document tree and stored DocSpec JSON records. Both flow into the same lint stage, lintDocument for JSX and lintSpec for JSON, preceded by validateSpec for the JSON shape. The stage runs 37 rules on the compiled document model and writes no PDF bytes. A report with ok equal to true passes the pull-request check; a report with error findings fails it and prints each finding's code, message and hint. A dashed arrow leads from the passing report to a separate bytes stage, the hub's Case 3, where the document is rendered and compared against a golden PDF.

The stored-spec route pairs validateSpec (is this JSON a DocSpec at all — unknown fields, wrong types, the 18 DOC_SPEC_FIELDS including the 1.3.0 pdfx, outputIntent, typography and creationDate) with lintSpec (is it a document the engine will accept and a reader can use). Both return plain data: a LintReport is { ok, findings, counts }, where ok is false only when a finding has severity 'error'.

// tests/documents.lint.test.tsx — runs on every pull request; writes nothing.
import { describe, expect, it } from 'vitest';
import { lintDocument, lintSpec, validateSpec } from 'pdfnative-react';
import type { DocSpec } from 'pdfnative-react';
import { Invoice } from '../src/documents/Invoice.js';
import stored from './fixtures/stored-specs.json'; // DocSpecs as the database holds them
import fixture from './fixtures/invoice.json';

describe('document lint gate', () => {
  it('the JSX invoice has no blocking finding', () => {
    const report = lintDocument(<Invoice data={fixture} />, { overflow: true });
    expect(report.findings.filter((f) => f.severity === 'error')).toEqual([]);
  });
  it.each(stored)('stored spec $title still validates and lints', (spec: unknown) => {
    expect(validateSpec(spec).errors).toEqual([]);        // shape: fields and types
    const report = lintSpec(spec as DocSpec);             // model: engine + accessibility
    expect(report.ok, JSON.stringify(report.findings, null, 2)).toBe(true);
  });
});

What you gain, concretely:

Honest limits: lint checks the model, not the bytes. A PDF/A claim is still verified by veraPDF on the rendered file, and a PDF/X-4 claim by the engine's structural validator, which is deliberately not re-exported — it is one import away, as docs/RECIPES.md in the package shows:

import { validatePdfX } from 'pdfnative';
import { renderToBytes } from 'pdfnative-react';
const { valid, errors } = validatePdfX(renderToBytes(<PressSheet />));

L_OVERFLOW (a block taller than the page) needs a full layout pass and is opt-in through { overflow: true } — it costs about as much as rendering. There is no visual check: nothing in the model says whether the page looks right.

Case 2 — Long-form documents typeset like a book, in JSX#

A report that runs to forty pages exposes every default of a naive paginator: a heading stranded at the foot of a page, a single line carried to the next one, ragged right margins in a two-column brief. Engine 1.8.0 ships a typography engine, and pdfnative-react v1.3.0 exposes it as a <Document> prop (typography, sugar over layout.typography) with per-block overrides: <Heading keepWithNext>, <Paragraph align="justify" keepWithNext splittable>.

import { Document, Heading, Paragraph, Table, inspectDocument, renderToBytes,
         resolveFonts, setHyphenationProvider } from 'pdfnative-react';
const fontEntries = await resolveFonts({ latin: () => import('pdfnative/fonts/noto-sans-data.js') });
setHyphenationProvider((word, lang) => hyphenate(word, lang)); // optional: your Liang-pattern library

const book = (
  <Document title="Field guide" fontEntries={fontEntries}
            typography={{ splitParagraphs: true, orphans: 2, widows: 2,
                          keepHeadingsWithNext: { minLines: 3 }, opticalMargins: true,
                          kerning: true, fontFeatures: ['onum'], hyphenationLanguage: 'en' }}>
    <Heading level={1}>Chapter 1</Heading>
    <Heading level={2} keepWithNext>Habitat</Heading>
    <Paragraph align="justify" splittable>{chapterOne}</Paragraph>
    <Paragraph keepWithNext>Table 1 summarises the ranges:</Paragraph>
    <Table rows={ranges} />
  </Document>
);
const { totalPages } = inspectDocument(book);          // the same planner, no bytes
if (totalPages > 48) throw new Error(`page budget exceeded: ${totalPages} pages`);
const bytes = renderToBytes(book);

inspectDocument runs the planner that renderToBytes uses and returns the page count and every block's placement, so a page budget or a "table 1 is on the same page as its caption" assertion is a unit test, not a PDF you open.

What you gain, concretely:

Honest limits: kerning, fontFeatures and the narrow no-break space of punctuationSpacing: 'fr' need a registered font (fontEntries) — the base-14 faces carry no OpenType tables and no U+202F. tnum and lnum are no-ops on the bundled Noto Sans, whose figures are tabular and lining already. No hyphenation dictionary ships: soft hyphens (U+00AD) are honoured, and a provider set through setHyphenationProvider receives hyphenationLanguage with every word. The React renderer does not sign, fill forms or post-process existing PDFs — hand its bytes to the CLI or the engine for that.

Case 3 — One component tree, two palettes: screen PDF/A and press PDF/X-4#

The same catalogue goes to a customer portal and to a printer, and the two files have contradictory requirements: the portal wants a tagged PDF/A-2b file with sRGB colours; the press wants PDF/X-4 with CMYK plate values, a printer output intent, a bleed and colour bars — and PDF/X forbids the tagged claim. In v1.3.0 every colour prop accepts a CMYK value beside hex and RGB, and pdfx, outputIntent and print are <Document> props, so the palette is the only thing that changes between the two renders.

Architecture: one Sheet component tree receives a palette prop and is rendered twice. The first wrapper is a Document with tagged set to pdfa2b and the screen palette in hex; it produces the portal PDF, a tagged PDF/A-2b file under the built-in sRGB output intent. The second wrapper is a Document with pdfx set to pdfx4, a CMYK ICC output intent, trapped set to False and print bleed with crop marks and colour bars; it receives the press palette as CMYK tuples and produces the press PDF. A guard labelled L_CMYK_INTENT_MISMATCH sits on the screen path: a CMYK value that leaks into the PDF/A render is a lint warning, mirroring the engine diagnostic PDFA_DEVICE_CMYK_CONTENT.

import { Document, Heading, Paragraph, renderToBytes } from 'pdfnative-react';
const palette = {
  screen: { ink: '#111827', accent: '#2563EB' },              // hex, for the portal
  press:  { ink: [0, 0, 0, 100], accent: [100, 60, 0, 0] },   // C M Y K in percent, for the press
} as const;

const Sheet = ({ p }: { p: (typeof palette)[keyof typeof palette] }) => (
  <>
    <Heading color={p.ink}>Spring catalogue</Heading>
    <Paragraph color={p.accent}>Prices valid until 30 June.</Paragraph>
  </>
);

const portal = renderToBytes(
  <Document tagged="pdfa2b" fontEntries={fontEntries}><Sheet p={palette.screen} /></Document>);
const press = renderToBytes(
  <Document pdfx="pdfx4" fontEntries={fontEntries} metadata={{ trapped: 'False' }}
            outputIntent={{ iccProfile: pressProfile, outputConditionIdentifier: 'FOGRA39' }}
            print={{ bleed: 14.17, marks: { crop: true, colourBars: true } }}>
    <Sheet p={palette.press} /></Document>);

iccProfile is the printer's ICC profile as bytes (a prtr device class — a monitor profile such as sRGB is refused by L_PDFX_OUTPUT_INTENT), and trapped: 'False' states the trapping status PDF/X requires — pdfnative never traps. A 5 mm bleed (14.17 pt) is the strip the engine recommends for the colour bars: below it the patches fall under a densitometer aperture and L_PRINT_COLOUR_BARS warns; under 4 pt the engine skips the bars.

What you gain, concretely:

Honest limits: there is no RGB → CMYK conversion — plate values are authored, and the engine performs no colour management beyond mapping RGB content under a CMYK or Gray intent through /DefaultRGB. pdfx and tagged are mutually exclusive (one conformance claim per file), so the press file is not tagged. The bytes-level PDF/X-4 check is the engine's validatePdfX import shown in Case 1, and a valid result means the structural prerequisites hold — confirm the file with a certified preflight before it goes to press. No press profile ships with either package; the ICC profile is yours.

See also#