PhoenixKitDocumentCreator.GoogleDocsClient.SegmentReplay (PhoenixKitDocumentCreator v0.9.13)

Copy Markdown View Source

Pure helpers behind "give an appended section its own header/footer" (GoogleDocsClient.append_template/3's header/footer step): a structural fingerprint to decide whether a section even needs its own segment, and the request builders that replay a template's header/footer content into a freshly created one.

The Docs API has no "copy this header to that document" primitive — a template's header/footer is walked and rebuilt from scratch, the same approach proven live 2026-09-21 by a one-off script that rebuilt the house header/footer (not shipped in this repo), generalized here into the library: paragraphs (insertText + paragraph style, then text style), tables (insertTable + column widths + cell style + cell fill), inline images (insertInlineImage, source size and URI verbatim — no rescale to the target's own column width; the 2026-09-23 live check found Docs accepts this).

Every function here is pure — no get_fn/batch_fn, no Docs API calls. GoogleDocsClient owns the multi-round-trip orchestration (create the segment, insert a skeleton, re-fetch, fill tables) the same way it already splits flatten_template_with_table_markers_and_styles/1 (pure) from finish_append_template/6 (orchestration) for body content.

Why the fingerprint drops so much

Three real "home" header/footer pairs (Hinnapakkumine, Leping, Joonised (tootmine)) were fetched live 2026-09-23 to calibrate this — they're meant to look identical, having all been rebuilt from the same model by the 2026-09-21 script, but their JSON differs in ways that carry no visual meaning:

  • table column width and inline image size scale with each template's own content width (Hinnapakkumine's logo table: 261pt columns / a 236x46pt logo; Leping/Joonised's: 225.6pt columns / a 215x42pt logo, same ~5.13 aspect ratio) — comparing these directly would flag every correctly-scaled home header as "different".
  • a cell's paragraphStyle carries a different set of explicit-zero fields depending on which tool last touched it (Leping/Joonised's image-cell paragraph has explicit-zero indentStart/spaceBelow/... that Hinnapakkumine's never had; Joonised's footer table cells lack paddingTop/etc. entirely where Hinnapakkumine's carry an explicit 5pt) — none of this changes how the header renders.
  • the footer's divider line is a native horizontalRule element in Hinnapakkumine (never rebuilt) but a thin bordered paragraph in Leping/Joonised (the rebuild script's stand-in, since horizontalRule can't be inserted via the API) — structurally unrelated shapes that must still compare equal.

So the fingerprint keeps: block shape (paragraph / table / rule), paragraph alignment, per-run bold/italic/rounded fontSize, raw text (including untouched {{placeholder}} syntax — substitution runs after every section is appended), table rows/columns and per-cell content, and an inline image's rounded aspect ratio. It drops table column widths, image absolute size, cell padding/border/alignment, paragraph line spacing/indentation, and any URI/id — exactly the fields the live comparison above showed vary without a visual difference.

Summary

Functions

createHeader/createFooter request for a fresh DEFAULT segment on the section whose section break sits at break_index — the section break element's own startIndex (NOT the insertSectionBreak request's location.index, one less — see append_template/3's doc on why the break inserts a newline ahead of itself).

updateParagraphStyle requests for fields GoogleDocsClient.paragraph_style_requests/2 deliberately doesn't capture (borders, shading) — a header/footer replay's own pass, kept separate from the shared, narrow body builder (which is covered by exact batch-content tests body appends rely on) rather than widening it. One request per span that has at least one such field; a span with none gets none — no anti-inheritance mask needed here, since every field is on a brand new, isolated segment with nothing adjacent to inherit from.

updateTextStyle requests for fields GoogleDocsClient.text_style_requests/2 deliberately doesn't capture (weightedFontFamily, underline, link, baselineOffset), same separate-pass reasoning as extra_paragraph_style_requests/2.

Structural fingerprint of a header/footer segment's content (or a table cell's content — same shape), for comparing "does this look like the same header/footer" across documents. Two segments fingerprint equal iff == on the returned term holds. See the moduledoc for what's kept/dropped and why.

Whether a header/footer segment's content can be rebuilt faithfully by the replay. The replay rebuilds text runs, rule paragraphs, and one-row- or-more tables whose cells hold text or a single inline image. Anything else would be dropped silently or rejected by the API

