neeto-pdf-nano
neeto-pdf-nano adds reusable PDF templates, field mapping, preview, download,
and email attachments to a Rails and React application.
It ships as two packages:
neeto-pdf-engine: the Rails engine.@bigbinary/neeto-pdf-frontend: the React component library.
Contents
- What the nano provides
- What the host product must provide
- Quick start
- Components
- Server-side rendering
- API endpoints
- Testing
- Local development
- Publishing
What the nano provides
The Rails engine owns:
- PDF document storage and Active Storage template attachments.
- Tenant-scoped document APIs.
- Mapping persistence and validation.
- Server-side PDF rendering.
- Document cloning and output filename templates.
The frontend package owns:
- The PDF documents list and upload flow.
- The field-mapping editor.
- Preview and download controls.
- Email attachment selection and preview components.
The examples below use the current @bigbinary/neeto-pdf-frontend/v2
entrypoint. The package also keeps its legacy root entrypoint for existing
consumers.
What the host product must provide
The host supplies an owner for reusable templates and a data record whose values fill each PDF. It also owns:
- Tenant authorization and product routes.
- Field normalization and value formatting.
- Preview-record pagination.
- Email settings and attachment delivery.
- Owner cloning and lifecycle cleanup when applicable.
The host must use Rails, Active Record, Active Storage, PostgreSQL, and
NeetoCommonsBackend::Api::BaseController. The frontend package uses the peer
dependencies declared in package.json, including React Query, Axios, i18next,
Formik, Tailwind CSS, neetoAtoms, neetoUI, and neetoMolecules.
Quick start
Backend engine
1. Install the engine
source "NEETO_GEM_SERVER_URL" do
gem "neeto-pdf-engine"
end
bundle install
2. Copy the document migration
db/migrate/20260706120000_create_neeto_pdf_engine_documents.rb
Give it a free host timestamp, then run:
bundle exec rails db:migrate
Do not run the general engine migration installer; this repository contains
unrelated template migrations. owner_id is a UUID. Adapt and test the copied
migration first if the owner uses another primary-key type.
3. Configure the owner and render context
Create config/initializers/neeto_pdf_engine.rb:
# frozen_string_literal: true
NeetoPdfEngine.owner_class = "DocumentOwner"
NeetoPdfEngine.render_context_resolver = lambda do |owner:, record_id:|
PdfDocuments::RenderContextResolver.call(owner:, record_id:)
end
Replace DocumentOwner with the real model. It must be the current organization
or support where(organization: current_organization). Add:
class DocumentOwner < ApplicationRecord
belongs_to :organization
has_many :neeto_pdf_documents,
as: :owner,
class_name: "NeetoPdfEngine::Document",
dependent: :destroy
end
Implement the render context:
module PdfDocuments
class RenderContextResolver
def self.call(owner:, record_id:)
record = owner.data_records.find(record_id) # Use the real association.
{
value_resolver: ValueResolver.new(owner:, record:),
filename_context: { id: record.id }
}
end
end
end
Always find the data record through the owner. The value resolver must respond
to call(mapping):
module PdfDocuments
class ValueResolver
def initialize(owner:, record:)
@owner = owner
@record = record
end
def call(mapping)
return system_value(mapping["field_slug"]) if mapping["source"] == "system"
value_for(mapping["field_slug"])
end
private
attr_reader :owner, :record
def value_for(field_slug)
# Return the host-formatted value.
end
def system_value(field_slug)
case field_slug
when "form_name" then owner.name
when "submission_id" then record.id
when "submission_date" then I18n.l(record.created_at, format: :long)
else ""
end
end
end
end
The built-in system keys are form_name, submission_id, and
submission_date; the host defines their meaning. For an image, return:
{
type: "image",
data: attachment.download,
content_type: attachment.blob.content_type
}
Use image/svg+xml for SVG data. Return text, a number, or "" for other
mappings.
4. Mount and protect the engine
Mount the engine:
mount NeetoPdfEngine::Engine, at: "/neeto_pdf"
const PDF_API_BASE_URL = "/neeto_pdf/api/v1";
The engine authenticates and scopes owners to the current organization. The host
must add server authorization to NeetoPdfEngine::Api::V1::DocumentsController,
including the render action. canManage only changes the UI.
5. Handle host lifecycle rules
When applicable, add NeetoPdfEngine::Document to host incineration, clone PDF
documents with a cloned owner, and remap saved record_id values to cloned
fields.
Frontend package
1. Install the package and PDF runtimes
yarn add @bigbinary/neeto-pdf-frontend [email protected] [email protected]
The package uses the host's Axios case transforms, React Query provider, and i18next instance.
2. Include styles and translations
There is no PDF stylesheet. Keep neetoUI and neetoMolecules styles enabled, and
make Tailwind scan @bigbinary packages. For Tailwind CSS 4, adjust this path
relative to the host stylesheet:
@import "tailwindcss" important;
@source "../../../../node_modules/@bigbinary";
The standard neeto-commons build loads package translations. Other builds must
merge @bigbinary/neeto-pdf-frontend/app/javascript/src/translations/en.json
into their i18next resources.
3. Serve the PDF.js worker
Add:
{
"scripts": {
"sync:pdf-worker": "mkdir -p public && cp -f node_modules/pdfjs-dist/legacy/build/pdf.worker.min.mjs public/pdf.worker.min.js"
}
}
Run it before dev and build. Serve the result at:
/pdf.worker.min.js
The generated worker may be ignored in Git.
Components
Documents page
import { PdfDocumentsPage } from "@bigbinary/neeto-pdf-frontend/v2";
const PdfDocuments = ({ ownerId, emailAttachments }) => (
<PdfDocumentsPage
apiBaseUrl="/neeto_pdf/api/v1"
canManage={canManagePdfDocuments}
emailAttachments={emailAttachments}
ownerId={ownerId}
onOpenEditor={document => openEditor(document.id)}
/>
);
The page lists documents in a table. Uploading one opens the editor straight away, so mapping is the next step rather than a separate decision.
onOpenEditorreceives the complete document object. It is called both when a row is opened and right after a successful upload.emailAttachmentsdescribes the emails a document can be attached to, as[{ key, label, dataTestId, onSelect }]. It drives the "Attach PDF to email" menu in the row kebab and in the editor top bar. The items only navigate; attaching happens on those email pages, not here.renderHeaderandrenderContentcan fit the page into a host layout.- Pass
canManageexplicitly. It only changes the UI.
Uploads use neetoMolecules FileUpload. Active Storage must return signedId.
Templates must be PDFs of 10 MB or less.
Editor
import { PdfEditorPage } from "@bigbinary/neeto-pdf-frontend/v2";
<PdfEditorPage
apiBaseUrl="/neeto_pdf/api/v1"
canManage={canManagePdfDocuments}
documentId={documentId}
emailAttachments={emailAttachments}
fields={pdfFields}
homeUrl={pdfDocumentsUrl}
isPreviewLoading={isPreviewLoading}
mode={mode}
ownerId={ownerId}
previewPage={previewPage}
previewRecord={previewRecord}
previewTotal={previewTotal}
onBack={goToPdfDocuments}
onModeChange={setMode}
onPreviewPageChange={setPreviewPage}
/>;
Use a real homeUrl. mode and onModeChange are only needed for
host-controlled mode state.
Field shape
const pdfField = {
id: "field-id",
key: "stable-field-key",
recordId: "field-id",
slug: "stable-field-key",
label: "Display label",
kind: "email",
renderType: "text",
icon: EmailIcon,
typeLabel: "Email",
};
Use a stable recordId, plain-text labels, and renderType: "image" only for
image data. Exclude fields that cannot contain values.
Preview record shape
const previewRecord = { id: "data-record-id" };
The editor sends this ID to the render endpoint. The engine then calls
render_context_resolver, keeping preview and attachment rendering on the same
value path.
Email attachment controls
import {
PdfAttachmentSelector,
PdfAttachmentsPreview,
} from "@bigbinary/neeto-pdf-frontend/v2";
import { Form, Formik } from "formik";
<Formik
initialValues={{ pdfDocumentIds: persistedPdfDocumentIds }}
onSubmit={saveEmailSettings}
>
<Form>
<PdfAttachmentSelector
documents={pdfDocuments}
maxTotalSize={25 * 1024 * 1024}
/>
<PdfAttachmentsPreview documents={pdfDocuments} />
</Form>
</Formik>;
Formik: pdfDocumentIds
Frontend API: customFields.pdfDocumentIds
Rails: custom_fields["pdf_document_ids"]
Permit custom_fields[:pdf_document_ids] as an array. Remove deleted or
inaccessible IDs before saving. Fetch documents with the same owner ID used by
the editor. Pass maxTotalSize in bytes to prevent selections whose combined
template size would exceed the email attachment limit. When a selection would
exceed the limit, the selector keeps it unchecked and explains the document size
and remaining space.
Server-side rendering
render_context = PdfDocuments::RenderContextResolver.call(
owner:,
record_id: record.id
)
owner.neeto_pdf_documents.where(id: selected_pdf_document_ids).map do |document|
NeetoPdfEngine::Documents::RendererService.new(
document,
**render_context
).process
end
Each result contains:
{
filename: "document-record-id.pdf",
mime_type: "application/pdf",
content: "<binary PDF>",
truncated_mapping_ids: []
}
The host attaches or encodes content. Encrypted PDFs raise
NeetoPdfEngine::EncryptedDocumentError. Built-in fonts support ASCII and
Latin-1; other characters are removed during server rendering.
API endpoints
With the engine mounted at /neeto_pdf:
GET /neeto_pdf/api/v1/documents
POST /neeto_pdf/api/v1/documents
GET /neeto_pdf/api/v1/documents/:id
PATCH /neeto_pdf/api/v1/documents/:id
PUT /neeto_pdf/api/v1/documents/:id
PATCH /neeto_pdf/api/v1/documents/:id/template_file
POST /neeto_pdf/api/v1/documents/:id/clone
POST /neeto_pdf/api/v1/documents/:id/render
DELETE /neeto_pdf/api/v1/documents/:id
Every request requires owner_id; clients cannot choose owner_type. Render
also requires record_id and accepts temporary mappings without persisting
them. Uploads accept an Active Storage signed blob ID or multipart
template_file.
The default output filename is:
<parameterized-document-name>-{{id}}.pdf
filename_context[:id] replaces {{id}}.
Testing
Run the Rails test suite and build the frontend package before publishing a change:
bundle exec rails test
yarn build
For documentation-only changes, also run:
yarn prettier --check README.md
git diff --check -- README.md
Local development
gem "neeto-pdf-engine", path: "../neeto-pdf-nano"
For local frontend work, follow the frontend package development guide.
Publishing
Follow the building and releasing packages guide to publish the frontend package and Rails engine.