Pageworks — Developer Reference
This is the complete external contract for building a dependency app against
Pageworks. The engine ships closed-source (resourceExposurePolicy fully closed — no
debugging, no source download, no source in the symbol file); this document, plus the
symbol package, is everything a third-party developer will ever see or need. If a step
in your onboarding requires reading engine source, that is a gap in this document, not
something to work around by asking for the source.
1. Consuming the engine as a dependency
Add Pageworks to your app's dependencies array in app.json:
{
"dependencies": [
{
"id": "c653f0a4-b86d-4122-8396-17efd3d7a703",
"publisher": "Stefan Maron Consulting",
"name": "Pageworks",
"version": "1.0.0.0"
}
]
}
Use the actual installed version in your environment for version — this is the
minimum, not a pin. Once the dependency is declared and symbols are downloaded, only
objects marked Access = Public on the engine side are visible to your code at all;
everything else does not resolve, by design. The complete list of public members is
API stability promise in the engine's own repo — restated here only as needed to
use each extension point (sections 3–6 below).
2. Extension point 1 — Registering Blocks
"Blocks" is the user-facing name shown in the client (the Pageworks Blocks page, its
captions, tooltips, and messages). The underlying identifiers — the PageworksRegistry
API below, its RegisterPartial procedure, the PageworksPartial table, and the
{{> name}} include syntax — all still say "Partial"; there is no RegisterBlock alias.
As a developer you call RegisterPartial; your users see the results as "Blocks".
A Block (registered via the RegisterPartial API) is a named, shared template fragment
(company footer, address block, style header) that any Pageworks template can pull in
with {{> name}}. Registering your own Blocks lets your app ship reusable fragments that
consuming reports — yours or anyone else's — can reference, and that tenants can later
override without touching your code.
Call the public PageworksRegistry codeunit (codeunit 71179686) from your own app's
install or upgrade codeunit — never from a page or a directly-invoked action; the engine
requires a real install/upgrade module context (NavApp.GetCallerModuleInfo()) and
errors out otherwise.
namespace MyCompany.MyApp;
using SMC.Pageworks.Partials;
codeunit 70101 MyAppInstall
{
Subtype = Install;
trigger OnInstallAppPerCompany()
begin
RegisterPageworksPartials();
end;
local procedure RegisterPageworksPartials()
var
Registry: Codeunit PageworksRegistry;
begin
Registry.RegisterSource('mycompany');
Registry.RegisterPartial(
'company-footer',
'<span>My Company Ltd. - VAT GB123456789 - www.mycompany.example</span>',
'Standard company footer');
end;
}
Signatures (both Access = Public on PageworksRegistry):
procedure RegisterSource(Prefix: Code[20])
procedure RegisterPartial(Name: Code[50]; Content: Text; Description: Text[100])
Rules a consumer must obey:
- Call
RegisterSourceonce per app to claim a namespace prefix (e.g.'mycompany'). Prefixes are unique across every installed app in the tenant — the first registrant keeps a given prefix; a conflicting claim fails your install with an actionableLF-PREFIXerror naming the owning app. - Call
RegisterPartialafterRegisterSourcefor each baseline Block you ship.Nameis unqualified and only needs to be unique within your own app. - Both calls are idempotent: re-registering identical content is a no-op (compared by content hash); re-registering with changed content updates your baseline only — tenant-created overrides of that Block are never touched.
- Reference your Blocks from any template two ways:
- Unqualified —
{{> company-footer}}. Resolves tenant-override-first, then a unique baseline match across all installed apps. If more than one app's baseline registers the same unqualified name, an unqualified reference is ambiguous (LF-AMBIG) — the finding names the candidates and how to qualify. - Qualified —
{{> mycompany/company-footer}}. Pinned to your app's prefix; always unambiguous, and the right choice when you reference your own Block from your own templates.
- Unqualified —
- Resolution precedence: a tenant override, if one exists for that name, always wins over any extension baseline, regardless of qualification. Nothing else — no version, no install order — factors into precedence.
- An unresolved
{{> name}}(no baseline and no override) isLF-PARTIAL; an include chain that cycles back on itself isLF-CYCLE. Both are validation/render errors, not silent fallbacks. - Caller identity is derived from the platform's module info, never from a parameter —
you cannot register a Block under another app's identity, and you cannot spoof
RegisterSource's ownership check.
3. Extension point 2 — Registering images
An extension can ship its own baseline image assets (a logo, a stamp, a signature
graphic) through the same public PageworksRegistry codeunit, referenceable by
templates via <img src="..."> exactly like a tenant-uploaded image — see
Template language reference's "Images" section for the
full src resolution grammar and format rules.
codeunit 70101 MyAppInstall
{
Subtype = Install;
trigger OnInstallAppPerCompany()
begin
RegisterPageworksImages();
end;
local procedure RegisterPageworksImages()
var
Registry: Codeunit PageworksRegistry;
ImageData: Codeunit "Temp Blob";
ImageOutStream: OutStream;
begin
ImageData.CreateOutStream(ImageOutStream);
// ... write your JPEG/PNG source bytes to ImageOutStream ...
Registry.RegisterImage('Logo', ImageData, 'Corporate letterhead logo');
end;
}
Signature (Access = Public on PageworksRegistry):
procedure RegisterImage(Name: Code[50]; var ImageData: Codeunit "Temp Blob"; Description: Text[100])
Rules a consumer must obey:
- The image format (JPEG or PNG) is auto-detected from the content's own magic bytes — you never declare a format.
- Unlike
RegisterPartial/RegisterFont,RegisterImagedoes not require a priorRegisterSourcecall: unqualified resolution (src="Logo") works without a claimed prefix; qualified resolution (src="acme/Logo") still requires one. - Caller identity is derived from
NavApp.GetCallerModuleInfo(), exactly likeRegisterPartial/RegisterFont— never a parameter, never spoofable. - Registration is idempotent: re-registering byte-identical content (SHA-256 hash compare) is a no-op; changed content updates your baseline in place. A tenant upload of the same effective name overrides your baseline, mirroring the Block/font precedent exactly.
- The image is validated eagerly, before it is stored — an unsupported container format,
corrupt bytes, or content exceeding the 10 MB per-asset limit fails the
RegisterImagecall itself with an actionableLF-IMGFMT/LF-IMGCORRUPT/LF-IMGSIZEerror (see Error & finding code catalog) — never a bad asset silently accepted and failing later at render time.
4. Extension point 3 — Registering shared stylesheets
An extension can ship its own baseline stylesheet — a set of named classes, each a
closed bag of the same style properties available inline — through the same public
PageworksRegistry codeunit. A template references it via <style-sheets src="...">
and applies a class with class="name" exactly like a tenant-defined stylesheet — see
the Shared styles guide for the full authoring syntax, cascade, and
precedence rules.
codeunit 70101 MyAppInstall
{
Subtype = Install;
trigger OnInstallAppPerCompany()
begin
RegisterPageworksStyleSheets();
end;
local procedure RegisterPageworksStyleSheets()
var
Registry: Codeunit PageworksRegistry;
begin
Registry.RegisterSource('mycompany');
Registry.RegisterStyleSheet(
'brand',
'.brand-heading { color: #1A2B3C; font-weight: bold; }',
'Corporate brand classes');
end;
}
Signature (Access = Public on PageworksRegistry):
procedure RegisterStyleSheet(Name: Code[50]; Content: Text; Description: Text[100])
Rules a consumer must obey:
- Requires a prior
RegisterSourcecall, exactly likeRegisterPartial—Nameis unqualified and only needs to be unique within your own app. - Content is validated against the same closed style-property allowlist inline
style="..."uses, before it is stored — an invalid property or value fails theRegisterStyleSheetcall itself with an actionableLF-UNSUPerror, never a bad stylesheet silently accepted and failing later at render/validation time. - Registration is idempotent: re-registering byte-identical content (SHA-256 hash compare) is a no-op; changed content updates your baseline in place. A tenant override of the same effective name is never touched, mirroring the Block/image/font precedent.
- Caller identity is derived from
NavApp.GetCallerModuleInfo(), never a parameter, never spoofable.
5. Extension point 4 — Registering page sizes
An extension can ship its own baseline named page sizes through the same public
PageworksRegistry codeunit. A registered page size is referenceable from any template
via page-size="Name" exactly like the built-in A4/Letter — see the
Template language reference's "Custom page sizes" section
for how authors use them (including entering dimensions in mm/inch as well as points in
the client).
codeunit 70101 MyAppInstall
{
Subtype = Install;
trigger OnInstallAppPerCompany()
begin
RegisterPageworksPageSizes();
end;
local procedure RegisterPageworksPageSizes()
var
Registry: Codeunit PageworksRegistry;
begin
// A5 portrait: 419.53 x 595.28 pt, 42.5 pt margins all round.
Registry.RegisterPageSize('A5', 419.53, 595.28, 42.5, 42.5, 42.5, 42.5, 'ISO A5');
end;
}
Signature (Access = Public on PageworksRegistry):
procedure RegisterPageSize(Name: Code[50]; WidthPt: Decimal; HeightPt: Decimal; MarginTopPt: Decimal; MarginRightPt: Decimal; MarginBottomPt: Decimal; MarginLeftPt: Decimal; Description: Text[100])
Rules a consumer must obey:
- All dimensions and margins are supplied in points (the engine's canonical unit; 1 in = 72 pt). The client's Page Sizes card additionally displays and accepts mm and inches, computing points for you — but the API takes points.
- Like
RegisterImage,RegisterPageSizedoes not require a priorRegisterSourcecall. Caller identity is derived fromNavApp.GetCallerModuleInfo(), never a parameter. - Registration is idempotent: re-registering identical values is a no-op; changed values update your baseline in place. A tenant-created page size (or tenant override) of the same name always wins over your baseline at render time, mirroring the Block/image/font precedent.
- Referencing a name that resolves to no page size is a validation/render error
(
LF-UNSUPnaming the value) — never a silent fallback to a default size.
6. Extension point 5 — Wiring a report layout
Wiring is entirely declarative — there is no AL call into the engine. A report or report
extension opts a layout into Pageworks rendering by giving it the engine's MIME-type
token in a rendering { layout { ... } } block:
report 71179675 CustomerListPageworks
{
DefaultRenderingLayout = PageworksLayout;
dataset
{
dataitem(Customer; Customer)
{
column(No; "No.") { }
column(Name; Name) { }
}
}
rendering
{
layout(PageworksLayout)
{
Type = Custom;
LayoutFile = './src/Demo/CustomerListPageworks.pageworks';
MimeType = 'reportlayout/pageworks';
Caption = 'Pageworks template';
Summary = 'Layered XHTML template rendered by Pageworks.';
}
}
}
The same shape works on a reportextension to add a Pageworks layout to a report you
don't own, without touching its dataset:
reportextension 71179692 StandardSalesInvoicePageworks extends "Standard Sales - Invoice"
{
rendering
{
layout(SalesInvoicePageworksPWSTM)
{
Type = Custom;
LayoutFile = './src/Demo/SalesInvoicePageworks.pageworks';
MimeType = 'reportlayout/pageworks';
Caption = 'Sales Invoice (Pageworks)';
Summary = 'Showcase invoice layout rendered natively in-tenant by Pageworks.';
}
}
}
Type = CustomandMimeType = 'reportlayout/pageworks'are both required — this exact token pair is the stable contract (API stability promise, item 5). The engine's subscriber only handles layouts carrying this MIME type; every other report and layout is left entirely to the platform.LayoutFilepoints at your template — a plain, well-formed XHTML file shipped as a normal source file in your app. The conventional naming is the single*.pageworksextension (see./src/Demo/CustomerListPageworks.pageworksabove), not a double*.pageworks.html;LayoutFile's on-disk extension doesn't affect an extension-wired layout's MIME type, sinceMimeType = 'reportlayout/pageworks'is declared explicitly in AL, but using the single extension consistently matters for a manually-uploaded layout, where Business Central derives the MIME type from the file extension (see Creating a layout in the client). The Sales Invoice demo (./src/Demo/SalesInvoicePageworks.pageworks) also uses the single.pageworksextension, so that exporting and re-uploading a copy of the demo through the client keeps working. It is not registered via any AL call; the platform packages it as the layout's content at build time, same as any other custom report layout.- Requesting PDF, Preview, or Print from a Pageworks-wired layout is served by the engine's
native PDF backend. Requesting Word or Excel raises an actionable
LF-FMTerror instead of producing empty output; declare a second (RDLC or Word) layout in the samerenderingblock if the report must also offer those formats. - The template language itself (root attributes, structural elements,
data-each, bindings, inline styles) is published for authors as Template language reference — read it once you start writing.pageworkscontent. It is a supported subset of XHTML/CSS — a closed allowlist, not full HTML/CSS — and anything outside it is anLF-UNSUPfinding.
7. Extension point 6 — Registering fonts
An extension can ship its own font programs (e.g. a corporate-branding typeface bundled
with a vertical solution) through the same public PageworksRegistry codeunit,
resolvable by templates exactly like a tenant-uploaded font.
codeunit 70101 MyAppInstall
{
Subtype = Install;
trigger OnInstallAppPerCompany()
begin
RegisterPageworksFonts();
end;
local procedure RegisterPageworksFonts()
var
Registry: Codeunit PageworksRegistry;
FontData: Codeunit "Temp Blob";
FontOutStream: OutStream;
begin
FontData.CreateOutStream(FontOutStream);
// ... write your TTF/OTF font program bytes to FontOutStream ...
Registry.RegisterFont('MyApp-Sans', Enum::PageworksFontStyleVariant::Regular, FontData, 'MyApp corporate sans, regular weight');
end;
}
Signature (Access = Public on PageworksRegistry):
procedure RegisterFont(Name: Code[50]; StyleVariant: Enum PageworksFontStyleVariant; var FontData: Codeunit "Temp Blob"; Description: Text[100])
Rules a consumer must obey:
- One call per (family, style-variant) pair.
Regular/Bold/Italic/BoldItalicare independently registered — there is no synthetic/faux bold or italic. A template combiningfont-familywith a weight/style whose variant you never registered fails loudly (LF-FONT-VARIANT), it never falls back to a different variant. - Caller identity is derived from
NavApp.GetCallerModuleInfo(), exactly likeRegisterPartial/RegisterImage— never a parameter, never spoofable. The font is stored as anExtension-scope baseline row keyed by your app identity, the family name, and the style variant. - Registration is idempotent: re-registering byte-identical content is a no-op; changed bytes update your baseline in place.
- A tenant upload of the same family/variant overrides your baseline (exactly the
partial/image precedent) — reverting that tenant override restores your registered
content. Call
RegisterSourcefirst if you want your font referenced asprefix/name; unqualified references resolve to your baseline whenever the family name is unique across all installed apps. - The font content is validated before it is stored — zero-byte or corrupt bytes,
content exceeding the documented per-asset size limit, or a CFF-outline (as opposed
to TrueType-outline/
glyf) OTF all fail theRegisterFontcall itself with an actionableLF-FONT-*error (see Template language reference's "Fonts & Typography" section for the complete asset-guard list) — never a bad asset silently accepted and failing later at render time. - Licensing responsibility is yours. The engine embeds whatever font bytes you
register; it does not vet, and cannot vet, third-party font licensing. You are solely
responsible for holding the rights necessary to embed and redistribute the font
you register (the same principle the tenant-upload maintenance UI enforces via an
explicit acknowledgment step for a human uploader —
RegisterFonthas no separate acknowledgment gate because your own app's install/upgrade code is the accountable party for content it ships).
This licensing-acknowledgment step is enforced only in the tenant-side maintenance UI
(the Pageworks Font Assets page), not in RegisterFont itself. A human uploading a
font there must go New → set Name → Acknowledge Licensing → Import, in that order —
Import prompts for the acknowledgment inline if it hasn't been given yet. Custom fonts
(both extension-registered and tenant-uploaded) are a fully shipped, supported feature,
not a preview capability.
Registering a barcode font (RegisterFont symbology overload)
A five-parameter overload of RegisterFont additionally couples the font asset to a
1D barcode symbology, marking it as a barcode font — see the
Barcodes guide for how a coupled font's runs are then auto-encoded
at render time.
Signature (Access = Public on PageworksRegistry):
procedure RegisterFont(Name: Code[50]; StyleVariant: Enum PageworksFontStyleVariant; var FontData: Codeunit "Temp Blob"; Description: Text[100]; Symbology: Enum PageworksBarcodeSymbology)
This is a non-breaking addition — the four-parameter overload above is unchanged and
still registers ordinary text fonts (Symbology defaults to None). Every other rule
above (one call per family/style-variant, idempotency, tenant-override precedence,
content validation, licensing responsibility) applies identically; the only difference
is the extra Symbology value stored alongside the font asset.
8. Extension point 7 — Invoking validation
Call the public PageworksValidator codeunit (codeunit 71179690) to check a
template before deployment — from your own tooling, a test, or a custom page action.
It never throws for template problems; every outcome comes back as a finding row.
procedure Validate(TemplateText: Text; ReportId: Integer; var Finding: Record PageworksFinding temporary)
var
Validator: Codeunit PageworksValidator;
Finding: Record PageworksFinding temporary;
TemplateText: Text;
begin
TemplateText := GetMyTemplateText();
// ReportId <> 0 also resolves {{...}} bindings against that report's real dataset
// (this executes the report's OnPreReport/trigger code — only pass reports that are
// safe to run outside their normal invocation context). Pass 0 to skip binding
// checks, e.g. when validating a partial in isolation.
Validator.Validate(TemplateText, Report::CustomerListPageworks, Finding);
if Finding.FindSet() then
repeat
case Finding.Severity of
Finding.Severity::Error:
Message('%1 (%2) at %3', Finding.Message, Finding.Code, Finding.Location);
Finding.Severity::Warning:
Message('Warning %1 (%2) at %3', Finding.Message, Finding.Code, Finding.Location);
end;
until Finding.Next() = 0;
end;
PageworksFinding (table 71179677, Access = Public, Extensible = false,
temporary) fields you read: EntryNo, Severity, Code, Message, Location. Its
Add() procedure is internal — it is the engine's own insertion point, not part of
your contract; you only ever read rows the engine populates.
PageworksFindingSeverity (enum 71179676, Access = Public, Extensible = false)
has exactly two values:
| Value | Ordinal | Meaning |
|---|---|---|
Error | 0 | Rendering this template will fail with the same code. Must be fixed before deployment. |
Warning | 1 | Renders, but with documented degradation (e.g. character substitution, a keep-together group breaking across pages). |
Both types are shipped closed (Extensible = false) by design — do not build logic that
assumes either can be extended.
9. Stability & versioning
PageworksRegistry, PageworksValidator, PageworksFinding,
PageworksFindingSeverity, and the reportlayout/pageworks MIME-type wiring
contract are the entirety of the engine's public surface and are covered by
API stability promise — read it for what counts as breaking vs. non-breaking and
the obsoletion process. Version compatibility (what a Major/Minor/hotfix bump may
contain) is covered by Versioning policy. Neither is restated here; both apply
to every signature and contract shown in this document.