Pulls the new segment's id out of a createHeader/createFooter batch response's replies.

Phase (б) of the replay: insert the flattened text (table markers included, see GoogleDocsClient.flatten_template_with_table_markers_and_styles/1) at the start of a fresh (empty) segment, then its paragraph and text style. text already has its own final trailing newline stripped by the caller when the template ends in a plain paragraph (the Docs API refuses to delete a segment's terminal newline, so the segment's own pre-existing one serves as that paragraph's terminator instead — see GoogleDocsClient.skeleton_insert_text/2's doc); an empty text (a template that's a single, otherwise-empty paragraph) skips insertText entirely — the API rejects an empty one, same as body's/a cell's, and the paragraph/text style requests below still apply, targeting the segment's own pre-existing newline. No segmentId yet — wrap the result with with_segment_id/2.

Phase (в) of the replay: column widths, cell style (padding/alignment replayed, borders always cleared — a bare insertTable cell renders with a visible border every captured home table lacks), cell text/style, and inline images (verbatim source size and URI — see the moduledoc).

Wraps already-built, segment-agnostic requests (from GoogleDocsClient.paragraph_style_requests/2, GoogleDocsClient.text_style_requests/2, GoogleDocsClient.paragraph_then_text_style_requests/3, GoogleDocsClient.table_column_width_requests/2, GoogleDocsClient.table_skeleton_requests/2, and this module's own builders) for a specific header/footer segment: every location, range, and tableStartLocation map found anywhere in the request tree gets segmentId merged in (existing keys win — nothing is overwritten). This is deliberately a transform over the result of those builders rather than a segment-aware copy of each one: the coordinate keys a Docs API request can carry are a small, closed set, so one generic walk covers every request shape above (and any new one built the same way) instead of duplicating each builder with a segment_id argument threaded through every range/location it emits.

Functions

create_segment_request(atom, break_index)

@spec create_segment_request(:header | :footer, non_neg_integer()) :: map()

createHeader/createFooter request for a fresh DEFAULT segment on the section whose section break sits at break_index — the section break element's own startIndex (NOT the insertSectionBreak request's location.index, one less — see append_template/3's doc on why the break inserts a newline ahead of itself).

extra_paragraph_style_requests(base_index, spans_with_extras)

@spec extra_paragraph_style_requests(integer(), [{map(), map()}]) :: [map()]

updateParagraphStyle requests for fields GoogleDocsClient.paragraph_style_requests/2 deliberately doesn't capture (borders, shading) — a header/footer replay's own pass, kept separate from the shared, narrow body builder (which is covered by exact batch-content tests body appends rely on) rather than widening it. One request per span that has at least one such field; a span with none gets none — no anti-inheritance mask needed here, since every field is on a brand new, isolated segment with nothing adjacent to inherit from.

spans_with_extras — [{span, extras}], span shaped like GoogleDocsClient.paragraph_style_requests/2's (start_offset, length), extras a map with border_top/border_bottom/ border_left/border_right (each the source's own raw Docs Border object, or nil), shading (raw Shading object or nil), and space_below (a bare point value or nil — the horizontalRule stand-in's forced 6pt gap, see paragraph_extras/1's caller).

extra_text_style_requests(base_index, runs_with_extras)

@spec extra_text_style_requests(integer(), [{map(), map()}]) :: [map()]

updateTextStyle requests for fields GoogleDocsClient.text_style_requests/2 deliberately doesn't capture (weightedFontFamily, underline, link, baselineOffset), same separate-pass reasoning as extra_paragraph_style_requests/2.

runs_with_extras — [{run, extras}], run shaped like GoogleDocsClient.text_style_requests/2's (start_offset, length), extras a map with weighted_font_family/underline/link/ baseline_offset (each the source's own raw value, or nil) and font_size (a bare point value, or nil — the horizontalRule stand-in's forced 4pt, see text_extras/1's caller; NOT the same as the shared builder's own captured font_size, which this leaves alone unless a rule forces an override).

fingerprint(content, inline_objects)

@spec fingerprint([map()], map()) :: term()

Structural fingerprint of a header/footer segment's content (or a table cell's content — same shape), for comparing "does this look like the same header/footer" across documents. Two segments fingerprint equal iff == on the returned term holds. See the moduledoc for what's kept/dropped and why.

