This document is for server-side code that edits the text of a Typograph document without running the Typograph library — for example a Rails backend filling placeholders in a template before the document is opened in the editor or the viewer.
It covers where the text lives in the JSON, the three rules that are easy to get wrong, and worked examples. If you are working in the browser with the library loaded, you do not need any of this — use the editor's own API instead: FindReplace.md.
Read The one rule before you ship anything. The failure it describes is silent: it produces a wrong PDF rather than an error.
Inside a document, each rich text element carries a story_data object:
{
"type": "rich",
"story_data": {
"name": "",
"paragraph_runs": [
{
"style": "default",
"overrides": null,
"tab_stops": [],
"character_runs": [
{ "style": "default", "text": "Dear NAME, welcome." }
]
}
]
},
"rich_text_layout": { "initial_baseline_y": 0, "lines": [ ... ] }
}
The shape is:
| level | what it is |
|---|---|
story_data |
all the text of one frame (or one linked chain — see Linked frames) |
paragraph_runs[] |
one entry per paragraph |
character_runs[] |
runs of text within a paragraph that share formatting |
character_run.text |
the actual string |
Formatting is a named style plus an overrides bag. style names a style defined in the
document; overrides is anything set directly on that run. overrides is omitted entirely on a run
that has none.
story_data holds its paragraphs under paragraph_runs normally, and under blocks when
the story contains a table — blocks is an ordered mix of paragraphs and tables. Always handle
both:
paragraphs = story['blocks'] || story['paragraph_runs'] || []
Entries in blocks that have no character_runs key are tables. Skip them unless you intend to
handle table cells, whose content nests further.
Formatting splits text into runs, and it splits mid-sentence. This is one paragraph reading "Dear NAME, welcome." with the name in bold:
"character_runs": [
{ "style": "default", "text": "Dear " },
{ "style": "default", "overrides": { "font_style": "bold" }, "text": "NAME" },
{ "style": "default", "text": ", welcome." }
]
Searching each run for "Dear NAME" finds nothing. Searching for "NAME" happens to work here, and
will stop working the moment someone italicises half the word.
Always match against the concatenated text of the whole paragraph, then map the offset back to runs. The worked example below does this.
Which formatting does the replacement get? The library's own search and replace uses the formatting of the run the match starts in, and you should do the same — it is predictable and it is the only choice that stays stable when a match spans runs of different formatting.
paragraph_run, not a \nTo add a paragraph, add a paragraph_run. There is no "newline character" in this model, and no
soft line break at all.
⚠️ A \n inside character_run.text will render, so this mistake looks like it works:
{ "style": "default", "text": "Line one\nLine two\nLine three" }
That draws as three lines — and it is one paragraph. Paragraph-level properties (style,
justification, tab_stops, drop caps, space before and after) apply per paragraph_run, so this
gets one set of them for the whole block where it should have three. It is also not the structure
the editor produces for the same text, so it will not survive editing consistently.
The correct form is three entries, each copying style, overrides and tab_stops from the
paragraph it came from:
"paragraph_runs": [
{ "style":"default", "overrides":null, "tab_stops":[],
"character_runs":[{ "style":"default", "text":"Line one" }] },
{ "style":"default", "overrides":null, "tab_stops":[],
"character_runs":[{ "style":"default", "text":"Line two" }] },
{ "style":"default", "overrides":null, "tab_stops":[],
"character_runs":[{ "style":"default", "text":"Line three" }] }
]
Beside story_data, each rich element carries rich_text_layout — the computed layout: where
each line breaks, its height, table geometry. Canvas writes it and never reads it back; it is an
output for downstream consumers, chiefly the PDF generator, which trusts it.
When you edit the text, that block still describes the text that used to be there.
That is harmless for the editor and the viewer. Both recompute the layout from story_data when
they open a document, and write a corrected rich_text_layout on the next save. A document you have
edited is self-healing:
rich_text_layout says |
actually renders | |
|---|---|---|
| original short text | 1 line | 1 line |
| you replace it with a much longer string | still 1 line | — |
| opened in the editor or viewer | still 1 line | 4 lines |
| saved again | 4 lines | 4 lines |
⚠️ It is not harmless for PDF generation. A document that goes from your backend straight to the PDF pipeline is drawn with the old line breaks: not an error, a wrong document.
So: JSON you have edited must be opened in the editor or the viewer before it is rendered to PDF. If that ever stops being true for your pipeline, talk to us — the answer is to re-run the layout through the library (it runs under Node), because a second layout engine drifting from the first is the thing this design exists to prevent.
Do not strip rich_text_layout. It gains nothing — it is ignored on load and rewritten on save —
and removing it risks failing schema validation.
Text frames can be linked so that text flows from one into the next. Only the first frame of a chain owns the text; the others are empty and take their content from it at layout time.
If you are matching on story_data you get this for free — a linked chain has one story, on the
first element. But if you locate an element by position or name and find its story_data empty,
that is why: look for the head of the chain.
Handles the cross-run case from Rule 1. Replaces every occurrence in every paragraph and gives the replacement the formatting of the run the match starts in.
Run against a real document exported from the editor, covering: a match inside one run; a match spanning two runs; a match starting inside the bold run (the replacement correctly inherits
{"font_style":"bold"}); several occurrences in one paragraph; a replacement that contains the needle ("cat"→"concatenate"— replaced once, no loop); and a needle that is absent.
# Returns the number of replacements made.
def replace_in_story(story, needle, replacement)
paragraphs = story['blocks'] || story['paragraph_runs'] || []
paragraphs.sum { |para| replace_in_paragraph(para, needle, replacement) }
end
def replace_in_paragraph(para, needle, replacement)
runs = para['character_runs']
return 0 if runs.nil? # a table block, not a paragraph
flat = runs.map { |r| r['text'].to_s }.join
return 0 unless flat.include?(needle)
spans = spans_for(runs)
# All match ranges, left to right
matches = []
i = 0
while (i = flat.index(needle, i))
matches << [i, i + needle.length]
i += needle.length
end
out = []
cursor = 0
matches.each do |m_start, m_end|
out.concat(slice_runs(spans, cursor, m_start)) # text before the match
start_run = spans.find { |s, e, _| m_start >= s && m_start < e }&.last || runs.first
new_run = { 'style' => start_run['style'], 'text' => replacement }
new_run['overrides'] = start_run['overrides'] if start_run['overrides']
out << new_run # the replacement itself
cursor = m_end
end
out.concat(slice_runs(spans, cursor, flat.length)) # the tail
para['character_runs'] = out.reject { |r| r['text'].to_s.empty? }
matches.length
end
# Where each run sits in the paragraph's flattened text: [start, end, run]
def spans_for(runs)
pos = 0
runs.map do |r|
len = r['text'].to_s.length
span = [pos, pos + len, r]
pos += len
span
end
end
# The runs covering [from, to) in flattened coordinates, each trimmed to that window.
def slice_runs(spans, from, to)
return [] if to <= from
spans.each_with_object([]) do |(s, e, run), acc|
lo = [s, from].max
hi = [e, to].min
next if hi <= lo
acc << run.merge('text' => run['text'].to_s[(lo - s)...(hi - s)])
end
end
Per Rule 2 this is a different operation — you are adding paragraphs, not changing a string.
The text around the placeholder has to go somewhere, and the answer is what pressing Enter in
the editor would do: everything before the placeholder stays with the first new paragraph, and
everything after it moves to the last one. So replacing NAME in
Dear NAME, welcome.
with ["Jane Doe", "Managing Director", "Acme Ltd"] gives three paragraphs:
Dear Jane Doe
Managing Director
Acme Ltd, welcome.
The surrounding text keeps its own formatting; the inserted lines take the formatting of the run the
placeholder started in — so if NAME was bold, all three inserted pieces are bold and Dear and
, welcome. are not.
# Returns the number of paragraphs that replaced the original, or 0 if the needle is absent.
def replace_with_paragraphs(story, needle, lines)
paragraphs = story['blocks'] || story['paragraph_runs']
index = paragraphs.index do |p|
p['character_runs'] && p['character_runs'].map { |r| r['text'].to_s }.join.include?(needle)
end
return 0 if index.nil?
para = paragraphs[index]
runs = para['character_runs']
flat = runs.map { |r| r['text'].to_s }.join
spans = spans_for(runs)
m_start = flat.index(needle)
m_end = m_start + needle.length
head = slice_runs(spans, 0, m_start) # text before the placeholder
tail = slice_runs(spans, m_end, flat.length) # text after it
# inserted lines take the formatting of the run the placeholder starts in
start_run = spans.find { |s, e, _| m_start >= s && m_start < e }&.last || runs.first
line_run = lambda do |text|
r = { 'style' => start_run['style'], 'text' => text }
r['overrides'] = start_run['overrides'] if start_run['overrides']
r
end
# every new paragraph inherits the original's paragraph-level properties
new_para = lambda do |character_runs|
{
'style' => para['style'],
'overrides' => para['overrides'],
'tab_stops' => para['tab_stops'],
'character_runs' => character_runs.reject { |r| r['text'].to_s.empty? }
}
end
made =
if lines.length == 1
[ new_para.(head + [line_run.(lines.first)] + tail) ] # no split needed
else
[ new_para.(head + [line_run.(lines.first)]) ] +
lines[1..-2].map { |l| new_para.([line_run.(l)]) } +
[ new_para.([line_run.(lines.last)] + tail) ]
end
paragraphs[index, 1] = made
made.length
end
Handles a placeholder that is the whole paragraph (head and tail are simply empty), one at the very
start or end, a single-element lines (collapses to an ordinary replacement), and a placeholder
that itself spans several character runs.
blocks and paragraph_runsstyle and overrides\n in character_run.text — add paragraph_run entries insteadrich_text_layout untouchedSee also: Embedding.md for running the editor or viewer, FindReplace.md for the same replacements done in the browser with the library loaded, DocumentEvents.md for reacting to changes in the browser.