PhoenixKitDocumentCreator. GoogleDocsClient
(PhoenixKitDocumentCreator v0.9.13)
Copy Markdown
View Source
Google Docs and Drive API client for the Document Creator module.
This module provides direct Google Drive and Docs API access without touching the local database. Use it when you need raw Drive operations: creating files, listing folders, moving files, exporting PDFs, reading document content, and substituting template variables.
For combined Drive + DB operations, use PhoenixKitDocumentCreator.Documents.
Capabilities
- Folders:
find_folder_by_name/2,create_folder/2,find_or_create_folder/2,ensure_folder_path/2,discover_folders/0,list_subfolders/1 - Files:
list_folder_files/1,move_file/2,copy_file/3,create_document/2 - Docs:
get_document/1,get_document_text/1,batch_update/2,replace_all_text/2 - Export:
export_pdf/1,fetch_thumbnail/1 - Status:
file_status/1,file_location/1 - URLs:
get_edit_url/1,get_folder_url/1
OAuth credentials and tokens are managed by PhoenixKit.Integrations
under the "google" provider. The module references the active
connection by uuid via the "google_connection" field in the
"document_creator_settings" row — active_integration_uuid/0 is
the resolver. Pre-uuid values ("google" / "google:name" strings)
are auto-migrated to the matching integration row's uuid on first
read; the rewritten setting then drives all subsequent dispatches.
Folder configuration is stored separately under the
"document_creator_folders" settings key.
Configuration
:page_fit_safety_pt(float, default8.0) — small residual safety margin subtracted from afit: "page"image slot's available height on every page it occupies, on top of the header/footer-derived body box. Seepage_fit_safety_pt/0.
Through 2026-09-22 this was a single flat constant (config :phoenix_kit_document_creator, :page_fit_safety_pt, 8.0130pt) standing in for the whole header/footer overhang, calibrated live against one template. As of 2026-09-23 the top/bottom of the body is instead computed from each section's own header/footer content (header_extent_pt/2,footer_extent_pt/2, folded intosection_boxes/1asbody_top_pt/body_bottom_pt) — seepage_fit_image_list_inserts/4.page_fit_safety_pt/0now only covers what that estimate can't see (line-height rounding, the little Docs adds before flowing an image vs. plain text) — hence the much smaller default.Calibrated live 2026-09-22/23 against Andi's landscape "Joonised (tootmine)" template and the composite Hinnapakkumine + Joonised + Leping preview (A4 landscape, 72pt margins, the shared "house" header — a 1x2 table with a logo — and house footer — ahorizontalRuleelement, two empty Calibri 9.5pt paragraphs, and a details table whose cells hold soft () line breaks — both taller than theirmarginHeader/marginFooterof 36pt) — these are the measurements the header/footer content estimator (segment_extent_pt/2and friends) is built to reproduce fromdoc["headers"]/doc["footers"]alone, without a live render:- text on a fresh page starts at 72pt (
marginTop), but Docs starts an inline image's paragraph at ~111pt — it lays the image out under the header, which extends past its own margin (36pt + ~75pt of content); plain text is NOT pushed down the same way.header_extent_pt/2on this house header (a 1x2 table with a ~46pt-tall logo image) estimates ≈78pt against the ≈75pt measured live — a few points over, never under. - the footer extends ~68pt past its own 72pt
marginBottom(the last line of text that still fits lands around y≈448pt against that nominal margin's 523pt edge on a 596pt-tall page) — i.e. ~105-107pt of TOTAL footer zone measured from the page bottom, against a nominalmarginFooterof 36pt.footer_extent_pt/2on the house footer estimates ≈106.8pt from its content alone (ahorizontalRuleelement counts as one line on top of its paragraph's own text, two empty Calibri 9.5pt paragraphs, and a 2-column details table whose right cell's softline breaks add lines within a single Docs paragraph) — in line with that measurement. - a line's true height runs ahead of the naive
fontSize × lineSpacing/100product — measured live at ~14.55pt for Arial 11pt/115% (12.65 × ~1.15).estimate_paragraph_height_pt/1applies a single@font_leading(1.22) constant to every line regardless of font, the larger of the two real multipliers measured (Arial ~1.15, the house footer's Calibri ~1.22) — safe-side for Arial, matching for Calibri. - the section's terminal paragraph (present after the last image, when
the image slot ends its section) costs one more default-style line,
~15.4pt (
11 × 1.15 × 1.22) — this ispage_fit_image_list_inserts/4'strailing_line_pt, subtracted only from the LAST image rendered in the slot (every other image is immediately followed by another image, not the section's terminal paragraph). header_extent_pt/2/footer_extent_pt/2estimate a segment's content the same wayestimate_paragraph_height_pt/1estimates a body paragraph (lineCount × fontSize × lineSpacing/100 × @font_leading + spaceAbove + spaceBelow + borderTop/borderBottom width+padding, an empty paragraph counting as one line, one more line persoft break and perhorizontalRuleelement), plus: a table is the sum of its rows, a row is the tallest of its cells, and a cell is the sum of its paragraphs' heights plus its owntableCellStylepadding (falling back to 5pt top/bottom — the Docs default when a table's cells don't set it explicitly, as seen on the house header's own cells) and border widths — and a paragraph holding an inline image is sized from that image's owninlineObjects[id].embeddedObject.size.heightplus its ownmarginTop/marginBottom(falling back to 5pt each) instead of the font formula, which would drastically undersize it.- live-verified (2026-09-23, commit
1137050): a "Joonised (tootmine)" preview with 2 and 3fit: "page"images each landed one per page, no blank pages, footer untouched — images sized to 311pt and 315pt.
- text on a fresh page starts at 72pt (
Summary
Functions
Returns the uuid of the active Google integration, or nil if none
has been chosen.
Append a template's content to an existing Google Doc via batchUpdate.
Send a batchUpdate request to a Google Doc.
Builds the list of batchUpdate request maps to substitute image tags.
Builds the Phase 2 batch: for every matched table, an
updateTableCellStyle clearing its default cell borders when its slot is
a grid (columns >= 2), followed by one insertInlineImage per cell.
Builds a single image insert request map.
Folder-settings keys holding discovered folder IDs (cleared on config change).
Check if connected. Returns {:ok, %{email: email}} or {:error, reason}.
Page content width in points = pageSize.width − marginLeft − marginRight. Falls back to 468pt (US Letter with 1" margins) if anything is missing.
Copy a Google Doc for use as the base of a composed document.
Copy a file in Google Drive. Returns the new file's ID.
Create a new blank Google Doc in a specific folder.
Create a folder in Google Drive. Optionally specify a parent folder.
Returns {:ok, folder_id}.
Delete (trash) a Google Doc. Used for best-effort cleanup after a failed composition.
Returns :ok or {:error, reason}.
Discover templates, documents, and deleted folder IDs. Looks for folders by name in Drive root, creating them if they don't exist. Caches results in Settings.
Return the range {1, end_index} of the current content in a Google Doc.
Walk a path like "clients/active/templates", creating folders as needed.
Returns {:ok, leaf_folder_id}.
Export a Google Doc as PDF. Returns {:ok, pdf_binary}.
Fetch a document thumbnail as a base64 data URI via the Drive API.
Resolve the current parent folder and path for a Drive file.
Fetch Google Drive file metadata needed for sync classification.
Phase B — emits insertInlineImage requests for each cell, last-first so earlier inserts don't shift later indices. One image per cell; extra cells beyond the media list are ignored.
Find a folder by name, optionally within a parent folder.
Returns {:ok, folder_id} or {:error, :not_found}.
Scans a documents.get response for image tag occurrences.
Find a folder by name, or create it if it doesn't exist.
Optionally specify a parent folder.
Returns {:ok, folder_id}.
Phase 1a of the append-with-tables pipeline (see append_template/3):
locate flatten_template_with_table_markers/1 marker tokens in an
already-fetched document. Mirrors find_text_var_ranges/2's UTF-16 index
arithmetic (Regex.scan(..., return: :index) yields byte offsets; Google
Docs indices count UTF-16 code units).
Phase 0 of the append-with-tables pipeline (see append_template/3).
Same walk as flatten_template_with_table_markers/1, additionally
capturing per-run character style (bold, italic, font size, foreground
color) and per-paragraph style (alignment, line spacing, space
above/below, named style type, indentation, list bullet) — see
append_template/3's "Formatting fidelity" doc section.
The Settings key used for folder configuration.
Same as header_extent_pt/2, for the footer that resolves for
section_style.
Get stored OAuth credentials via PhoenixKit.Integrations.
Read a Google Doc's full content.
Extract plain text content from a Google Doc (for variable detection).
Get the edit URL for a Google Doc.
Get configured folder paths and names from Settings, with defaults.
Get cached folder IDs from Settings, or discover them.
Get the Google Drive folder URL.
One updateTableCellStyle spanning a whole table, zeroing all four cell
borders (padding untouched). table_el is a block as returned by
collect_tables/1 (%{"startIndex" => _, "table" => %{...}}); its
rowSpan/columnSpan come from the table's own dimensions
(table_dimensions/1), not from the slot config, so a partially-filled
last row is still covered.
Estimated height in points of the header that resolves for section_style
(its own defaultHeaderId, else the document's) — 0.0 when neither has
one. This function only ever looks at the ONE section_style it's given;
it does not walk the "inherits from the previous SectionBreak" chain Docs
itself uses for defaultHeaderId/defaultFooterId (see section_boxes/1's
doc) — callers outside that chain-aware pipeline (e.g. a direct call on an
isolated section) get only the own-or-document resolution, which is
correct for the document's first section but not necessarily for a later
one whose own id is unset. See the moduledoc's Configuration section for
the estimator's rules and the live measurement it's calibrated against.
Per-image width in points for N columns sharing content_width_pt.
List Google Docs directly in a Drive folder (non-recursive, fully paginated).
List subfolders within a parent folder (non-recursive, fully paginated).
Returns {:ok, [%{"id" => ..., "name" => ...}]}.
Identifies which of tables_asc (table elements from the re-fetched document,
ascending by start index) are the tables Phase 1 just inserted, returning them
in slot order.
Move the top-level Drive folders (templates, documents, deleted) into
root_folder_id. For each folder the cached ID is tried first; when absent,
the folder is located by name in the Drive root. Moving the deleted folder
carries its sub-folders along automatically.
Move a file to a different folder in Google Drive.
Host-tunable residual safety margin, in points, subtracted from a
fit: "page" image's available height — on every image of the slot, not
just the section's first. Configure via config :phoenix_kit_document_creator, :page_fit_safety_pt, N (see the
Configuration section of the moduledoc: the top/bottom of the available
area comes from section_boxes/1's body_top_pt/body_bottom_pt, so
this only covers what that estimate can't see). A non-numeric or
negative value falls back to the default 8.0.
Builds createParagraphBullets requests replaying captured list
membership (see extract_bullet_info/2's doc for what "replaying" means
here — glyph family, not exact glyph/format). Contiguous spans (no gap
in offsets, same source listId) are merged into a single request
spanning the whole run, so consecutive list items land in one target list
(numbered items continuing count 1, 2, 3, ... instead of each restarting
at 1) rather than length(spans) separate single-paragraph lists.
Builds updateParagraphStyle requests replaying captured paragraph style
(alignment, line spacing, space above/below, named style type,
start/first-line indentation — see
flatten_template_with_table_markers_and_styles/1's cell_paragraphs /
body_paragraphs), anchored at base_index the same way
text_style_requests/2 anchors character runs.
Rename a file in Google Drive.
Replace all {{variable}} placeholders in a Google Doc.
Keys are wrapped in {{ }} automatically.
Resolves each find_image_tag_ranges/2 occurrence to the section-scoped
fill that owns it, tagging it with the {position, name} key
apply_image_fills/3's fills_map is keyed by.
Compute the three Drive paths (templates, documents, deleted) given a folder config map.
Per-section content boxes for a Google Doc — one per sectionBreak in its
body, each covering the character range from that break up to the next
(or the end of the body).
Builds the single updateSectionStyle request that gives an appended
section both its template's own page margins (same resolution as
section_margin_requests/2, which this reuses) and its page ORIENTATION,
relative to the target document's page size. target_doc is the
already-fetched target document (append_template/3's current_doc).
Builds the updateSectionStyle request that gives a section its
template's own page margins, and nothing else. section_index is any
index inside the section. append_template/3 does not call this: it
sends section_layout_requests/3, which carries these same margins plus
the section's page orientation in one request.
Substitute all sections' variables and image params into a Google Doc in a single atomic pass per phase (text then image).
Two-step image substitution: GET the document, build the batch, send it.
Column-width companion used by build_table_fill_requests/3 (Phase 2, NOT
the Phase 1b skeleton batch — see below). table_start_index must be a
table's real, post-insert startIndex from a re-fetched document (the
same value extract_table_cells/1's caller already has via
match_new_tables/3's matched table element), never the location.index
an insertTable request was given. column_properties is a table's
captured column_properties list (see
flatten_template_with_table_markers_and_styles/1), index-aligned to the
table's columns.
Phase A — emits batchUpdate requests that delete the placeholder range and
create a Google Docs table at its start index. After a doc re-fetch,
fill_table_cells/3 populates the table.
Phase 1b of the append-with-tables pipeline (see append_template/3): for
each located marker, delete the marker text and insert a bare table of its
captured dimensions at that position. Sorted descending by start_index
(same convention as collect_text_replacements/3 and
build_image_batch_requests/3) so earlier replacements in the list don't
shift the indices of markers still to be processed.
Builds updateTextStyle requests replaying captured per-run character
style (see flatten_template_with_table_markers_and_styles/1's cell_runs
/ body_runs), anchored at base_index — the same index the
corresponding insertText used. Adjacent runs sharing identical style are
merged into one range first, but every non-empty run is still covered,
including a plain run's explicit bold: false/italic: false — this is
deliberate: a freshly inserted blob of text otherwise inherits its style
from whatever character precedes it in the target document, so leaving a
plain run unstyled would silently pick up bold/italic from neighboring
content (seen live: an appended section's plain paragraph inheriting bold
from an adjacent heading).
Upload a raw image binary to Drive and return a public, embeddable URL.
Validate a Google Drive file/folder ID. Returns {:ok, id} or {:error, :invalid_file_id}.
Functions
@spec active_integration_uuid() :: String.t() | nil
Returns the uuid of the active Google integration, or nil if none
has been chosen.
The settings value at document_creator_settings.google_connection is
expected to be a UUIDv7 (the integration row's storage uuid). Older
installs may have a legacy "google" or "google:name" string here;
this function detects that, resolves it to the matching integration's
uuid, rewrites the setting, and returns the uuid. Subsequent calls
read the migrated value directly.
@spec append_template(String.t(), String.t(), keyword()) :: {:ok, {integer(), integer()}} | {:error, term()}
Append a template's content to an existing Google Doc via batchUpdate.
Inserts a section break (next page), then the content of template_doc_id
into target_doc_id, then gives the new section the template's own page
margins. Returns {:ok, {start_index, end_index}} representing the
character range of the inserted content — callers use this for
section-scoped substitution.
Paragraph text is inserted via a single insertText, same as before this
function also handled tables. Tables are NOT part of that flattened text —
there is no Docs API primitive for "paste another document's table here" —
so they are rebuilt in two extra batchUpdate passes, reusing the exact
pattern already proven for image-grid tables (table_image_inserts/3 ->
re-fetch -> match_new_tables/3 -> fill_table_cells/3):
flatten_template_with_table_markers/1walks the template likeget_document_text/1does, but emits a unique marker token at each table's position (instead of silently dropping it) and captures the table's{rows, columns, cell text}separately, in document order. The marked-up text is inserted via the same singleinsertTextas before.- Re-fetch, locate the markers (
find_table_marker_ranges/1), and replace each with a bare table of the right size (table_skeleton_requests/2) in one batchUpdate. - Re-fetch again, identify the newly-inserted tables (
match_new_tables/3, reused as-is), and fill every cell with its captured text, character and paragraph style, and column widths (build_table_fill_requests/3, private) in one batchUpdate. Any{{var}}placeholder that lived inside a table cell is now physically present in the document, so it substitutes normally inComposer.apply_substitutions/4like any other text.
Templates with no tables skip steps 2-3 entirely — this reduces to exactly
the previous single-insertText behaviour, with no extra Docs API calls.
Formatting fidelity: table column widths
(tableStyle.tableColumnProperties, fixed-width columns only — evenly
distributed is insertTable's own default) are captured during flatten
and replayed via updateTableColumnProperties in the Phase 2 (cell-fill)
batch, targeting the table's real post-insert startIndex from the
re-fetch already done for cell matching — NOT the Phase 1b skeleton
batch, since insertTable's own location index cannot be trusted as
the resulting table's position (verified live: Google inserts an
implicit paragraph break ahead of a table landing mid-paragraph, shifting
its real startIndex by one from the requested location — see
table_column_width_requests/2's doc). Per-run character style — bold,
italic, font size, foreground color — is captured for both table cell
text and the section's own (non-table) body text, and replayed via
updateTextStyle in the same batch as the corresponding insertText
(after that text's paragraph style — see below). Every inserted character
is covered by an explicit range, including bold: false/italic: false
for plain runs, so freshly inserted text can never silently inherit
formatting from neighboring content already in the target document.
Paragraph-level style — alignment, line spacing, space above/below, named
style type (headings), start/first-line indentation — is captured the
same way and replayed via updateParagraphStyle
(paragraph_style_requests/2), same anti-inheritance guarantee: every
field is always in the mask — with the template's value, or unset so it
resolves against the paragraph's named style in the target document.
Paragraph style is always sent BEFORE character style
(paragraph_then_text_style_requests/3): an updateParagraphStyle whose
mask includes namedStyleType resets the paragraph's text style, even
when the named style doesn't change, so the opposite order silently
stripped every appended section of its font sizes and bold. That reset is
not in Google's public API reference — verified live 2026-09-21, and no
mock-based test can guard it. List bullets are replayed via
createParagraphBullets (paragraph_bullet_requests/2), resolving
bulleted vs numbered from the source and mapping to Google's own default
preset for that family — this reproduces glyph family, not an arbitrary
custom glyph/format exactly (see extract_bullet_info/2's doc).
Each appended template becomes its own document SECTION: the content is
preceded by insertSectionBreak (NEXT_PAGE, so it still starts on a new
page) rather than a page break, and the section then gets the template's
own page margins AND orientation via updateSectionStyle
(section_layout_requests/3). Margins are a document-level setting
otherwise, so a contract laid out for 72pt margins used to be poured into
whatever the first template's were. The margins ride in the same atomic
batch as the content, on purpose: a composed document with the wrong
margins is the very defect this exists to prevent, so a margin request
Google rejects fails the append (and the compose) loudly rather than
leaving a quietly mis-laid-out document behind. Traps (the first two
verified live 2026-09-21):
- A section break inserts a newline ahead of itself, so the appended
content starts at
insert_index + 2— in a fresh, empty paragraph of the new section. That fresh paragraph is what makes paragraph-level styling safe for the section's own first paragraph:updateParagraphStyle/createParagraphBulletstarget whole paragraphs, and content that merely continued the target's last paragraph would reformat the preceding section's trailing text too. (The target's closing character is the document's shared terminal marker, not a shiftable paragraph separator — a page break alone, an inline element, never split it, which is why this used to insert its own"\n"first.) updateDocumentStyleon margins overwrites the margins of EVERY section, silently. Nothing here sends it; anything that ever does must run before the section margins are set.- Page SIZE is document-wide; page ORIENTATION is per section via
flipPageOrientation, relative to the document'spageSize— hence the XOR against the target's page shape insection_layout_requests/3.flipPageOrientationmust always carry a concrete boolean when it's sent — the API requires "setting a concrete value"; unsetting it results in a 400 — so every appended section states it explicitly, includingfalse, or it silently inherits the previous section's flip.
Known limitations: cell shading, borders, and merged cells are not
restored — insertTable creates a bare table beyond the column widths
above. Nested tables (a table inside a table cell) are not supported.
After the section is in place, it gets its OWN header/footer when the
template has one and it would otherwise look different from what the
section inherits: SectionStyle.defaultHeaderId/defaultFooterId are
read-only and inherit from the PREVIOUS section (the very first section
inherits from documentStyle) — there is no request to point a section at
an existing header/footer, only createHeader/createFooter for a
brand new, empty one. So a template's own header/footer (documentStyle)
is compared, via SegmentReplay.fingerprint/2, against the header/footer
the new section would inherit (the target's own trailing section, walked
the same read-only-inheritance way); equal (or the template has none) —
nothing happens, matching every append before this; different — a fresh
segment is created via SegmentReplay.create_segment_request/2
(sectionBreakLocation pointing at this section's own break, content_start - 1) and the template's segment content is replayed into it
(SegmentReplay.skeleton_requests/3, then, if it has tables,
SegmentReplay.table_fill_requests/2 after a re-fetch — the same
marker/skeleton/fill shape finish_append_template/6 uses for the body,
scoped to the new segment via SegmentReplay.with_segment_id/2). A
replay failure fails the append loudly, same as a body table mismatch.
Fidelity beyond the shared marker/skeleton/fill shape (live-verified
2026-09-23 against a real template whose element count came back wrong,
whose border/font-family/link were silently dropped, and whose paragraph/
table deletes the API rejected outright twice over — none fixed by
widening the shared, narrow body builders — see
SegmentReplay.extra_paragraph_style_requests/2's doc):
- a fresh segment starts with one empty paragraph, and the Docs API
refuses to delete a segment's own terminal newline ("The range cannot
include the newline character at the end of the segment"). When the
template has no tables, its last plain paragraph's own trailing
newline is simply never inserted — the pre-existing one serves as its
terminator, every style request still targeting that paragraph's
original (un-stripped) offset (
replay_paragraphs_only/6,skeleton_insert_text/2). - a table-bearing template is different:
insertTablelanding where a marker was ALWAYS splits whatever paragraph absorbed the marker into a "before" half and an "after" half, and the API refuses to delete either one too ("Cannot delete the requested range"). So neither half is ever explicitly created: the paragraph immediately before EACH table (and the template's own last paragraph) never gets its trailing newline inserted either — the split reintroduces it for free, landing that paragraph exactly where the template had it, with nothing to delete. Since a paragraph's real position next to a table isn't knowable until the table exists, ALL style requests for a table-bearing segment wait for a re-fetch, which is matched against the template element-by-element (paragraph or table, in order —match_segment_elements/2, failing loudly on a count mismatch instead of guessing a pairing) and styled at each element's own real index (replay_with_tables/7,finish_segment_with_tables/7,table_bearing_skeleton_text/2). - borders,
weightedFontFamily,underline, andlinkaren't in the shared body builders' capture at all; a separate pass (body_extra_style_requests/4/per_paragraph_bundles/3, and per-cell inaugment_table_info/2) replays them from the template's own raw JSON. A paragraph carrying a nativehorizontalRule(never inserted through the API) replays as a thin bordered paragraph instead, regardless of its own paragraphStyle (paragraph_extras/1,text_extras/1).
Options (used in tests):
:get_fn— overridesget_document/1(used for both the template fetch and every target-document re-fetch):batch_fn— overridesbatch_update/2
Send a batchUpdate request to a Google Doc.
Builds the list of batchUpdate request maps to substitute image tags.
fills is a map keyed by variable name; each value carries kind,
default_width_px, separator (atom or nil), and media — a list of
%{uri, width_px, height_px}.
Empty media list = the tag is still deleted (cleared).
Builds the Phase 2 batch: for every matched table, an
updateTableCellStyle clearing its default cell borders when its slot is
a grid (columns >= 2), followed by one insertInlineImage per cell.
All border requests come first, across every table, before any cell fill —
insertInlineImage shifts the indices of tables that come later in the
document, but updateTableCellStyle (addressed by tableStartLocation,
not a text index range) does not, so ordering border-then-fill within one
table would still corrupt a later table's startIndex were the two kinds
interleaved per table instead of grouped by kind.
For the same reason the image inserts run in strictly descending index order across the whole batch: the last table is filled first, each table's cells back to front.
Builds a single image insert request map.
Options:
:insertion_index— document character index for insertion (required):config— map with:default_width_px,:opacity,:z_index(required)
When z_index > 0, emits a createPositionedObject with layout = "WRAP_TEXT".
When z_index <= 0, emits insertInlineImage (default inline behaviour).
Opacity application requires a follow-up UpdateEmbeddedObjectPropertiesRequest
with the object ID returned by the batchUpdate response — not emitted here.
A Logger warning is written when opacity != 1.0. This is a documented
no-op (open risk) per the spec's "Open Risks" section: applying transparency
requires a second batchUpdate pass after the initial insert, using the
embedded object ID from the first response. Not yet implemented.
@spec cached_folder_id_keys() :: [String.t()]
Folder-settings keys holding discovered folder IDs (cleared on config change).
Check if connected. Returns {:ok, %{email: email}} or {:error, reason}.
Page content width in points = pageSize.width − marginLeft − marginRight. Falls back to 468pt (US Letter with 1" margins) if anything is missing.
Copy a Google Doc for use as the base of a composed document.
Returns {:ok, new_doc_id}. The copy is named by its source doc ID so it
can be identified for best-effort cleanup on rollback before a final name
is applied.
@spec copy_file(String.t(), String.t(), keyword()) :: {:ok, String.t()} | {:error, :invalid_file_id | :copy_failed | term()}
Copy a file in Google Drive. Returns the new file's ID.
@spec create_document(String.t(), keyword()) :: {:ok, %{doc_id: String.t(), name: String.t(), url: String.t() | nil}} | {:error, :create_document_failed | term()}
Create a new blank Google Doc in a specific folder.
@spec create_folder(String.t(), keyword()) :: {:ok, String.t()} | {:error, :create_folder_failed | term()}
Create a folder in Google Drive. Optionally specify a parent folder.
Returns {:ok, folder_id}.
Delete (trash) a Google Doc. Used for best-effort cleanup after a failed composition.
Returns :ok or {:error, reason}.
@spec discover_folders() :: %{ templates_folder_id: String.t() | nil, documents_folder_id: String.t() | nil, deleted_templates_folder_id: String.t() | nil, deleted_documents_folder_id: String.t() | nil }
Discover templates, documents, and deleted folder IDs. Looks for folders by name in Drive root, creating them if they don't exist. Caches results in Settings.
Return the range {1, end_index} of the current content in a Google Doc.
Used by the Composer to pin section 0's range before any sections are appended. The range starts at index 1 because Google Docs body content always begins at 1.
Walk a path like "clients/active/templates", creating folders as needed.
Returns {:ok, leaf_folder_id}.
@spec export_pdf(String.t()) :: {:ok, binary()} | {:error, :invalid_file_id | :drive_export_too_large | :drive_file_not_found | :drive_forbidden | :drive_rate_limited | :pdf_export_failed | term()}
Export a Google Doc as PDF. Returns {:ok, pdf_binary}.
On failure the reason names why Drive refused the export instead of
collapsing every case into :pdf_export_failed:
:drive_file_not_found— Drive returned 404. Usually the file was deleted, but Drive also answers 404 for a live file the current connection is not allowed to see (an unshare, or a re-pointed Google connection), so callers must not treat it as proof the file is gone.:drive_forbidden— Drive returned 403 because the connected Google account lacks permission to read the file:drive_rate_limited— Drive returned 403 because of a rate/quota limit (retrying later can succeed):drive_export_too_large— the Doc is past the export endpoint's size cap (about 10 MB of PDF) AND the fallback below failed too:pdf_export_failed— any other non-200 response, including a 403 with an unrecognized reason
Past that cap files.export answers 403 exportSizeLimitExceeded; the
same PDF is then downloaded from the file's exportLinks (a
docs.google.com URL that has no such cap) with the same credentials.
The token is only ever sent to an https://docs.google.com link.
@spec fetch_thumbnail(term()) :: {:ok, String.t()} | {:error, :no_doc_id | :no_thumbnail | :thumbnail_link_failed | :thumbnail_fetch_failed | :invalid_file_id | term()}
Fetch a document thumbnail as a base64 data URI via the Drive API.
@spec file_location(term()) :: {:ok, %{folder_id: String.t(), path: String.t(), trashed: boolean()}} | {:error, :invalid_file_id | :not_found | term()}
Resolve the current parent folder and path for a Drive file.
@spec file_status(term()) :: {:ok, %{trashed: boolean(), parents: [String.t()]}} | {:ok, :not_found} | {:error, :invalid_file_id | term()}
Fetch Google Drive file metadata needed for sync classification.
Phase B — emits insertInlineImage requests for each cell, last-first so earlier inserts don't shift later indices. One image per cell; extra cells beyond the media list are ignored.
@spec find_folder_by_name(String.t(), keyword()) :: {:ok, String.t()} | {:error, :not_found | :folder_search_failed | term()}
Find a folder by name, optionally within a parent folder.
Returns {:ok, folder_id} or {:error, :not_found}.
@spec find_image_tag_ranges(map(), [String.t()]) :: [ %{name: String.t(), start_index: integer(), end_index: integer()} ]
Scans a documents.get response for image tag occurrences.
Returns a flat list of %{name, start_index, end_index} covering every
occurrence in body content, headers, footers, and table cells, restricted
to the names supplied.
Offset note: Regex.scan(..., return: :index) returns byte offsets;
Google Docs startIndex counts UTF-16 code units. The implementation
converts byte offsets to UTF-16 code-unit counts via
:unicode.characters_to_binary/3 so supplementary-plane codepoints
(emoji, rare CJK) are counted as the two units a surrogate pair occupies.
Find a folder by name, or create it if it doesn't exist.
Optionally specify a parent folder.
Returns {:ok, folder_id}.
@spec find_table_marker_ranges(map()) :: [ %{marker_index: integer(), start_index: integer(), end_index: integer()} ]
Phase 1a of the append-with-tables pipeline (see append_template/3):
locate flatten_template_with_table_markers/1 marker tokens in an
already-fetched document. Mirrors find_text_var_ranges/2's UTF-16 index
arithmetic (Regex.scan(..., return: :index) yields byte offsets; Google
Docs indices count UTF-16 code units).
Phase 0 of the append-with-tables pipeline (see append_template/3).
Flattens a template document's body the same way get_document_text/1
does for paragraphs, but instead of silently skipping table blocks, emits a
unique marker token at each table's position and captures its structure
separately.
This is a distinct code path from get_document_text/1 — that function's
existing behaviour (silently skipping tables) is relied on by its other
callers (Documents.detect_variables/1,
Documents.image_slots_for_template/1) and is intentionally left
untouched.
Returns {text, tables}. tables is a list of
%{marker_index: pos_integer(), rows: pos_integer(), columns: pos_integer(), cell_texts: [String.t()]}, one entry per table, in document order.
cell_texts is row-major (row 0's cells left-to-right, then row 1's, ...),
one entry per table cell — the same order extract_table_cells/1 and the
cell-fill phase (build_table_fill_requests/3, both private) enumerate
cells in.
Nested tables (a table inside a table cell) are not supported: a cell's text is captured from its paragraph blocks only, same limitation the top-level flatten has.
This is a thin wrapper around flatten_template_with_table_markers_and_styles/1
that drops its 3rd/4th return values (body_runs/body_paragraphs) — kept
at its original 2-tuple arity so existing callers are unaffected by the
style-capture addition.
@spec flatten_template_with_table_markers_and_styles(map()) :: {String.t(), [map()], [map()], [map()]}
Same walk as flatten_template_with_table_markers/1, additionally
capturing per-run character style (bold, italic, font size, foreground
color) and per-paragraph style (alignment, line spacing, space
above/below, named style type, indentation, list bullet) — see
append_template/3's "Formatting fidelity" doc section.
Returns {text, tables, body_runs, body_paragraphs}:
text,tables— identical toflatten_template_with_table_markers/1, except each table map intablesgains three keys:column_properties(the source table'stableStyle.tableColumnProperties, normalized to%{width_type, magnitude, unit}, one per column,[]if the source table has none),cell_runs(one run-list per cell, parallel tocell_texts, same order — seetext_style_requests/2's run shape) andcell_paragraphs(one paragraph-span-list per cell, parallel tocell_texts, same order — seeparagraph_style_requests/2's span shape).body_runs— the non-table body text's per-run style spans, same run shape as acell_runsentry, withstart_offset/lengthin UTF-16 units relative to the start oftext(table markers consume offset but contribute no run — they're deleted before any style request referencing them would apply).body_paragraphs— the non-table body text's per-paragraph style spans, same shape as acell_paragraphsentry, offsets relative to the start oftext.
@spec folder_settings_key() :: String.t()
The Settings key used for folder configuration.
Get stored OAuth credentials via PhoenixKit.Integrations.
Read a Google Doc's full content.
Extract plain text content from a Google Doc (for variable detection).
Get the edit URL for a Google Doc.
@spec get_folder_config() :: map()
Get configured folder paths and names from Settings, with defaults.
@spec get_folder_ids() :: map()
Get cached folder IDs from Settings, or discover them.
Get the Google Drive folder URL.
One updateTableCellStyle spanning a whole table, zeroing all four cell
borders (padding untouched). table_el is a block as returned by
collect_tables/1 (%{"startIndex" => _, "table" => %{...}}); its
rowSpan/columnSpan come from the table's own dimensions
(table_dimensions/1), not from the slot config, so a partially-filled
last row is still covered.
A table block missing "startIndex" (proto3 omits an explicit zero) is
defaulted to 0 defensively — real document content never starts before
index 1, so this branch is never expected to fire.
Estimated height in points of the header that resolves for section_style
(its own defaultHeaderId, else the document's) — 0.0 when neither has
one. This function only ever looks at the ONE section_style it's given;
it does not walk the "inherits from the previous SectionBreak" chain Docs
itself uses for defaultHeaderId/defaultFooterId (see section_boxes/1's
doc) — callers outside that chain-aware pipeline (e.g. a direct call on an
isolated section) get only the own-or-document resolution, which is
correct for the document's first section but not necessarily for a later
one whose own id is unset. See the moduledoc's Configuration section for
the estimator's rules and the live measurement it's calibrated against.
Per-image width in points for N columns sharing content_width_pt.
List Google Docs directly in a Drive folder (non-recursive, fully paginated).
Returns {:ok, [%{"id" => ..., "name" => ..., "modifiedTime" => ..., "thumbnailLink" => ..., "parents" => [...]}]}.
For recursive traversal across subfolders, use
PhoenixKitDocumentCreator.GoogleDocsClient.DriveWalker.walk_tree/2.
List subfolders within a parent folder (non-recursive, fully paginated).
Returns {:ok, [%{"id" => ..., "name" => ...}]}.
@spec match_new_tables([map()], [non_neg_integer()], [non_neg_integer()]) :: {:ok, [map()]} | :mismatch
Identifies which of tables_asc (table elements from the re-fetched document,
ascending by start index) are the tables Phase 1 just inserted, returning them
in slot order.
pre_existing_starts and new_slot_starts are start indices captured from the
pre-Phase-1 document. Phase 1's deletes/inserts shift the absolute indices of
everything after a placeholder, so a startIndex set-difference misclassifies a
pre-existing table located after a placeholder (its index moves and no longer
matches the snapshot). Table order is never changed by inserts, though, so we
reconstruct the pre/new interleaving from the original indices and read it off
the post-Phase-1 tables positionally — robust regardless of where pre-existing
tables sit relative to the placeholders.
Returns {:ok, new_tables} aligned with new_slot_starts sorted ascending, or
:mismatch when the table count doesn't line up (e.g. Phase 1 partially
failed) so the caller can skip filling rather than fill the wrong tables.
@spec migrate_folders_to_root(String.t()) :: {:ok, %{moved: [String.t()], skipped: [String.t()]}} | {:error, [{String.t(), term()}]}
Move the top-level Drive folders (templates, documents, deleted) into
root_folder_id. For each folder the cached ID is tried first; when absent,
the folder is located by name in the Drive root. Moving the deleted folder
carries its sub-folders along automatically.
Clears cached folder IDs on full success so they are re-discovered from the new location on next use.
Returns {:ok, %{moved: [labels], skipped: [labels]}} or
{:error, [{label, reason}]} if any move fails.
@spec move_file(String.t(), String.t()) :: :ok | {:error, :invalid_file_id | :move_failed | :get_file_parents_failed | :drive_file_not_found | term()}
Move a file to a different folder in Google Drive.
@spec page_fit_safety_pt() :: float()
Host-tunable residual safety margin, in points, subtracted from a
fit: "page" image's available height — on every image of the slot, not
just the section's first. Configure via config :phoenix_kit_document_creator, :page_fit_safety_pt, N (see the
Configuration section of the moduledoc: the top/bottom of the available
area comes from section_boxes/1's body_top_pt/body_bottom_pt, so
this only covers what that estimate can't see). A non-numeric or
negative value falls back to the default 8.0.
Builds createParagraphBullets requests replaying captured list
membership (see extract_bullet_info/2's doc for what "replaying" means
here — glyph family, not exact glyph/format). Contiguous spans (no gap
in offsets, same source listId) are merged into a single request
spanning the whole run, so consecutive list items land in one target list
(numbered items continuing count 1, 2, 3, ... instead of each restarting
at 1) rather than length(spans) separate single-paragraph lists.
Spans with no bullet (not a list item) or zero length (see
paragraph_style_requests/2) are excluded before grouping.
Requests are emitted in DESCENDING range order: createParagraphBullets
strips leading tabs from paragraphs in its range, which shifts every
later index — applying the highest range first means any shift lands
only below ranges that are already done, the same reasoning as every
other index-shifting pass in this module.
Builds updateParagraphStyle requests replaying captured paragraph style
(alignment, line spacing, space above/below, named style type,
start/first-line indentation — see
flatten_template_with_table_markers_and_styles/1's cell_paragraphs /
body_paragraphs), anchored at base_index the same way
text_style_requests/2 anchors character runs.
Every field is always included in the request's fields mask — the
same anti-inheritance guarantee text_style_fields/1 applies to
bold/italic. A property the template paragraph doesn't set (captured as
nil) is left out of the payload, which the Docs API reads as "unset":
it then resolves against the paragraph's named style in the target
document (the same result as in the template wherever the two documents'
named styles agree — see extract_paragraph_style/2), instead of being
pinned to a concrete value the template never asked for. Without the
complete mask, a newly split paragraph in the target document would
inherit alignment/spacing/named style from whatever paragraph sat at the
insertion point (e.g. an appended section's plain paragraph picking up
CENTER alignment from a neighboring heading), not from the source
template.
Unlike text_style_requests/2, spans are never merged — each paragraph
gets its own request, since paragraphs are already discrete units (no
benefit to coalescing, even when two adjacent ones share identical style).
A span whose length is 0 (a paragraph with no textRun at all — doesn't
happen for a real, non-empty paragraph, since even a blank line carries a
"\n"-only run) is skipped: with zero characters, it contributes no
offset and doesn't exist as distinct content in the inserted text either.
Ranges deliberately use the paragraph's own natural (un-stripped) length,
even for a table cell's last paragraph whose text had its trailing
newline stripped before insertion (see cell_fill_requests/4) — the
cell's pre-existing bare paragraph supplies that newline structurally
either way, so the natural length lands the range exactly on it. See
cell_paragraph_spans/2's doc for the full argument.
Safe to batch with the insert/fill it styles: like character style
changes, paragraph style changes never shift document indices. They DO
reset the text style of the paragraphs they touch (the mask includes
namedStyleType), so for the same range they must come BEFORE
text_style_requests/2 — use paragraph_then_text_style_requests/3.
@spec rename_file(String.t(), String.t()) :: :ok | {:error, :invalid_file_id | :rename_failed | term()}
Rename a file in Google Drive.
Replace all {{variable}} placeholders in a Google Doc.
Keys are wrapped in {{ }} automatically.
@spec resolve_image_ranges( [%{name: String.t(), start_index: integer(), end_index: integer()}], [ {{non_neg_integer(), String.t()}, map(), {integer(), integer()} | nil} ] ) :: [ %{ name: String.t(), start_index: integer(), end_index: integer(), key: {non_neg_integer(), String.t()} } ]
Resolves each find_image_tag_ranges/2 occurrence to the section-scoped
fill that owns it, tagging it with the {position, name} key
apply_image_fills/3's fills_map is keyed by.
all_image_fills is substitute_all_images/4's {{position, name}, fill, section_range} list. An occurrence is matched to the entry whose name
equals its own and whose section_range contains its start_index — the
same slot name can occur once per section (an image-grid template and its
per-orientation twin, for example, both use a slot named joonised), so
matching by name alone (as this used to) always resolves to whichever
section happened to be last in all_image_fills, regardless of which
section's placeholder text is actually at that index.
An occurrence with no matching entry (no section's range contains it, or no section declared that name) is dropped, same as before.
Compute the three Drive paths (templates, documents, deleted) given a folder config map.
@spec section_boxes(map()) :: [ %{ start_index: non_neg_integer(), end_index: non_neg_integer(), width_pt: float(), height_pt: float(), margin_top: float(), margin_bottom: float(), body_top_pt: float(), body_bottom_pt: float() } ]
Per-section content boxes for a Google Doc — one per sectionBreak in its
body, each covering the character range from that break up to the next
(or the end of the body).
A sectionBreak StructuralElement's own sectionStyle describes the
section that STARTS right after it (see first_section_style/1's doc for
why the body's very first element is always one of these). width_pt /
height_pt are that section's own page content area — documentStyle's
pageSize, swapped when the section's resolved flipPageOrientation is
true (section value if the key is present, else the document's, else
false — same resolution section_flip?/2 uses for "does the section
look landscape"), minus that section's own margins (its sectionStyle,
else documentStyle, else 72pt — the same resolution content_width_pt/1
and section_margin_requests/2 use). margin_top / margin_bottom are
exposed so callers (the fit: "page" image scaling in apply_image_fills/3)
can reserve space without re-deriving them.
A document with no explicit sectionBreak yields a single box for the
whole body, using documentStyle alone; when pageSize is missing too it
falls back to the same constants content_width_pt/1 uses — 468pt width
(documented as "Letter, 1in margins"), and 648pt height (Letter's 792pt
minus the same 144pt of margin).
body_top_pt / body_bottom_pt are the top/bottom of the area a fit: "page" image can actually use — computed from the section's header/footer
content (header_extent_pt/2, footer_extent_pt/2), not just its
marginTop/marginBottom: body_top_pt = max(margin_top, margin_header + header_extent), body_bottom_pt = page_h_effective - max(margin_bottom, margin_footer + footer_extent). A header/footer taller than its own
margin pushes the body down/up further than the nominal margin would; one
no taller than its margin leaves the nominal margin as-is. See the
moduledoc's Configuration section for the live measurement this
reproduces.
A section's defaultHeaderId/defaultFooterId resolve by walking the
sections in document order — own value if set, else the PREVIOUS
section's (already-resolved) value, else (only for the first section)
documentStyle's — per the Docs API reference for those fields ("If
unset, the value inherits from the previous SectionBreak's SectionStyle.
If the value is unset in the first SectionBreak, it inherits from
DocumentStyle's defaultHeaderId"). This function does that walk once and
hands each section's resolved id to header_extent_pt/2 /
footer_extent_pt/2 as if it were the section's own — those two
functions themselves only ever look at the one section_style they're
given, they don't walk the chain.
@spec section_layout_requests(non_neg_integer(), map(), map()) :: [map()]
Builds the single updateSectionStyle request that gives an appended
section both its template's own page margins (same resolution as
section_margin_requests/2, which this reuses) and its page ORIENTATION,
relative to the target document's page size. target_doc is the
already-fetched target document (append_template/3's current_doc).
Google Docs stores page SIZE document-wide — a section cannot have its own
pageSize — but page ORIENTATION is per section, via
SectionStyle.flipPageOrientation, always resolved relative to
DocumentStyle.pageSize. A template's own "true" orientation is therefore
derived, not read off a single field: Google records landscape either as a
literally wide pageSize (width > height) or as a portrait-shaped
pageSize plus flipPageOrientation: true (section-level if the template
itself is a composed document, else document-level) — landscape_shaped?
XOR the resolved flip covers both. The section this function builds a
request for then gets the flip that makes it look, against the TARGET
document's own (unflipped) page shape, the way the template actually looks:
template_landscape? XOR target_landscape_shaped?.
flipPageOrientation is ALWAYS in the request with a concrete boolean —
never omitted, including false — in the same updateSectionStyle and
mask as the margins. The API requires "setting a concrete value" for this
field; unsetting it results in a 400, and omitting the request entirely
would let the section silently inherit whatever flip the previous section
(or the document) carries — the exact defect this exists to prevent when a
portrait template follows a landscape one.
@spec section_margin_requests(non_neg_integer(), map()) :: [map()]
Builds the updateSectionStyle request that gives a section its
template's own page margins, and nothing else. section_index is any
index inside the section. append_template/3 does not call this: it
sends section_layout_requests/3, which carries these same margins plus
the section's page orientation in one request.
The margins are the ones the template's own first page renders with: its
first section's sectionStyle.margin* where set, else
documentStyle.margin* — the API's own resolution order (a section margin
left unset defaults to the document's). A template that is itself a
composed document carries per-section margins this way, and reading
documentStyle alone would hand its first section the wrong ones.
Only the margins the template actually states are touched; a margin
present without a magnitude is an explicit zero (the API omits a zero
magnitude from its JSON — see dimension_or_nil/1). No margins in either
place produces no request.
@spec substitute_all_sections(String.t(), [map()], %{ required(non_neg_integer()) => {integer(), integer()} }) :: :ok | {:error, term()}
Substitute all sections' variables and image params into a Google Doc in a single atomic pass per phase (text then image).
sections is a list of %{position, variable_values, image_params} maps.
ranges maps each section position to its {start_index, end_index} in the
document. All positions must have a range entry — section 0's range must be
provided explicitly (use document_content_range/1 after copy, before append).
Each {{key}} placeholder in the document is matched against the section whose
range contains it; that section's variable_values[key] supplies the replacement.
Placeholders outside all section ranges are left untouched.
Text substitution runs before image substitution (per image-substitution.md) and
the document is re-fetched between the two phases so image indices are accurate
after text edits. All operations within a phase are batched in a single
batchUpdate in reverse-index order so no substitution shifts the indices of
another. Section ranges are also recalculated (shift_ranges/2) by the net
UTF-16 delta of every body text replacement, so the image phase matches
markers against boundaries that reflect the edited document rather than the
original one.
Headers and footers get their own pass, never by range containment (a
header/footer has no body index at all). Each header/footer segment
resolves against the section that OWNS it — the section, in document
order, whose own or inherited defaultHeaderId/defaultFooterId first
introduces that particular segment id (header_footer_owners/3). Before
append_template/3 could give a section its own header/footer, this was
always the whole document's single shared segment, owned by whichever
section has the lowest position — that's still exactly what this
resolves to when every section still shares the original segment, so a
composed document untouched by that feature substitutes exactly as
before. Each header/footer segment has its own Docs index space (a Docs
segmentId), independent of the body's, so those replacements are excluded
from shift_ranges/2 and sent as their own segmentId-scoped requests.
Two-step image substitution: GET the document, build the batch, send it.
fills is the same shape as build_image_batch_requests/2.
Options (used in tests):
:get_fn— overridesget_document/1:batch_fn— overridesbatch_update/2
Column-width companion used by build_table_fill_requests/3 (Phase 2, NOT
the Phase 1b skeleton batch — see below). table_start_index must be a
table's real, post-insert startIndex from a re-fetched document (the
same value extract_table_cells/1's caller already has via
match_new_tables/3's matched table element), never the location.index
an insertTable request was given. column_properties is a table's
captured column_properties list (see
flatten_template_with_table_markers_and_styles/1), index-aligned to the
table's columns.
Verified live against the real Docs API: insertTable at location.index = 8 produced a table whose actual startIndex was 9, one past the
requested location — Google inserts an implicit paragraph break ahead of
a table landing mid-paragraph, and updateTableColumnProperties rejects
the un-adjusted index with INVALID_ARGUMENT: The provided table start location is invalid. This is exactly why finish_append_template/6
re-fetches after the skeleton batch before filling cells — this function
rides along on that same re-fetch instead of trying to predict the offset
analytically.
Only FIXED_WIDTH columns produce a request — EVENLY_DISTRIBUTED (or a
column with no captured property) is already what a bare insertTable
produces, so emitting a request for it would be a no-op round trip. One
request per fixed column (the Docs API's columnIndices field lets one
request retarget several columns sharing an identical width, but per-
column source widths are rarely identical in practice, so this keeps the
mapping simple and correct over minimizing request count).
Phase A — emits batchUpdate requests that delete the placeholder range and
create a Google Docs table at its start index. After a doc re-fetch,
fill_table_cells/3 populates the table.
Phase 1b of the append-with-tables pipeline (see append_template/3): for
each located marker, delete the marker text and insert a bare table of its
captured dimensions at that position. Sorted descending by start_index
(same convention as collect_text_replacements/3 and
build_image_batch_requests/3) so earlier replacements in the list don't
shift the indices of markers still to be processed.
Column widths are deliberately NOT applied here even though the table's
captured column_properties are available at this point — see
table_column_width_requests/2's doc for why insertTable's location
index cannot be trusted as the resulting table's real tableStartLocation.
Builds updateTextStyle requests replaying captured per-run character
style (see flatten_template_with_table_markers_and_styles/1's cell_runs
/ body_runs), anchored at base_index — the same index the
corresponding insertText used. Adjacent runs sharing identical style are
merged into one range first, but every non-empty run is still covered,
including a plain run's explicit bold: false/italic: false — this is
deliberate: a freshly inserted blob of text otherwise inherits its style
from whatever character precedes it in the target document, so leaving a
plain run unstyled would silently pick up bold/italic from neighboring
content (seen live: an appended section's plain paragraph inheriting bold
from an adjacent heading).
Safe to batch with the insert it styles: text style changes never shift
document character indices, so nothing else in the same batch needs to
account for these requests' presence. One ordering contract: for the same
range these must come AFTER paragraph_style_requests/2, which resets
text style — use paragraph_then_text_style_requests/3.
@spec upload_image_for_embedding(binary(), String.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
Upload a raw image binary to Drive and return a public, embeddable URL.
Used when inserting an image into a Google Doc via insertInlineImage,
which requires a fetchable URL (not raw bytes). Uploads the binary to
Drive, grants anyone-with-link read access, and returns an
lh3.googleusercontent.com/d/<id>=s4096 URL that Google's image fetcher
can read without following a redirect.
The =s4096 suffix matters: a bare lh3…/d/<id> serves a copy scaled
down to 1600px on the long side, so every larger image lost detail in the
document and its PDF (a 4000×2884 upload reached the PDF as 1600×1154).
=sN returns the original bytes when the long side is at most N and never
enlarges; 4096 rather than =s0 because insertInlineImage rejects
images over 25 megapixels, and a 4096px long side stays under that for
any aspect ratio. Google's own PDF export stores images at up to 2500px on
the long side, so 4096 loses nothing there.
data— raw image bytesmime_type— MIME type string, e.g."image/jpeg"opts— optional keyword list; supports:name(file name, defaults to"embed-image")
Validate a Google Drive file/folder ID. Returns {:ok, id} or {:error, :invalid_file_id}.