replayable?(content, inline_objects)

@spec replayable?([map()], map()) :: boolean()

Whether a header/footer segment's content can be rebuilt faithfully by the replay. The replay rebuilds text runs, rule paragraphs, and one-row- or-more tables whose cells hold text or a single inline image. Anything else would be dropped silently or rejected by the API:

  • autoText (page numbers / page counts) — the Docs API cannot insert it, so a replayed "Page 1 of 3" footer would come out "Page of ".
  • an inline image outside a table cell, a cell with more than one image or an image next to text, or an image with no contentUri (drawings, charts) — only an image-only cell's single picture is re-inserted.
  • positioned (floating) objects, nested tables, and any other element kind (equations, footnote references, …).

false keeps the section on the header/footer it inherits instead of replacing it with a lossy copy.

segment_id_from_replies(atom, replies)

@spec segment_id_from_replies(:header | :footer, [map()]) :: String.t() | nil

Pulls the new segment's id out of a createHeader/createFooter batch response's replies.

skeleton_requests(text, runs, paragraphs)

@spec skeleton_requests(String.t(), [map()], [map()]) :: [map()]

Phase (б) of the replay: insert the flattened text (table markers included, see GoogleDocsClient.flatten_template_with_table_markers_and_styles/1) at the start of a fresh (empty) segment, then its paragraph and text style. text already has its own final trailing newline stripped by the caller when the template ends in a plain paragraph (the Docs API refuses to delete a segment's terminal newline, so the segment's own pre-existing one serves as that paragraph's terminator instead — see GoogleDocsClient.skeleton_insert_text/2's doc); an empty text (a template that's a single, otherwise-empty paragraph) skips insertText entirely — the API rejects an empty one, same as body's/a cell's, and the paragraph/text style requests below still apply, targeting the segment's own pre-existing newline. No segmentId yet — wrap the result with with_segment_id/2.

table_fill_requests(entries, inline_objects)

@spec table_fill_requests([map()], map()) :: [map()]

Phase (в) of the replay: column widths, cell style (padding/alignment replayed, borders always cleared — a bare insertTable cell renders with a visible border every captured home table lacks), cell text/style, and inline images (verbatim source size and URI — see the moduledoc).

entries — one map per table, built by the caller from a re-fetched document (GoogleDocsClient.extract_table_cells/1's cells matched against the captured template table): %{table_start, columns, cells, column_properties, cell_styles, cell_texts, cell_runs, cell_paragraphs, cell_image_ids, cell_paragraph_extras, cell_run_extras}. All the cell_* lists are row-major and index-aligned with each other (same padding-to-column-count — see flatten_template_with_table_markers_and_styles/1's normalize_row/3). cell_image_ids entries are nil or an inlineObjects key. cell_paragraph_extras/cell_run_extras are themselves lists (one per cell, parallel to cell_paragraphs/cell_runs) of {span, extras} / {run, extras} pairs in extra_paragraph_style_requests/2's and extra_text_style_requests/2's own shape — a cell's border/font-family/ underline/link get the same separate-pass treatment as body content. columns is the table's column count; each row-major cell_styles entry is addressed at {div(i, columns), rem(i, columns)}, so a multi-row table's second row lands on row 1 rather than on an out-of-range column of row 0.

No segmentId yet — wrap the result with with_segment_id/2.

with_segment_id(requests, segment_id)

@spec with_segment_id([map()], String.t()) :: [map()]

Wraps already-built, segment-agnostic requests (from GoogleDocsClient.paragraph_style_requests/2, GoogleDocsClient.text_style_requests/2, GoogleDocsClient.paragraph_then_text_style_requests/3, GoogleDocsClient.table_column_width_requests/2, GoogleDocsClient.table_skeleton_requests/2, and this module's own builders) for a specific header/footer segment: every location, range, and tableStartLocation map found anywhere in the request tree gets segmentId merged in (existing keys win — nothing is overwritten). This is deliberately a transform over the result of those builders rather than a segment-aware copy of each one: the coordinate keys a Docs API request can carry are a small, closed set, so one generic walk covers every request shape above (and any new one built the same way) instead of duplicating each builder with a segment_id argument threaded through every range/location it emits.