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
paragraphStylecarries a different set of explicit-zero fields depending on which tool last touched it (Leping/Joonised's image-cell paragraph has explicit-zeroindentStart/spaceBelow/... that Hinnapakkumine's never had; Joonised's footer table cells lackpaddingTop/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
horizontalRuleelement in Hinnapakkumine (never rebuilt) but a thin bordered paragraph in Leping/Joonised (the rebuild script's stand-in, sincehorizontalRulecan'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
@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).
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).
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).
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:
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.
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).
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.
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.