typograph-editor-documentation
    Preparing search index...

    Typograph — The text model, for backend code

    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.


    To 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.


    • [ ] Handle both blocks and paragraph_runs
    • [ ] Match on the concatenated paragraph text, never run by run
    • [ ] Give the replacement the starting run's style and overrides
    • [ ] Never put \n in character_run.text — add paragraph_run entries instead
    • [ ] Leave rich_text_layout untouched
    • [ ] Open the result in the editor or viewer before any PDF is produced

    See 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.