Author Shojiku templates (definitions.yml / params.json / templates.yml) from natural-language requirements, driving the engine's validate → preview loop until the rendered pages are correct. Use when creating or reworking a template for the Shojiku PDF engine.
Install
npx skillscat add kengos/shojiku/shojiku-template-author Install via the SkillsCat registry.
Shojiku Template Author
Audience: AI agents only. This page is written as instructions
to an AI agent authoring templates. If you are a human (PM,
designer, engineer), you don't follow these steps yourself — you give
your document requirements to an AI agent (e.g. Claude Code) and it
runs this playbook. The human-readable syntax reference is
docs/engine/.
You turn document requirements into the three-file set the Shojiku
engine consumes, and you iterate against the engine's own output —
the engine is the only source of rendering truth. Never reason from a
mental PDF model, never use another PDF library, never read the
engine's source code: the template reference (docs/engine/README.md,
one page per feature) plus the engine's diagnostics and previews are
your whole contract.
The three files
| File | Role | Owner |
|---|---|---|
definitions.yml |
data dictionary (validation + GUI/AI) | engineer↔author seam |
params.json |
runtime data — data only, never geometry | calling app |
templates.yml |
ALL layout/geometry/styling | you |
definitions.yml schema (cheat sheet — the normative page is definitions.md)
version: "0.2.0" # optional
# NOTE: document `locale`/`currency` are NOT definitions.yml keys — they
# live in the template's `defaults:` block (docs/engine/defaults.md).
type: object # REQUIRED root — an OpenAPI-style schema
required: [profile] # optional required top-level keys
properties: # nesting mirrors the params JSON exactly
profile:
type: object # a top-level object = a palette field group
title: 基本情報 # optional (was `label` in the retired v1 form)
properties:
name: # binding key = the dotted path (profile.name)
type: string # string|number|integer|boolean|object|array
title: 氏名 # optional; also: example (sample value),
# enum, minLength/maxLength, minimum/maximum,
# displayFormat (default display variant),
# displayFormats: [{id, label}], currency,
# precision, unit: item (a SEMANTIC key —
# display words live in the locale pack),
# placeholder, recommendedStyle
joined_on:
type: string
format: date # semantic types are type+format pairs:
# date-time/date/image on string;
# currency/percentage/quantity on number/integer.
# Unknown format values are generation hints.
items: # type: array = a table/repeat/list data source;
type: array # the template binds data: { key: items }
minItems: 1 # optional row-count constraints
items:
type: object
properties:
quantity: # row keys are RELATIVE to one element
type: integer
format: quantityTrap: unknown keys in definitions.yml are parse errors (like
templates), located to the field path and YAML line — including the
retired v1 groups: list form, which errors with a migration hint. If
a definitions file ends up with no properties, every template binding
fails with unknown_data_key; validation flags the upstream cause once
as empty_definitions. If EVERY key errors at once, suspect the
definitions file's structure, not the template.
Trap: supplying BOTH definitions and params turns on params-vs-schema
validation (params_* warnings: required/type/range/enum/unknown keys).
The Shojiku repo's bundled-example gate (make examples:render — repo
contributors only; irrelevant when authoring outside the repo) is
WARNING-clean — so a bundled example's schema
must cover every params key it ships and declare list-like row values
truthfully (type: array, not a scalar lie). Blank variants are safe:null/"" values skip the schema checks (the placeholder domain).
Trap: in a definitions-backed example, table / repeat /repeat_flow data keys ARE validated against definitions.yml (an
undeclared key + its sub-fields → unknown_data_key), but a list
data key is NOT — so adding a table to a showcase needs its array
property declared, while a list over the same data would render without
one. Declare the property when you add the table, or make examples:render
reddens on the first render.
Wire gotchas the reference doesn't make obvious
A code panel indents with U+00A0, not spaces. The text layer drops
ordinary leading spaces, so a YAML sample pasted into a showcase panel
renders flush left with its structure gone. Every existing panel inexamples/dev/layout-showcasealready uses non-breaking spaces for the
indent — the trap is well known, but the RECIPE is only visible by reading
the bytes of one (repr()a line, or any editor that shows them), never by
looking at the file. Copy the indentation from a neighbouring panel rather
than typing it.page.size: named presetsA3/A4/A5,B4/B5(JIS),Letter/Legal/Tabloid, or custom{ w: "420mm", h: "297mm" }.
Physical units (mm/cm/in/pt) are strings; bare numbers are pt.page.margin: a bare number is pt and units-as-one-value
(margin: 15mm) is rejected — for units use the per-side map{ top: "15mm", right: "15mm", bottom: "15mm", left: "15mm" }.Flex row widths: children without
box.wsize to their CONTENT
and stay there —flexGrowdefaults to 0, the way CSS does, so
nothing takes the leftover unless you ask. For "N equal columns" writeflexBasis: 0ANDflexGrow: 1on each child — that pair is CSS'sflex: 1;flexBasis: 0alone collapses the child to nothing, andflexGrow: 1alone gives each child its content width plus an equal
share of the slack, which is not an equal split. For "this one takes
whatever is left" (a value beside a fixed label),flexGrow: 1on its
own is exactly right. A row whose content is too wide shrinks its children rather
than overflowing; a row that still cannot fit warnsflex_row_overflow; so does a column or
hand-positioned child past its container's content box, and a band /
absolute-body item past the edge of the SHEET. Reaching into the page
margins stays silent by design (the full-bleed escape hatch), so the
preview is still the only check for "did I mean to bleed into the
margin".An underline under a flex child:
line'sfrom/totake full
lengths, so nest the line INSIDE the field and writeto: { x: "100%" }— the field's width is a row share nobody can type
as a pt value. A line is never itself a flex item (no box to size); it
always resolves against its parent's content box, sopaddinginsets
it.Showcase code panels indent with NON-BREAKING spaces (U+00A0), not
ordinary spaces. Atext: |-block scalar strips the common leading
indent, and the text layer then drops the remaining ordinary leading
spaces, so a code sample written with real spaces renders flush
left — the render emits no diagnostic andmake examples:renderstays
green, so for a long time only the preview showed it and eight of the
showcase's forty-four panels shipped that way. Tabs collapse
identically — the wrap tokenizer folds a tab into the same space run
— so an editor's auto-indent inside a block scalar is the same bug.make examples:checknow refuses both forms
(scripts/check-example-text-indent.sh, which reports the offendingpath:line), but the gate only fires underexamples/andskills/
— nothing catches it in a template you author elsewhere, so the
preview is still the check there.char_gridis the exception to
the whole rule: it takes the sametext:key but never reaches the
wrapper (it fills cells verbatim), so a leading ASCII space there is a
real occupied cell — which is exactly how you write a 原稿用紙
1字下げ. Keep ordinary spaces in achar_gridblock and waive the
gate with atext-indent-exempt: <reason>comment on the opener line.
Indent every level of a code
panel with NBSP (2 per level); grep the showcase for\xa0to copy
the idiom. Acell:/nested block in a panel needs the same treatment
as any other level.Mixed styles inside one line (label + value) use
spans:on a text
item (per-spanstyle/styleNames,textordata) — see
text.md.Parse errors from the engine can lack a location (e.g.
missing field `id`): match the named field against the schemas
above / the reference page of whatever you just edited.Multi-line text:
\nsplits paragraphs (each wraps independently) —
author it with YAML block scalars (|/|-). Wrapping drops leading
ASCII spaces, so hard indentation (code samples) needs no-break
spaces (U+00A0); a literal{key}-shaped string needs the{{
escape or validation flags it as an unknown binding.Tables: no explicit body cell spanning (colspan) — but a section-heading
row ("学歴") is expressible from the DATA: tag the row
(kind: heading) and style it withrow.conditionalStyles
(when: { key: kind, equals: heading }— the form-mark predicate, read
relative to the row), optionally withmergeEmptyCells: trueso the
row reads as one full-width banner — which needs the heading in the
FIRST column and the rest of the row empty: a value extends right over
the empty cells after it, like a spreadsheet merge, and an empty cell
with no value to its left stays its own cell. The banner IS the first
column's cell, so that column's ownstyle(e.g. atextAlignfor its
values) wins over the row style across the whole banner. A closing
"以上" row takes the same recipe when it should span the row; left in
the last column with the first empty, it stays an ordinary cell. Table
grid stroke is one width — no thick-outer/thin-inner.tabletakes abox— in the flow body it still paginates
(boxonly narrows/centers it horizontally); in a container / absolute
body / band it renders as one bounded block (no pagination, andrepeatHeader/autoPageBreak/keepTogetherwarntable_pagination_key_ignored). Two tables CAN now sit side by side —
each adirection: rowcontainer child — the A3 two-page-spread
pattern. A
table inside a cell —repeat/repeat_flowor acell:column — is
still unsupported (table_in_cell).A table column takes
cell:instead ofdata:— a per-row
sub-template of freely placed items, with the CELL's top-left as the
origin and bindings scoped to the row.dataandcellare exclusive
and one is required (column_content_conflict/column_content_missing);cellPaddingdoes NOT inset a container
cell — usecell.box.padding. An auto row is as tall as its tallest
cell; a fixedrow.heightclips per the cell'soverflow.cell:(and arepeat/repeat_flowcell) is a container by
POSITION, so it takes NOtype: containerkey — it already knows
what it is; only a container in anitems:list needs the tag. Adding
one is a parse error whose message points at the WRONG NODE: the body
is an untagged enum, so serde reports the last variant's failure —unknown field `type` … at `sections.body`— while the real
offender is the cell twenty lines down. Any parse error namingsections.bodyand listing body keys (id/box/styleNames/style/items) means "something inside the body failed"; bisect the
items rather than reading the reported path.
The per-element sub-template is spelled differently per item:repeatand a table column takecell:,repeat_flowtakesitem:. Writingcell:on arepeat_flowis a parse error whose
message DOES list the valid keys (id,data,gap,item) — read
the key list in the error rather than assuming the shape is shared.A bundled example must round-trip through the Designer's document
model byte-for-byte (gui/designer-core'sserialize(parse(src)) === srctest over everyexamples/**/templates.yml). The form that bites
a hand-authored block is the FLOW SEQUENCE: the canonical spelling has
inner spaces —[ a, b ], not[a, b]— which is also what the
Designer writes.make examples:renderstays green either way; only the gui
gate catches it, so match the spacing of the neighbouringstyleNames: [ meta ]lines when adding a list to an example. A
TRAILING BLANK LINE fails the same gate the same way — the serializer
emits exactly one final newline, so a section appended with one\n
too many redsroundtrip.test.tswith a diff whose entire content is a
bare-.make examples:renderpasses it happily; end the file with exactly
one newline.Custom
page.size+orientationdouble-swap without warning: a
landscape custom size (w>h) plusorientation: landscapeflips
the page back to portrait. With a custom size, omitorientation.Every gap — the flow body's
gap:included — takes a fullLength
(bare pt number,"5%","1em");fontSize/letterSpacingtake
length strings too (em/rem/%on fontSize; letterSpacing rejects%).Document format defaults: set once in the template —
defaults: { formats: { currency: symbol, date: wareki } }— and
every placement just binds the key. Currency variants:default=
bare9,000(composes with literal 円/¥ text),symbol=¥9,000,name=9,000円.symbol/namealso work as a placement pick on
a PLAIN number field ({subtotal:symbol}→¥9,800) — the engine
promotes the value to currency, so money display needs no
definitions type. Aformats:registry defines reusable named
date/datetime patterns (stamp: { type: date, pattern: "yyyy.MM.dd" });
per-placement pattern strings do not exist — reference by name.
Do not guess a variant spelling — ask. The names differ per locale
pack and a document's ownformats:entries add to them, soformat_catalog(MCP) /shojiku formats(CLI) is the authority: it
answers every pickable spelling per field type, where it comes from
(builtin/pack/registry), and what each one actually renders.
Aprobesentry previews a pattern before you author it. Types with no
named variants at all (number,percentage,quantity) come back
markedfixed: naming a VARIANT on one warns and renders the plain form.
A type name is not a variant —format: currencyon a number is a type
OVERRIDE, and so is thesymbol/namemoney pick above; both change what
the value is rendered as and neither warns.format: quantityprints the locale's COUNTER WORD, not a bare
number — under ja-JP a6renders6点. So a literal 点/個/行 in the
surrounding text doubles it ({count} 点→61点 点), and a count that
pairs with its own unit column (台 / 冊 / 枚 counters) renders the WRONG counter
on every row. Useformat: quantityfor a standalone total; leave a
count that has a unit beside it as a plaintype: integer. Nothing
warns — only the render shows it.format: date-timewants full RFC 3339, offset included:2026-07-15T18:20:00is rejected asformat_error;2026-07-15T18:20:00+09:00passes. (format: datetakes the bareYYYY-MM-DD.) The engine has no clock, so a print timestamp is a
normal typed parameter the caller supplies — renders stay
deterministic.Size fixed-height text boxes from the font, not by eye: the box
must exceedfontSize × lineHeight(default lineHeight 1.4 — e.g.fontSize: 15needsh > 21), ortext_overflow/container_overflowfires and descenders clip. Same for fixed table
heights:header.height/row.heightneed> fontSize × lineHeight + 2 × cellPadding(e.g.height: 18withcellPadding: 4leaves only 10pt for a 12.6pt line).
Recompute when you COPY a sibling document's box — the source
height encodes ITS line count and lineHeight, so reusing it while
changing either overflows: a 4-line box atlineHeight: 1.7needs
64.6pt where the 2-line 1.6 original lived in 60.textAlign/verticalAlignare STYLE keys — writing them insidebox:is a parse error (unknown key), and one wrong key kills the
whole render.rect/ellipse/checkbox/ textmarkspeak the unifiedStyle(+styleNames): fill isbackgroundColor(fillColoris a
parse error), and a barerectdraws nothing — authorborderWidth: 1for an outline. Form marks keep a 1pt outline
default. Onlylinekeeps its own shape style (width/color;styleNames:on a line is a parse error). Aline'sfrom/to
take a full Length on both axes — and both axes are required
(to: { x: "100%", y: 0 }reaches the right edge of its box) — so a
rule can follow an mm-based layout directly.char_gridworks inside containers andrepeat/repeat_flowcells
(one sheet there — no pagination; element-scoped bindings in cells).
Entry-box rows (〒 label + digit boxes + hyphen) are a flex row — see
the snippet in char_grid.md; blank boxes aretext: ""(omitting
bothtextanddatawarns instead). Real postal rows keep the
hyphen OUT of the boxes: a 3-cell grid + a plain−text + a 4-cell
grid (an 8-cell grid with the hyphen inside reads wrong on paper).A vertical
char_gridtakeswritingModeas an ITEM key, besidegrid:/markup:— NOT instyle:. The style property parses fine
(it is a normal inherited text property) but the grid stays
horizontal with no diagnostic; only the preview shows it.fontFamilycan only name faces from packs the active localeuses:— en-US loadsnoto-sans/noto-sans-monoonly, so kanji
under--lang en-USdegrades tomissing_glyph. For a
Latin-primary document that needs Japanese accents, keep the locale
that carries the JP packs (ja-JP) and setdefaults.currencyfor
the money instead (every builtin pack embeds the same currency set,
socurrency: USDrenders$6under ja-JP).Engine SVG subset has no rounded corners: a
rx/ryon<rect>
draws square and warnssvg_unsupported— which reddensmake examples:render. Author example assets withoutrx(and without<text>, which the subset also lacks).Since the same engine version, a fixed-width flex row that exceeds its
parent warnsflex_row_overflowand a definite-width flow item past
the region edge warnsflow_item_overflow. Band / absolute-body items
are covered too, bysheet_overflow— see the overflow bullet above
for what stays deliberately silent (a bleed into the page margin).Blank-form fields: a
data:binding (or itsdefinitions.yml
field) takes aplaceholder— verbatim text drawn when the value is
absent /null/"", so an intentionally-blank fillable form
renders clean instead of emittingmissing_data/format_error. The
placement'splaceholderwins over the field's; only the FIELD-level
one reaches{key}interpolation segments (put the placeholder indefinitions.ymlfor an inline{birth_date:wareki}生(満{age}歳)).
It is never interpolated or formatted (a{…}inside stays literal),
and a PRESENT-but-invalid value still warns (placeholderis for
blank, not broken). This is how one form serves a blank ↔
filled-sample pair
(examples/forms/rirekisho-ja). String fields already blank cleanly ("");
only typed (date/number/currency) fields need it.
Optical adjustment (you place coordinates without seeing them)
You choose positions and sizes from YAML numbers, with no visual sense
of how they read. Human designers don't rely on perception either —
they apply coded correction rules for optical illusions. Apply these
so your first render is closer to right, and lean on the engine's
built-in corrections instead of re-deriving them:
- Overshoot: a circle/oval/triangle flush with a text cap band reads
smaller than the text. It must extend 2–3% past the band to look
equal. The text-anchoredmark:bakes this into its default clearance
— prefer it over a hand-placedellipse, which you'd have to
pixel-tune (see form_marks.md). - Optical center: content centered in a box reads low. Nudge it
2–5% of the box height above geometric center when you place
something meant to look centered (the engine'sverticalAlign: middle
is geometric — compensate if it reads low in the preview). - Irradiation / expansion colors: light-on-dark reads bolder and
larger than the same shape dark-on-light. For a white-on-dark label,
consider a slightly smaller size or lighter weight to match a
dark-on-light sibling. - Helmholtz–Kohlrausch: high-chroma colors read brighter than a
gray of equal luminance. A luminance-only contrast check can pass while
a saturated accent still reads louder than the heading — mute it. - Apparent area: a circle needs a larger diameter than a square's
side to read as the same size; a checkbox (square) and an ellipse
mark (round)
beside equal text are not the same authored size.
These are starting corrections, not a substitute for the preview loop —
still render and look. When a value looks deliberately "off" by a couple
percent, it may be an intentional optical correction; don't "fix" it back
to geometric purity without checking.
Engine access — MCP first, CLI fallback (canonical command table)
The engine is the only source of rendering truth; you reach it two ways.
This is the one canonical home for the command surface — the
migrator/debugger skills reference this section, they never restate it.
Prefer the MCP surface when a
shojikuMCP server is registered
(the AI-agent transport per the product story): call its tools
directly, no shell. The tools and their arguments (paths are strings,
relative to the server's working dir):Tool Required args Optional args Returns validatetemplatePathdefinitionsPath,paramsPathdiagnostics JSON {items:[…]}render_previewtemplatePath,paramsPathdefinitionsPath,lang,scale(default 2.0),page(1-based), the asset knobsone PNG per page, then diagnostics JSON inspect_layouttemplatePath,paramsPathdefinitionsPath,lang, the asset knobsinspect envelope (engine info + layout tree + path-addressed boxes + margins), then diagnostics JSON capabilities— — engine version + capability-key list format_catalog— templatePath(or inlinetemplate),lang,probes([{fieldType, pattern}], ≤16)the pickable spellings per field type with a rendered sample of each, then diagnostics JSON list_examples— — the bundled catalog: each entry's shojiku://example/...URI, title, what it exercises, file names, sizeget_exampleuri— that entry's source files together (append /<file>to the URI for one file)list_reference— — the syntax reference index: each page's shojiku://reference/...URI, title, group, what it covers, the catalog shapes it documentsget_referenceuri— that page's markdown (syntax, defaults, limitations) + its keys as a JSON Schema fragment; #<key>returns that key's node on every shape of the page carrying it,#<Shape>/#<Shape>.<key>narrowStart from a bundled example rather than a blank file. The 34
entries cover invoices, receipts, forms, labels, vertical typography
and 7 blank per-locale page setups;dev/layout-showcaseexercises
most of the syntax in one document. An entry answers itstemplates.yml,definitions.ymlandparams.jsontogether, because
a template's bindings are meaningless without the definitions. The
showcase is over the 64 KiB bundle cap, so read it one file at a time
(shojiku://example/dev/layout-showcase/templates.yml) — the refusal
message names the URIs. The same content is aresources/readaway for
clients that fetch resources.When the question is "which construct", read the reference page. An
example shows one way to do one thing;list_referenceis the index of
every feature, andget_referenceon a page answers both halves — the
prose with its syntax examples and its## Limitations, and the same
page's keys as a JSON Schema fragment.shojiku://reference/table#style
answers every shape on that page carrying astylekey, each naming
its owner, so an ambiguous key costs no round trip.Every
<name>Pathargument has an inline twin —definitions/template/paramscarrying the source TEXT — for a client that
shares no filesystem with the server. Pass one spelling per source
(both is an invalid-params error) and keep an inline payload under
512 KiB; prefer the path form when you have files, since an inline
template resolves no bundled imagesrc:unless the call also passesassetsDir. The asset knobs (render_preview/inspect_layout)
mirror the CLI's asset flags:assetsDir(bundled-asset root),assetMode(open|bundled-only),allowDynamicImage/denyDynamicImage(item-id arrays, ≤256 entries).Fall back to the CLI when no MCP server is available (or you are
driving Docker). The same four template operations plusrender— the
two example-reading tools have no CLI twin, but the image carries the
files themselves at/opt/shojiku/examples, so read them off disk
instead; every template
command takes--templates(note the plural); the rendering commands
(preview/inspect/render) also require--params, whilevalidateaccepts it optionally (mirroring the MCP table) —--definitionsis optional but recommended everywhere:# diagnostics as JSON — fix until clean shojiku validate --definitions definitions.yml --templates templates.yml --params params.json # one PNG per page (the {page} token is required in --output) shojiku preview --templates templates.yml --params params.json --output "preview-{page}.png" --scale 2 # resolved geometry: layout tree + path-addressed boxes for every item shojiku inspect --templates templates.yml --params params.json # which display variants each field type can take, and what each RENDERS shojiku formats --templates templates.yml --lang ja-JP --probe date:'yyyy年M月d日' # final PDF shojiku render --templates templates.yml --params params.json --output out.pdfLocale (
--lang <id>/ MCPlang) applies to the rendering
commands —preview,inspect,render(and MCPrender_preview/inspect_layout) — and toformats/format_catalog, whose whole
answer is locale-dependent;validatetakes NO locale (validation does
not format). Builtin idsja-JP/en-US, a barejapicks its unique
builtin, else it falls todefaults.localethenja-JP. Under Docker
the CLI finds./packs/from the working dir; otherwise point it with--font-dir/--locale-diror$SHOJIKU_FONT_DIR/$SHOJIKU_LOCALE_DIR.
Both transports bundle diagnostics into every response — there is no
separate "get errors" call. A render_preview/preview that emits
warnings still produces pages; read the diagnostics part every time.
Installed standalone (e.g. via npx skills add, outside the Shojiku
repo): every relative docs/… and examples/… reference in these three
skills — the template reference, docs/engine/diagnostics.md,docs/architecture.md, the bundled examples — resolves inside the
Shojiku repository, not in this install. When this skill lives in
another project, read them from a Shojiku checkout. If you have neither
a checkout nor the shojiku/shojiku-mcp binaries, obtain the Shojiku
repository first — its docs/quickstart.md covers building the CLI and
registering the MCP server; you cannot run the validate → preview loop
(and must not claim a template verified) without one of those binaries.
The authoring loop
- Read the reference first:
docs/engine/README.mdand every
linked page relevant to the features you plan to use (standalone
install: resolve those paths per the note ending § Engine access). - Write the three files.
- Validate (
validatetool /shojiku validate, see the command
table above) — machine-readable diagnostics (JSON). Fix until clean.
Diagnostics with the same code flooding every key share ONE root
cause — fix the cause, not the instances. - Preview (
render_previewtool /shojiku preview) and
actually view every page image yourself. Validation cannot see
horizontal overflow, clipped labels, or bad visual hierarchy — only
the pixels can. Never declare a template done without having looked
at every rendered page. - Iterate 2–4 until the pages are visually correct.
Authoring log (required artifact)
Keep AUTHORING.md next to the files; per iteration record: what you
changed and why, the engine's response (diagnostics summary / what the
preview showed). Finish it with two sections:
- Design notes — decisions made on guesses, reference pages that were
missing or ambiguous. - Gap report — things you wanted to express but the engine
cannot; unhelpful diagnostics. This feeds the engine roadmap.
Self-consistency check (before declaring done)
Re-read your own notes and verify the wire matches every claim in them
(e.g. if you wrote "unsized children split equally", confirm those
children really have no w:). A note that says one thing while the
YAML does another is a known authoring failure mode.