Skip to content
Crixaa
← All posts

The shape your editor saves is not the shape your renderer reads

The Crixaa team4 min read

Every placeholder in the template had the right name, and every field in the panel had a value typed into it. The generated PDF came back with the totals blank and a line-items table that was nothing but a header row.

Two components, two shapes

The form panel has one job beyond collecting input: give you back exactly what you typed when you reopen it. That makes a flat map of strings the obvious storage.

{
  "buyer.name": "Rekha Iyer",
  "buyer.gstin": "29AABCU9603R1ZM",
  "totals.grand_total": "1,24,500.00",
  "line_items": "[{\"description\":\"Retainer\",\"amount\":\"90000.00\"}]"
}

Every key is literal text, dots included. Every value is a string, including the table, which is serialised JSON because a text control cannot hold an array. Nothing is coerced in either direction, so a reload is exact.

The renderer wants the opposite of all three properties:

{
  "buyer": { "name": "Rekha Iyer", "gstin": "29AABCU9603R1ZM" },
  "totals": { "grand_total": "1,24,500.00" },
  "line_items": [{ "description": "Retainer", "amount": "90000.00" }]
}

It resolves {{totals.grand_total}} by splitting on the dot and walking the object, and a data-bound table asks Array.isArray(rows) before it draws a single row. Both decisions are sound. Neither works on the stored shape.

The failure has no symptoms

Pass the stored shape to the renderer and data.totals is undefined, so every dotted placeholder resolves to empty. line_items is a string, Array.isArray says no, so the table draws its header and stops.

Nothing throws and nothing is logged, because there is nothing for the renderer to log. From where it sits it was handed data that does not contain those fields, which callers do on purpose all the time for optional content. It cannot tell "you sent the wrong shape" from "you left those fields empty".

That is the part worth sitting with. A name mismatch is loud in practice: you open the template, spot {{buyer.nmae}}, fix it, move on. A shape mismatch is silent. Every name matches, the panel is full, the file opens, and the document is blank in exactly the places the content was supposed to be.

The conversion is part of the contract

Somebody has to convert. The mistake is treating that conversion as a detail internal to whichever call site needed it first.

If one side must transform its data before calling the other, the transform is part of the interface between them, and it belongs at a choke point no caller can bypass — not a helper callers are expected to remember. The render entry point does it, unconditionally.

export function toRenderData(stored, fields) {
  const tableKeys = new Set(
    fields.filter((f) => f.type === 'table').map((f) => f.key),
  )
  const out = {}

  const byDepth = (a, b) => a.split('.').length - b.split('.').length

  for (const key of Object.keys(stored).sort(byDepth)) {
    const value = tableKeys.has(key) ? parseRows(key, stored[key]) : stored[key]
    setPath(out, key.split('.'), value)
  }
  return out
}

function parseRows(key, raw) {
  if (Array.isArray(raw)) return raw
  if (raw == null || raw === '') return []
  const rows = JSON.parse(raw) // throws, deliberately
  if (!Array.isArray(rows)) throw new TypeError(`${key} is not a table`)
  return rows
}

function setPath(target, path, value) {
  const last = path.pop()
  let node = target
  for (const part of path) {
    if (typeof node[part] !== 'object' || node[part] === null) node[part] = {}
    node = node[part]
  }
  node[last] = value
}

The awkward cases live in the conversion

Two are worth naming. First: whether a key holds a table is a fact about the template, not about the string. Sniffing for a leading [ works until somebody types an array literal into a plain text field and gets a table nobody asked for. That is why toRenderData takes the field list — the template decides.

Second: a flat map can legitimately hold both totals and totals.grand_total. Nesting has to pick a winner, and if you do not choose a rule the winner depends on whatever order Object.keys returned, which is a rule, just not one you wrote down. Sorting by depth makes the branch win, deterministically.

And parseRows throws rather than returning [], because empty and broken look identical downstream. The boundary is the last place they still differ.

Test the document, not the dictionary

The instinct is to unit test the converter: feed it a flat map, assert the result is nested and the rows are an array. That test passes forever, including on the day the renderer changes how it resolves paths, because it asserts on the intermediate structure — the one thing neither component actually promised.

Render instead, and read the output back.

const pdf = await render(template, toRenderData(stored, template.fields))
const text = await extractText(pdf)

expect(text).toContain('1,24,500.00') // the dotted path resolved
expect(text).toContain('Retainer')    // the table drew a row

Slower, and worth it: it is the only assertion that fails when the two sides drift apart.

The short version

Two components can agree completely on a field name and still disagree on its shape, and shape disagreements do not announce themselves. If your editor stores something for its own convenience — flat keys, stringified values, anything the form needs to round-trip — the reshaping is the contract, not glue code. It belongs at one choke point, and the test for it is a document you read back rather than an object you inspect.

Try it on your own document

Design a template in the browser and generate a real PDF — free, no card.