"""
Generate reports from Earth Engine data with LLM-powered narratives.
``geeViz.outputLib.reports`` provides a :class:`Report` class that combines
``chartingLib.summarize_and_chart()`` results, ``thumbLib`` thumbnails,
and Gemini-generated narratives into styled HTML or Markdown reports.
All Earth Engine data requests (charts, tables, thumbnails, GIFs) **and**
LLM narratives for every section are executed in parallel using
``concurrent.futures``. Only the executive summary waits for all sections
to finish before it is generated.
Layouts
-------
* ``"report"`` (default) — traditional multi-page portrait layout,
sections flow vertically.
* ``"poster"`` — landscape multi-column layout, designed for large-format
printing or screen display. Sections tile into a responsive grid.
Themes
------
Two built-in themes match the geeView color palette:
* ``"dark"`` (default) — deep brown/black background with warm gray text
* ``"light"`` — white background with deep brown text
Example::
import geeViz.geeView as gv
from geeViz.outputLib import reports as rl
ee = gv.ee
report = rl.Report(
title="Wasatch Front Assessment",
theme="dark",
layout="poster", # landscape multi-column
)
report.header_text = "An analysis of land cover and fire trends."
report.add_section(
ee_obj=lcms.select(['Land_Cover']),
geometry=counties,
title="LCMS Land Cover",
stacked=True,
scale=60,
)
# PDF with static chart images
report.generate(format="pdf", output_path="report.pdf")
# HTML (interactive charts)
report.generate(format="html", output_path="report.html")
"""
"""
Copyright 2026 Ian Housman
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import base64
import concurrent.futures
import os
import textwrap
import threading
import traceback
from datetime import datetime
from geeViz.outputLib import charts as cl
from geeViz.outputLib import thumbs as tl
from geeViz.outputLib import themes as _themeLib
from geeViz.outputLib._templates import (
render_report_css as _render_report_css,
HTML_TEMPLATE as _HTML_TEMPLATE,
PDF_HTML_TEMPLATE as _PDF_HTML_TEMPLATE,
)
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
_DEFAULT_MODEL = "gemini-3-flash-preview"
_SECTION_PROMPT_TEMPLATE = textwrap.dedent("""\
You are a geospatial data analyst writing a section of a technical report.
Write a concise 2-4 paragraph narrative interpreting the data below for
a section titled "{title}".
Focus on key trends, notable changes, and significant values.
Do not repeat every number — highlight what matters.
{tone}
Use markdown formatting (bold, bullets) where it helps readability.
{units_context}
{image_context}
{extra}
Data:
{table}
""")
_SUMMARY_PROMPT_TEMPLATE = textwrap.dedent("""\
You are a geospatial data analyst writing the executive summary of a
technical report titled "{title}".
Below are brief summaries of each section. Write a concise 2-3 paragraph
executive summary that ties the findings together and highlights the
most important takeaways.
{tone}
Use markdown formatting.
{extra}
{image_note}
Section summaries:
{section_summaries}
""")
_TONES = {
"neutral": (
"Use a neutral, data-driven tone. State findings directly without "
"superlatives, editorial commentary, or narrative flourishes. "
"Let the data speak for itself."
),
"informative": (
"Use an informative, accessible tone. Briefly explain what the "
"data shows and why it matters, but avoid superlatives or hype."
),
"technical": (
"Use a formal, technical tone appropriate for a scientific audience. "
"Be precise with terminology and cite specific values."
),
}
# ---------------------------------------------------------------------------
# Logo (lazy-loaded, base64 encoded)
# ---------------------------------------------------------------------------
_LOGO_CACHE = {}
def _get_logo_b64(variant="dark"):
"""Return a base64-encoded PNG data URI for the geeViz logo.
Args:
variant: ``"dark"`` or ``"light"``.
"""
if variant in _LOGO_CACHE:
return _LOGO_CACHE[variant]
logo_dir = os.path.join(
os.path.dirname(__file__), "..", "geeView", "src", "assets", "images"
)
logo_path = os.path.join(logo_dir, f"geeviz-logo-{variant}.png")
if not os.path.exists(logo_path):
_LOGO_CACHE[variant] = None
return None
with open(logo_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
uri = f"data:image/png;base64,{b64}"
_LOGO_CACHE[variant] = uri
return uri
# CSS is now generated dynamically by _render_report_css() from _templates.py
# ---------------------------------------------------------------------------
# Internal section container
# ---------------------------------------------------------------------------
_THUMB_KEYS = frozenset({
"thumb_format", "thumb_viz", "thumb_viz_params", "thumb_band_name",
"thumb_dimensions", "thumb_fps", "thumb_columns", "thumb_max_frames",
"thumb_burn_in_date", "thumb_date_format", "thumb_date_position",
"thumb_bg_color", "thumb_geometry", "thumb_crs", "thumb_transform",
"burn_in_legend", "legend_scale", "legend_position", "basemap",
"burn_in_geometry", "geometry_outline_color", "geometry_fill_color",
"geometry_outline_weight", "clip_to_geometry", "geometry_legend_label",
"title_font_size", "label_font_size", "overlay_opacity",
"inset_map", "inset_basemap", "inset_scale", "inset_on_map",
"scalebar", "scalebar_units", "north_arrow", "north_arrow_style",
})
# Keys used by the report layer but not by summarize_and_chart
_REPORT_KEYS = frozenset({"sankey_only", "chart_types"}) | _THUMB_KEYS
class _Section:
"""Holds config and results for one report section."""
__slots__ = (
"ee_obj", "geometry", "title", "prompt", "kwargs",
"generate_table", "generate_chart", "thumb_format",
"chart_types",
"df", "fig", "sankey_fig", "extra_figs", "narrative",
"thumb_html", "thumb_bytes", "thumb_filmstrip_html", "error",
)
def __init__(self, ee_obj, geometry, title, prompt, kwargs,
generate_table, generate_chart, thumb_format,
chart_types=None):
self.ee_obj = ee_obj
self.geometry = geometry
self.title = title
self.prompt = prompt
self.kwargs = kwargs
self.generate_table = generate_table
self.generate_chart = generate_chart
self.thumb_format = thumb_format
self.chart_types = chart_types or [] # list of chart type strings
# Populated during generate()
self.df = None
self.fig = None # primary figure (first chart type)
self.sankey_fig = None # sankey figure if "sankey" in chart_types
self.extra_figs = [] # additional figures beyond the first
self.narrative = None
self.thumb_html = None
self.thumb_bytes = None
self.thumb_filmstrip_html = None
self.error = None
# ---------------------------------------------------------------------------
# Report class
# ---------------------------------------------------------------------------
[docs]
class Report:
"""Build and generate reports from Earth Engine data.
Args:
title (str): Report title.
model (str): Gemini model name. Default ``"gemini-3-flash-preview"``.
api_key (str, optional): Google API key. If not provided, loaded from
the ``GEMINI_API_KEY`` environment variable (via ``.env``).
prompt (str, optional): Additional guidance for the executive summary.
header_text (str, optional): Introductory text shown below the title.
header_icon (str, optional): Path to a PNG/JPG image for the report
header icon. If None, uses the built-in geeViz logo (theme-aware).
Pass ``False`` to suppress the icon entirely.
theme (str): Color theme — ``"dark"`` or ``"light"``. Default ``"dark"``.
layout (str): Layout mode — ``"report"`` (portrait, vertical flow) or
``"poster"`` (landscape, multi-column grid). Default ``"report"``.
tone (str): Narrative tone for LLM-generated text. Built-in options:
``"neutral"`` (default) — data-driven, no superlatives or narrative;
``"informative"`` — accessible, explains significance;
``"technical"`` — formal, precise terminology.
Can also be a custom string with tone instructions.
max_workers (int): Thread pool size for parallel EE requests.
Default 6.
Example::
report = Report(title="My Analysis", theme="light", layout="report")
report.add_section(ee_obj=lcms, geometry=area, title="Land Cover")
html = report.generate(format="html", output_path="report.html")
"""
def __init__(self, title="Report", model=_DEFAULT_MODEL, api_key=None,
prompt=None, header_text=None, header_icon=None,
theme="dark", layout="report", tone="neutral", max_workers=6):
self.title = title
self.model = model
self.prompt = prompt
self.header_text = header_text or ""
self.header_icon = header_icon
self.theme = theme
self.layout = layout
self.tone = tone
self.max_workers = max_workers
self._sections = []
self._summary = None
self._api_key = api_key
self._client = None
self._computed_theme = None # tracks theme used during thumbnail computation
# -- Public API --------------------------------------------------------
[docs]
def add_section(self, ee_obj, geometry, title="Section", prompt=None,
generate_table=True, generate_chart=True,
thumb_format="png", chart_types=None, **kwargs):
"""Add a data section to the report.
Args:
ee_obj: ``ee.Image`` or ``ee.ImageCollection`` to summarize.
geometry: ``ee.Geometry``, ``ee.Feature``, or ``ee.FeatureCollection``.
title (str): Section heading.
prompt (str, optional): Per-section LLM guidance for the narrative.
generate_table (bool): Include data table. Default True.
generate_chart (bool): Include chart. Default True.
thumb_format (str or None): Thumbnail image format. Default
``"png"`` (static thumbnail). Options:
* ``"png"`` — single composite thumbnail (works for both
``ee.Image`` and ``ee.ImageCollection``).
* ``"gif"`` — animated GIF with date labels
(``ee.ImageCollection`` only).
* ``"filmstrip"`` — grid of individual time-step frames
(``ee.ImageCollection`` only).
* ``None`` or ``False`` — no thumbnail.
chart_types (list of str, optional): Chart types to produce
for this section. Each entry is a single chart type
string passed to ``summarize_and_chart(chart_type=...)``.
Valid values include ``"bar"``, ``"stacked_bar"``,
``"line+markers"``, ``"stacked_line+markers"``,
``"donut"``, ``"scatter"``, and ``"sankey"``.
When ``"sankey"`` is in the list, the ``sankey``,
``transition_periods``, ``sankey_band_name``, and
``min_percentage`` kwargs are used for that chart.
An empty list ``[]`` or ``None`` auto-detects a single
chart type (existing behavior). Maximum recommended
length is 3.
Examples::
chart_types=["sankey", "line+markers"]
chart_types=["bar", "donut"]
chart_types=["sankey"] # sankey only, no line chart
**kwargs: All other keyword arguments. Thumbnail params
(prefixed ``thumb_``) are extracted and forwarded to
``thumbLib``; remaining kwargs go to
``chartingLib.summarize_and_chart()``.
Thumbnail params (all optional):
``thumb_viz_params``, ``thumb_band_name``,
``thumb_dimensions``, ``thumb_fps`` (gif only),
``thumb_columns`` (filmstrip only),
``thumb_max_frames`` (gif/filmstrip),
``thumb_burn_in_date`` (gif), ``thumb_date_format``
(gif/filmstrip), ``thumb_date_position`` (gif),
``thumb_bg_color``, ``thumb_geometry`` (override
clip region), ``thumb_crs``, ``thumb_transform``,
``burn_in_legend``, ``legend_scale``.
Chart params (examples):
``stacked=True``, ``scale=60``,
``feature_label="NAME"``, etc.
Returns:
Report: self (for method chaining).
"""
# Normalize chart_types
ct_list = list(chart_types) if chart_types else []
# If "sankey" is in chart_types, auto-set sankey=True in kwargs
if any(ct.lower().strip() == "sankey" for ct in ct_list):
kwargs["sankey"] = True
self._sections.append(_Section(
ee_obj, geometry, title, prompt, kwargs,
generate_table, generate_chart, thumb_format,
chart_types=ct_list,
))
return self
[docs]
def generate(self, format="html", output_path=None):
"""Generate the report.
All section data (charts, tables, thumbnails, GIFs) and LLM
narratives are computed in parallel. Only the executive summary
waits for all sections to finish first.
Args:
format (str): Output format — ``"html"``, ``"md"``, or ``"pdf"``.
PDF uses ``kaleido`` to render charts as static PNG images
and ``pdfkit``/``wkhtmltopdf`` for the final conversion.
If wkhtmltopdf is not installed, a print-ready HTML file
with ``@page`` CSS directives is generated instead (open
in a browser and Print → Save as PDF).
output_path (str, optional): File path to write. If None, returns
the content as a string (except for PDF which always requires
a path).
Returns:
str: The report content (or the file path if ``output_path`` given).
"""
# Only compute data once; subsequent generate() calls just re-render
already_computed = any(
sec.df is not None or sec.thumb_html
for sec in self._sections
)
if not already_computed:
print(f"Generating report: {self.title}")
self._compute_all_parallel()
self._generate_executive_summary()
else:
print(f"Re-rendering report: {self.title}")
if format == "md":
content = self._render_md()
elif format == "pdf":
return self._render_pdf(output_path)
else:
content = self._render_html()
if output_path:
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
f.write(content)
print(f"Report saved to: {output_path}")
return output_path
return content
# -- Private: fully parallel computation -------------------------------
def _compute_all_parallel(self):
"""Run ALL work — EE data + LLM narratives — in a single thread pool.
Flow:
1. Submit chart/table and thumbnail tasks for every section (all run
in parallel).
2. Each data task has a done-callback. When the *last* data task for
a section finishes, its LLM narrative is submitted immediately —
no threads are wasted blocking/waiting.
3. All section narratives run in parallel as soon as their data is
ready.
4. The executive summary is generated *after* this method returns
(it needs all narratives + images).
"""
# Set theme-aware defaults for thumbnail background color
_t = _themeLib.get_theme(self.theme)
default_bg = _t.bg_hex
for sec in self._sections:
sec.kwargs.setdefault("thumb_bg_color", default_bg)
n = len(self._sections)
lock = threading.Lock()
section_pending = {} # idx -> remaining data task count
narratives_remaining = [n] # mutable counter
all_done = threading.Event() # set when every narrative finishes
with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as pool:
# -- callbacks -------------------------------------------------
def _on_narrative_done(future):
"""Called when a narrative future completes."""
try:
future.result()
except Exception as e:
print(f" Unexpected narrative error: {e}")
traceback.print_exc()
with lock:
narratives_remaining[0] -= 1
if narratives_remaining[0] == 0:
all_done.set()
def _on_section_ready(idx):
"""Submit the narrative now that all data for *idx* is done."""
sec = self._sections[idx]
nf = pool.submit(self._compute_narrative, idx, sec)
nf.add_done_callback(_on_narrative_done)
def _data_callback(idx, future):
"""Attached to every data future via add_done_callback."""
with lock:
section_pending[idx] -= 1
if section_pending[idx] == 0:
_on_section_ready(idx)
# -- submit data tasks ----------------------------------------
for i, sec in enumerate(self._sections):
futs = []
if sec.generate_chart or sec.generate_table:
futs.append(pool.submit(self._compute_chart_table, i, sec))
if sec.thumb_format:
futs.append(pool.submit(self._compute_thumb, i, sec))
with lock:
section_pending[i] = len(futs)
if not futs:
# Nothing to compute — fire narrative immediately
_on_section_ready(i)
else:
for f in futs:
f.add_done_callback(
lambda future, idx=i: _data_callback(idx, future)
)
# Block until every narrative has finished
all_done.wait()
self._computed_theme = self.theme
def _compute_chart_table(self, idx, sec):
"""Compute chart(s) and table for a single section (thread-safe).
When ``sec.chart_types`` is a non-empty list, each chart type is
produced via a separate ``summarize_and_chart`` call. The first
figure goes to ``sec.fig``, sankey figures go to ``sec.sankey_fig``,
and any extras go to ``sec.extra_figs``.
When ``sec.chart_types`` is empty, falls back to the auto-detect
behavior (single ``summarize_and_chart`` call).
"""
print(f" [{idx+1}/{len(self._sections)}] Computing chart/table: {sec.title}")
try:
chart_kwargs = {k: v for k, v in sec.kwargs.items()
if k not in _REPORT_KEYS}
# Use section title as chart title if not explicitly set
chart_kwargs.setdefault("title", sec.title)
# Determine which chart types to produce
ct_list = sec.chart_types if sec.chart_types else []
if ct_list:
# --- Explicit chart_types list ---
all_figs = [] # list of (chart_type, fig)
for ct in ct_list:
ct_lower = ct.lower().strip()
print(f" [{idx+1}/{len(self._sections)}] Computing {ct} chart: {sec.title}")
try:
if ct_lower == "sankey":
# Sankey path
sankey_kwargs = dict(chart_kwargs)
sankey_kwargs["sankey"] = True
result = cl.summarize_and_chart(
sec.ee_obj, sec.geometry, **sankey_kwargs
)
if isinstance(result, tuple) and len(result) == 3:
sankey_df, fig, matrix_dict = result
# Store sankey data (matrix_dict) as df if no df yet
if sec.df is None:
sec.df = matrix_dict
all_figs.append(("sankey", fig))
elif isinstance(result, tuple) and len(result) == 2:
if sec.df is None:
sec.df = result[0]
all_figs.append(("sankey", result[1]))
else:
# Non-sankey path: strip sankey kwargs
non_sankey_kwargs = {k: v for k, v in chart_kwargs.items()
if k not in ("sankey", "transition_periods",
"sankey_band_name", "min_percentage")}
non_sankey_kwargs["chart_type"] = ct
result = cl.summarize_and_chart(
sec.ee_obj, sec.geometry, **non_sankey_kwargs
)
if isinstance(result, tuple) and len(result) >= 2:
first, second = result[0], result[1]
if sec.df is None:
if isinstance(first, dict):
import pandas
sec.df = pandas.concat(first, names=["Feature"])
else:
sec.df = first
all_figs.append((ct, second))
except Exception as e:
print(f" {ct} chart error: {type(e).__name__}: {e}")
# Distribute figures: first non-sankey → sec.fig,
# sankey → sec.sankey_fig, rest → sec.extra_figs
for ct, fig in all_figs:
if ct.lower().strip() == "sankey":
sec.sankey_fig = fig
elif sec.fig is None:
sec.fig = fig
else:
sec.extra_figs.append(fig)
else:
# --- Legacy auto-detect path (no chart_types list) ---
result = cl.summarize_and_chart(
sec.ee_obj, sec.geometry, **chart_kwargs
)
if isinstance(result, tuple) and len(result) == 3:
# Sankey: (sankey_df, fig, matrix_dict)
sec.df = result[2]
sec.sankey_fig = result[1]
elif isinstance(result, tuple) and len(result) == 2:
first, second = result
if isinstance(first, dict):
import pandas
sec.df = pandas.concat(first, names=["Feature"])
sec.fig = second
else:
sec.df = first
sec.fig = second
except Exception as e:
sec.error = f"{type(e).__name__}: {e}"
print(f" Chart/table error: {sec.error}")
traceback.print_exc()
def _compute_thumb(self, idx, sec):
"""Compute thumbnail image for a single section (thread-safe).
Dispatches to ``generate_thumbs``, ``generate_gif``, or
``generate_filmstrip`` based on ``sec.thumb_format``.
"""
fmt = sec.thumb_format
kw = sec.kwargs
label = {"png": "thumbnail", "gif": "GIF", "filmstrip": "filmstrip"}.get(fmt, fmt)
print(f" [{idx+1}/{len(self._sections)}] Computing {label}: {sec.title}")
# Shared params (all formats)
# thumb_crs/thumb_transform override section-level crs/transform/scale
# Only pass scale/transform if crs is also provided
geom = kw.get("thumb_geometry", sec.geometry)
thumb_crs = kw.get("thumb_crs", kw.get("crs"))
shared = dict(
viz_params=kw.get("thumb_viz_params"),
band_name=kw.get("thumb_band_name"),
dimensions=kw.get("thumb_dimensions", 512),
burn_in_legend=kw.get("burn_in_legend", True),
legend_scale=kw.get("legend_scale", 1.0),
bg_color=kw.get("thumb_bg_color", "black"),
crs=thumb_crs,
transform=kw.get("thumb_transform", kw.get("transform")) if thumb_crs else None,
scale=kw.get("thumb_scale", kw.get("scale")) if thumb_crs else None,
basemap=kw.get("basemap"),
burn_in_geometry=kw.get("burn_in_geometry", False),
geometry_outline_color=kw.get("geometry_outline_color"),
geometry_fill_color=kw.get("geometry_fill_color"),
geometry_outline_weight=kw.get("geometry_outline_weight", 2),
clip_to_geometry=kw.get("clip_to_geometry", True),
title_font_size=kw.get("title_font_size", 18),
label_font_size=kw.get("label_font_size", 12),
)
try:
if fmt == "gif":
import geeViz.geeView as gv
ee_mod = gv.ee
if not isinstance(sec.ee_obj, ee_mod.ImageCollection):
print(f" Skipping GIF: ee_obj is not an ImageCollection")
return
result = tl.generate_gif(
sec.ee_obj, geom,
fps=kw.get("thumb_fps", 2),
burn_in_date=kw.get("thumb_burn_in_date", True),
date_format=kw.get("thumb_date_format", "YYYY"),
date_position=kw.get("thumb_date_position", "upper-left"),
max_frames=kw.get("thumb_max_frames", 50),
**shared,
)
sec.thumb_bytes = result.get("gif_bytes") or _extract_image_bytes(result["html"])
# Also generate a filmstrip for PDF (GIFs don't work in static PDF)
try:
print(f" [{idx+1}/{len(self._sections)}] Computing filmstrip (PDF fallback): {sec.title}")
has_legend = kw.get("burn_in_legend", True)
default_cols = 3 if has_legend else 4
fs_result = tl.generate_filmstrip(
sec.ee_obj, geom,
columns=kw.get("thumb_columns", default_cols),
date_format=kw.get("thumb_date_format", "YYYY"),
max_frames=kw.get("thumb_max_frames", 50),
legend_position=kw.get("legend_position", "bottom"),
**shared,
)
sec.thumb_filmstrip_html = fs_result["html"]
except Exception as e:
print(f" Filmstrip fallback failed: {e}")
elif fmt == "filmstrip":
import geeViz.geeView as gv
ee_mod = gv.ee
if not isinstance(sec.ee_obj, ee_mod.ImageCollection):
print(f" Skipping filmstrip: ee_obj is not an ImageCollection")
return
result = tl.generate_filmstrip(
sec.ee_obj, geom,
columns=kw.get("thumb_columns", 3),
date_format=kw.get("thumb_date_format", "YYYY"),
max_frames=kw.get("thumb_max_frames", 50),
legend_position=kw.get("legend_position", "bottom"),
**shared,
)
sec.thumb_bytes = result.get("thumb_bytes") or _extract_image_bytes(result["html"])
else: # "png" (default)
result = tl.generate_thumbs(
sec.ee_obj, geom,
**shared,
)
sec.thumb_bytes = result.get("thumb_bytes") or _extract_image_bytes(result["html"])
sec.thumb_html = result["html"]
except Exception as e:
err_msg = f"{label.title()} error: {type(e).__name__}: {e}"
print(f" {err_msg}")
if sec.error:
sec.error += f"\n{err_msg}"
else:
sec.error = err_msg
def _recompute_thumbs_for_theme(self):
"""Recompute only thumbnail/GIF/filmstrip images for the current theme.
Called when the theme has changed since the last ``_compute_all_parallel()``
so that raster assets (which have baked-in background colors) match the
new theme. Charts are handled separately by ``_themed_figure()``.
"""
_t = _themeLib.get_theme(self.theme)
new_bg = _t.bg_hex
print(f" Theme changed ({self._computed_theme} -> {self.theme}); "
f"recomputing thumbnails with bg={new_bg}")
# Update the thumb_bg_color in each section's kwargs
for sec in self._sections:
sec.kwargs["thumb_bg_color"] = new_bg
# Recompute thumbnails in parallel
sections_with_thumbs = [
(i, sec) for i, sec in enumerate(self._sections)
if sec.thumb_format and sec.thumb_html
]
if sections_with_thumbs:
with concurrent.futures.ThreadPoolExecutor(max_workers=self.max_workers) as pool:
futs = {
pool.submit(self._compute_thumb, i, sec): (i, sec)
for i, sec in sections_with_thumbs
}
for fut in concurrent.futures.as_completed(futs):
try:
fut.result()
except Exception as e:
i, sec = futs[fut]
print(f" Thumb recompute error [{i}] {sec.title}: {e}")
self._computed_theme = self.theme
def _compute_narrative(self, idx, sec):
"""Generate the LLM narrative for a single section (thread-safe).
If thumbnail or GIF images are available, they are sent to the LLM
as inline image parts so the model can describe spatial patterns.
"""
if sec.error and sec.df is None and not sec.thumb_bytes:
sec.narrative = f"*Section could not be computed: {sec.error}*"
return
print(f" [{idx+1}/{len(self._sections)}] Generating narrative: {sec.title}")
table = self._df_to_table_str(sec.df)
# Build units context from kwargs so LLM knows what the numbers mean
units_context = self._build_units_context(sec)
# Build image context description and collect image bytes
image_context, images = self._build_image_context(sec)
# Resolve tone instruction
tone_text = _TONES.get(self.tone, self.tone) if self.tone else ""
prompt = _SECTION_PROMPT_TEMPLATE.format(
title=sec.title,
tone=tone_text,
units_context=units_context,
image_context=image_context,
extra=sec.prompt or "",
table=table,
)
text = self._llm(prompt, images=images if images else None)
if text:
sec.narrative = text
else:
sec.narrative = self._fallback_narrative(sec)
@staticmethod
def _build_units_context(sec):
"""Build a units/context string for the LLM prompt from section kwargs."""
parts = []
area_fmt = sec.kwargs.get("area_format", "Percentage")
is_sankey = sec.kwargs.get("sankey", False)
scale = sec.kwargs.get("scale", 30)
if is_sankey:
periods = sec.kwargs.get("transition_periods", [])
band = sec.kwargs.get("sankey_band_name", "")
parts.append(
f"This is a Sankey/transition analysis showing land class changes "
f"between time periods: {periods}."
)
if band:
parts.append(f"The band analyzed is '{band}'.")
parts.append(
f"The data table shows a transition matrix. Values represent "
f"the area of each from-class to to-class transition in "
f"**{area_fmt.lower()}** (at {scale}m pixel scale)."
)
else:
parts.append(
f"Numeric values in the data are in **{area_fmt.lower()}** "
f"(at {scale}m pixel scale)."
)
if area_fmt == "Percentage":
parts.append("Values sum to ~100% per time step.")
elif area_fmt == "Hectares":
parts.append("Values are in hectares.")
elif area_fmt == "Acres":
parts.append("Values are in acres.")
elif area_fmt == "Pixels":
parts.append(f"Values are pixel counts (each pixel = {scale}m x {scale}m).")
ct = sec.kwargs.get("chart_type", "")
stacked = sec.kwargs.get("stacked", False)
if stacked or (isinstance(ct, str) and ct.startswith("stacked")):
parts.append("The chart is a stacked area/bar chart showing composition over time.")
return "\n".join(parts)
@staticmethod
def _build_image_context(sec):
"""Build image context text and collect image bytes for the LLM.
Returns:
tuple: (context_str, images_list) where images_list is a list
of (bytes, mime_type) tuples, or empty list if no images.
"""
if not sec.thumb_bytes:
return "", []
fmt = sec.thumb_format or "png"
mime = "image/gif" if fmt == "gif" else "image/png"
images = [(sec.thumb_bytes, mime)]
descriptions = {
"gif": (
"An animated GIF map is attached showing the spatial output "
"of this dataset over time, clipped to the study area. "
"Describe any notable spatial patterns, geographic "
"concentrations, or changes you observe across the frames."
),
"filmstrip": (
"A filmstrip grid image (PNG) is attached showing individual "
"time-step frames arranged in a grid. Each frame is labeled "
"with its date. Describe any temporal progression or spatial "
"changes visible across the frames."
),
"png": (
"A map thumbnail (PNG) is attached showing the spatial "
"output of this dataset clipped to the study area. "
"Describe any notable spatial patterns, geographic "
"concentrations, or distribution you observe."
),
}
context = descriptions.get(fmt, descriptions["png"])
return context, images
# -- Private: LLM integration ------------------------------------------
def _get_client(self):
"""Lazy-init the Gemini client."""
if self._client is not None:
return self._client
key = self._api_key
if not key:
try:
import dotenv
dotenv.load_dotenv(os.path.join(os.path.dirname(os.path.dirname(__file__)), ".env"))
except ImportError:
pass
key = os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")
if not key:
raise ValueError(
"No Gemini API key found. Pass api_key= to Report() or set "
"GEMINI_API_KEY in your environment / .env file."
)
from google import genai
self._client = genai.Client(api_key=key)
return self._client
def _llm(self, prompt_text, images=None):
"""Call Gemini and return the response text.
Args:
prompt_text (str): The text prompt.
images (list[tuple], optional): List of ``(bytes, mime_type)``
tuples for multimodal input. Each image is included as an
inline data part so the model can see thumbnails/GIFs.
"""
try:
from google.genai import types
client = self._get_client()
# Build multimodal content if images are provided
if images:
parts = [types.Part.from_text(text=prompt_text)]
for img_bytes, mime in images:
parts.append(types.Part.from_bytes(
data=img_bytes, mime_type=mime,
))
contents = [types.Content(parts=parts)]
else:
contents = prompt_text
response = client.models.generate_content(
model=self.model,
contents=contents,
config=types.GenerateContentConfig(temperature=0.0),
)
return response.text
except Exception as e:
print(f" LLM error: {e}")
return None
def _df_to_table_str(self, df, max_rows=80):
"""Convert a DataFrame or dict of DataFrames to a markdown table string."""
if df is None:
return "(no data)"
if isinstance(df, dict):
parts = []
for label, mdf in df.items():
parts.append(f"**{label}**\n{mdf.to_markdown()}")
return "\n\n".join(parts) if parts else "(no data)"
if len(df) > max_rows:
half = max_rows // 2
table = df.head(half).to_markdown() + "\n\n... (truncated) ...\n\n" + df.tail(half).to_markdown()
else:
table = df.to_markdown()
return table
def _generate_executive_summary(self):
"""Generate the executive summary from all sections.
Includes all available thumbnail and GIF images so the LLM can
reference spatial patterns in the executive summary.
"""
print(" Generating executive summary...")
briefs = []
images = []
for sec in self._sections:
if sec.narrative and sec.narrative != self._fallback_narrative(sec):
brief = sec.narrative[:200].replace("\n", " ")
briefs.append(f"**{sec.title}**: {brief}...")
elif sec.df is not None:
if isinstance(sec.df, dict):
n_matrices = len(sec.df)
briefs.append(f"**{sec.title}**: {n_matrices} transition matrices")
else:
shape = f"{len(sec.df)} rows x {len(sec.df.columns)} columns"
cols = ", ".join(sec.df.columns[:10])
briefs.append(f"**{sec.title}**: {shape}. Columns: {cols}")
elif sec.error:
briefs.append(f"**{sec.title}**: Error -- {sec.error}")
# Collect images for the executive summary
if sec.thumb_bytes:
mime = "image/gif" if sec.thumb_format == "gif" else "image/png"
images.append((sec.thumb_bytes, mime))
image_note = ""
if images:
image_note = (
"\nMap images from each section are attached. Reference "
"notable spatial patterns in your summary where relevant."
)
tone_text = _TONES.get(self.tone, self.tone) if self.tone else ""
prompt = _SUMMARY_PROMPT_TEMPLATE.format(
title=self.title,
tone=tone_text,
extra=self.prompt or "",
image_note=image_note,
section_summaries="\n".join(f"- {b}" for b in briefs),
)
text = self._llm(prompt, images=images if images else None)
self._summary = text or "Executive summary could not be generated."
@staticmethod
def _fallback_narrative(sec):
"""Simple auto-generated text when LLM is unavailable."""
if sec.df is not None:
if isinstance(sec.df, dict):
return f"This section contains {len(sec.df)} transition matrices."
return (
f"This section contains {len(sec.df)} rows of data "
f"across {len(sec.df.columns)} columns."
)
return "No data available for this section."
# -- Private: icon helpers ---------------------------------------------
def _get_icon_uri(self):
"""Return a base64 data URI (or None) for the header/footer icon.
Checks ``header_icon`` first, then falls back to the built-in geeViz
logo. Supports file paths, URLs, and raw ``data:`` URIs.
"""
icon = self.header_icon
if icon is False:
return None
if icon and isinstance(icon, str):
if icon.startswith("data:"):
return icon
# URL — fetch and embed; fall through to default on failure
if icon.startswith(("http://", "https://")):
result = self._fetch_icon_url(icon)
if result:
return result
# File path
if os.path.exists(icon):
with open(icon, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
ext = os.path.splitext(icon)[1].lower().lstrip(".")
mime = {"png": "image/png", "jpg": "image/jpeg",
"jpeg": "image/jpeg", "svg": "image/svg+xml",
"gif": "image/gif"}.get(ext, "image/png")
return f"data:{mime};base64,{b64}"
# Default: built-in geeViz logo
_t = _themeLib.get_theme(self.theme)
variant = "dark" if _t.is_dark else "light"
return _get_logo_b64(variant)
@staticmethod
def _fetch_icon_url(url):
"""Fetch a URL and return a base64 data URI, or None on failure."""
try:
import urllib.request
req = urllib.request.Request(url, headers={"User-Agent": "geeViz"})
with urllib.request.urlopen(req, timeout=10) as resp:
data = resp.read()
content_type = resp.headers.get("Content-Type", "image/png")
# Simplify content type
if "svg" in content_type:
mime = "image/svg+xml"
elif "png" in content_type:
mime = "image/png"
elif "jpeg" in content_type or "jpg" in content_type:
mime = "image/jpeg"
else:
mime = content_type.split(";")[0].strip()
b64 = base64.b64encode(data).decode()
return f"data:{mime};base64,{b64}"
except Exception as e:
print(f" Warning: Could not fetch icon from {url}: {e}")
return None
# -- Private: CSS assembly ---------------------------------------------
def _get_css(self):
"""Build the full CSS string from theme + layout."""
_t = _themeLib.get_theme(self.theme)
return _render_report_css(_t, self.layout)
# -- Private: chart theming --------------------------------------------
def _themed_figure(self, fig):
"""Return a theme-appropriate copy of a Plotly figure.
Charts from ``chartingLib`` are dark-themed by default. For light
reports this returns a deep copy with ``_apply_light_theme`` applied.
For dark reports the original figure is returned unchanged.
"""
_t = _themeLib.get_theme(self.theme)
if _t.is_dark:
return fig
import copy
fig_copy = copy.deepcopy(fig)
# Preserve custom Sankey attributes (deepcopy may drop them)
for attr in ("_gradient_color_map", "_gradient_link_opacity"):
val = getattr(fig, attr, None)
if val is not None:
setattr(fig_copy, attr, val)
_themeLib.apply_plotly_theme(fig_copy, _t)
return fig_copy
# -- Private: chart to static image ------------------------------------
def _render_subplot_images(self, fig, n_subplots, width, parts):
"""Render a multi-subplot figure as separate per-row images.
Each subplot gets its own static PNG so it can page-break
independently in PDFs instead of being squished into one image.
"""
import plotly.graph_objects as go
# Get all traces and their subplot assignments
traces_by_row = {}
for trace in fig.data:
# Subplot row is encoded in yaxis: "y", "y2", "y3", etc.
yaxis = getattr(trace, "yaxis", "y") or "y"
row = int(yaxis.replace("y", "") or "1")
traces_by_row.setdefault(row, []).append(trace)
# Get layout info for title
title = ""
if fig.layout.title and fig.layout.title.text:
title = fig.layout.title.text
per_h = 300 # height per subplot row
sorted_rows = sorted(traces_by_row.keys())
for i, row_key in enumerate(sorted_rows):
traces = traces_by_row[row_key]
# Build a standalone figure for this subplot
sub_fig = go.Figure()
for trace in traces:
# Reset yaxis/xaxis to default for standalone figure
new_trace = trace.to_plotly_json()
new_trace.pop("xaxis", None)
new_trace.pop("yaxis", None)
sub_fig.add_trace(go.Scatter(**{
k: v for k, v in new_trace.items()
if k != "type" and v is not None
}))
# Get subplot title from annotations
sub_title = ""
for ann in (fig.layout.annotations or []):
ann_dict = ann.to_plotly_json()
# Subplot titles are positioned by yref
if ann_dict.get("text"):
# Check if this annotation belongs to this row
y = ann_dict.get("y", 0)
expected_y_range = (1 - (i + 0.5) / n_subplots)
if abs(y - expected_y_range) < 0.5 / n_subplots:
sub_title = ann_dict["text"]
break
# Apply theme
_t = _themeLib.get_theme(self.theme)
chart_title = sub_title or (title if i == 0 else "")
sub_fig.update_layout(
title=dict(text=chart_title, x=0.5) if chart_title else None,
height=per_h, width=width,
paper_bgcolor=_t.bg_hex,
plot_bgcolor=_t.bg_hex,
font=dict(color=_t.text_hex),
margin=dict(l=60, r=20, t=40 if chart_title else 20, b=40),
showlegend=(i == 0),
)
if i == 0 and fig.layout.legend:
sub_fig.update_layout(legend=fig.layout.legend.to_plotly_json())
img_uri = self._fig_to_static_img(sub_fig, width=width, height=per_h)
if img_uri:
parts.append(
f'<div class="chart"><img src="{img_uri}" '
f'style="max-width:100%;border-radius:6px;"></div>'
)
@staticmethod
def _fig_to_static_img(fig, width=900, height=500):
"""Render a Plotly figure to a base64 PNG data URI using kaleido.
Returns None if kaleido is not available.
"""
if fig is None:
return None
try:
import plotly.io as pio
img_bytes = pio.to_image(fig, format="png", width=width, height=height)
b64 = base64.b64encode(img_bytes).decode()
return f"data:image/png;base64,{b64}"
except Exception as e:
print(f" Static chart export failed: {e}")
return None
def _sankey_to_static_img(self, fig, width=900, height=600):
"""Render a Sankey figure to a base64 PNG via Chrome headless screenshot.
This preserves the D3 SVG gradient link colors that kaleido cannot
render. Falls back to kaleido if no browser is available.
Returns a ``data:image/png;base64,...`` URI or None.
"""
import subprocess
import tempfile
browser = _find_browser()
if not browser:
return self._fig_to_static_img(fig, width=width, height=height)
try:
_t = _themeLib.get_theme(self.theme)
sankey_html = cl.sankey_to_html(
fig, bg_color=_t.bg_hex, font_color=_t.text_hex,
renderer="d3", hide_toolbar=True,
)
tmp_dir = tempfile.mkdtemp(prefix="geeviz_sankey_")
tmp_html = os.path.join(tmp_dir, "sankey.html")
tmp_png = os.path.join(tmp_dir, "sankey.png")
with open(tmp_html, "w", encoding="utf-8") as f:
f.write(sankey_html)
# Use file:// URI and --virtual-time-budget so D3 JS fully executes
file_uri = "file:///" + tmp_html.replace(os.sep, "/")
cmd = [
browser, "--headless", "--disable-gpu",
f"--screenshot={tmp_png}",
f"--window-size={width},{height}",
"--hide-scrollbars",
"--virtual-time-budget=5000",
file_uri,
]
result = subprocess.run(cmd, capture_output=True, timeout=30)
if result.returncode == 0 and os.path.exists(tmp_png) and os.path.getsize(tmp_png) > 1000:
with open(tmp_png, "rb") as f:
img_bytes = f.read()
b64 = base64.b64encode(img_bytes).decode()
# Cleanup
os.remove(tmp_png)
os.remove(tmp_html)
os.rmdir(tmp_dir)
return f"data:image/png;base64,{b64}"
else:
print(f" Sankey screenshot failed, falling back to kaleido")
# Cleanup
for p in (tmp_png, tmp_html):
if os.path.exists(p):
os.remove(p)
os.rmdir(tmp_dir)
return self._fig_to_static_img(fig, width=width, height=height)
except Exception as e:
print(f" Sankey screenshot error: {e}, falling back to kaleido")
return self._fig_to_static_img(fig, width=width, height=height)
def _gif_to_filmstrip_html(self, sec):
"""Render a GIF section as a filmstrip grid for PDF output.
GIFs don't render in static PDFs, so we generate a filmstrip
(grid of PNG thumbnails) on the fly using the same parameters.
"""
import geeViz.geeView as gv
ee_mod = gv.ee
if not isinstance(sec.ee_obj, ee_mod.ImageCollection):
return sec.thumb_html # fallback to original
kw = sec.kwargs
geom = kw.get("thumb_geometry", sec.geometry)
shared = dict(
viz_params=kw.get("thumb_viz_params"),
band_name=kw.get("thumb_band_name"),
dimensions=kw.get("thumb_dimensions", 512),
burn_in_legend=kw.get("burn_in_legend", True),
legend_scale=kw.get("legend_scale", 1.0),
bg_color=kw.get("thumb_bg_color", "black"),
basemap=kw.get("basemap"),
)
try:
result = tl.generate_filmstrip(
sec.ee_obj, geom,
columns=kw.get("thumb_columns", 4),
date_format=kw.get("thumb_date_format", "YYYY"),
max_frames=kw.get("thumb_max_frames", 50),
crs=kw.get("thumb_crs"),
transform=kw.get("thumb_transform"),
**shared,
)
return result["html"]
except Exception as e:
print(f" GIF-to-filmstrip fallback failed: {e}")
return sec.thumb_html # fallback to original GIF HTML
# -- Private: section HTML builder (shared by HTML and PDF) ------------
def _render_section_html(self, sec, static_charts=False):
"""Render a single section to an HTML fragment.
Args:
sec: _Section instance.
static_charts: If True, render charts as static PNG images
(for PDF). If False, use interactive Plotly JS (for HTML).
"""
import markdown
parts = [f'<div class="section">', f'<h2>{sec.title}</h2>']
# Narrative first — fix inline `* ` markers to proper markdown list items
narrative_md = sec.narrative or ""
import re as _re
narrative_md = _re.sub(r'(?<=[.:]) \* ', r'\n* ', narrative_md)
parts.append(
f'<div class="narrative">{markdown.markdown(narrative_md)}</div>'
)
# Thumbnail / GIF / filmstrip image
if sec.thumb_format and sec.thumb_html:
if static_charts and sec.thumb_format == "gif":
# GIFs don't work in PDF — use pre-computed filmstrip grid
parts.append(sec.thumb_filmstrip_html or sec.thumb_html)
else:
parts.append(sec.thumb_html)
# Charts — render sankey_fig, fig, and any extra_figs
figs_to_render = []
if sec.sankey_fig is not None:
figs_to_render.append(sec.sankey_fig)
if sec.generate_chart and sec.fig is not None:
figs_to_render.append(sec.fig)
for extra in (sec.extra_figs or []):
figs_to_render.append(extra)
for fig_obj in figs_to_render:
# D3 sankey: already an HTML string (from chart_sankey_d3)
if isinstance(fig_obj, str):
parts.append(
f'<div class="chart"><iframe srcdoc="{_escape_attr(fig_obj)}" '
f'style="width:100%;height:{sec.kwargs.get("height", 600) + 50}px;border:none;overflow:hidden;"></iframe></div>'
)
continue
fig_to_render = self._themed_figure(fig_obj)
is_sankey = hasattr(fig_to_render, "_gradient_color_map")
if static_charts:
w = sec.kwargs.get("width", 900)
if is_sankey:
h = sec.kwargs.get("height", 650)
img_uri = self._sankey_to_static_img(
fig_to_render, width=w, height=h,
)
if img_uri:
parts.append(
f'<div class="chart"><img src="{img_uri}" '
f'style="max-width:100%;border-radius:6px;"></div>'
)
else:
# Use figure's own height for multi-subplot charts
fig_h = fig_to_render.layout.height or 500
h = sec.kwargs.get("height", fig_h)
img_uri = self._fig_to_static_img(
fig_to_render, width=w, height=h,
)
if img_uri:
# For tall charts, don't constrain width (let it scroll/flow)
style = 'width:100%;border-radius:6px;' if h > 800 else 'max-width:100%;border-radius:6px;'
parts.append(
f'<div class="chart"><img src="{img_uri}" '
f'style="{style}"></div>'
)
else:
if is_sankey:
_th = _themeLib.get_theme(self.theme)
chart_html = cl.sankey_to_html(
fig_to_render,
bg_color=_th.bg_hex,
font_color=_th.text_hex,
theme=self.theme,
)
parts.append(
f'<div class="chart"><iframe srcdoc="{_escape_attr(chart_html)}" '
f'style="width:100%;height:{sec.kwargs.get("height", 600) + 50}px;border:none;overflow:hidden;"></iframe></div>'
)
else:
# Scale height for multi-subplot figures
n_rows = sum(1 for k in fig_to_render.layout.to_plotly_json()
if k.startswith("yaxis"))
if n_rows > 1 and (fig_to_render.layout.height or 0) < 200 * n_rows:
fig_to_render.update_layout(height=200 * n_rows)
chart_div = fig_to_render.to_html(
full_html=False, include_plotlyjs=False,
config=cl._plotly_download_config(fig_to_render),
)
parts.append(f'<div class="chart">{chart_div}</div>')
# Table
if sec.generate_table and sec.df is not None:
area_fmt = sec.kwargs.get("area_format", "Percentage")
units_label = cl.AREA_FORMAT_DICT.get(area_fmt, {}).get("label", area_fmt)
is_sankey = sec.kwargs.get("sankey", False)
if isinstance(sec.df, dict):
for period_label, mdf in sec.df.items():
table_html = self._render_transition_matrix(
mdf, period_label, units_label=units_label,
)
parts.append(table_html)
else:
# Build descriptive table title: "Section Title - Annual units"
table_title = f'{sec.title} — Annual {units_label}'
title_html = f'<h4 class="matrix-title">{table_title}</h4>'
table_html = sec.df.to_html(classes="", border=0, max_rows=100)
parts.append(f'<div class="table-wrapper">{title_html}{table_html}</div>')
if sec.error:
parts.append(f'<div class="error">{sec.error}</div>')
parts.append('</div>')
return "\n".join(parts)
@staticmethod
def _render_transition_matrix(mdf, period_label, units_label=""):
"""Render a transition matrix DataFrame as HTML with diagonal highlighting.
Adds "From (year)" row header and "To (year)" column header labels,
and highlights the diagonal cells (same-class persistence).
"""
# Parse years from period_label like "1990 → 2024"
parts_label = period_label.split("\u2192")
from_year = parts_label[0].strip() if len(parts_label) == 2 else ""
to_year = parts_label[1].strip() if len(parts_label) == 2 else ""
class_names = list(mdf.index)
col_names = list(mdf.columns)
rows = []
# Header row — rows=from, columns=to
corner = f'{from_year} (rows) | {to_year} (columns)'
hdr = f'<tr><th class="matrix-corner">{corner}</th>'
for c in col_names:
hdr += f"<th>{c}</th>"
hdr += "</tr>"
rows.append(hdr)
# Data rows with diagonal highlight
for ri, rname in enumerate(class_names):
row = f'<tr><th>{rname}</th>'
for ci, cname in enumerate(col_names):
val = mdf.iloc[ri, ci]
css_class = ' class="diag"' if ri == ci else ""
row += f"<td{css_class}>{val}</td>"
row += "</tr>"
rows.append(row)
units_html = f'<p class="table-units">Values in {units_label}</p>' if units_label else ""
table = (
f'<div class="table-wrapper">'
f'<h4 class="matrix-title">{period_label}</h4>'
f'{units_html}'
f'<table class="transition-matrix" border="0">'
f'<thead>{rows[0]}</thead>'
f'<tbody>{"".join(rows[1:])}</tbody>'
f'</table></div>'
)
return table
# -- Private: rendering ------------------------------------------------
def _build_header_footer(self):
"""Build the header block, header text, and footer HTML."""
import markdown # noqa: F811
icon_uri = self._get_icon_uri()
if icon_uri:
header_block = (
f'<div class="report-header">'
f'<img src="{icon_uri}" alt="Logo">'
f'<h1>{self.title}</h1>'
f'</div>'
)
else:
header_block = f'<h1>{self.title}</h1>'
header_text_html = (
f'<p class="header-text">{self.header_text}</p>'
if self.header_text else ""
)
summary_md = self._summary or ""
summary_html = (
'<div class="summary">'
'<h3>Executive Summary</h3>'
f'{markdown.markdown(summary_md)}'
'</div>'
)
footer_parts = ['<div class="report-footer">']
if icon_uri:
footer_parts.append(f'<img src="{icon_uri}" alt="geeViz">')
footer_parts.append(
f'Powered by: '
f'<a href="https://geeviz.org/">geeViz.reportLib</a> | '
f'<a href="https://gemini.google.com/">Gemini</a> {self.model} | '
f'<a href="https://earthengine.google.com/">Earth Engine</a>'
)
footer_parts.append('</div>')
footer_html = "\n".join(footer_parts)
return header_block, header_text_html, summary_html, footer_html
def _render_html(self):
"""Assemble the full interactive HTML report."""
css = self._get_css()
header_block, header_text_html, summary_html, footer_html = (
self._build_header_footer()
)
section_parts = [
self._render_section_html(sec, static_charts=False)
for sec in self._sections
]
# Wrap sections in poster grid if poster layout
if self.layout == "poster":
sections_html = (
'<div class="poster-grid">\n'
+ "\n\n".join(section_parts)
+ '\n</div>'
)
else:
sections_html = "\n\n".join(section_parts)
return _HTML_TEMPLATE.format(
title=self.title,
css=css,
header_block=header_block,
header_text_html=header_text_html,
timestamp=datetime.now().strftime("%B %d, %Y at %I:%M %p"),
summary_html=summary_html,
sections_html=sections_html,
footer_html=footer_html,
)
def _render_md(self):
"""Render the report as Markdown (no charts, no thumbnails)."""
lines = [f"# {self.title}", ""]
if self.header_text:
lines += [self.header_text, ""]
lines += [
"## Executive Summary", "",
self._summary or "*(not generated)*", "",
]
for sec in self._sections:
lines += [f"## {sec.title}", ""]
if sec.narrative:
lines += [sec.narrative, ""]
if sec.generate_table and sec.df is not None:
if isinstance(sec.df, dict):
for label, mdf in sec.df.items():
lines += [f"### {label}", mdf.to_markdown(), ""]
else:
lines += [sec.df.to_markdown(), ""]
if sec.error:
lines += [f"**Error:** {sec.error}", ""]
lines.append("---\n")
lines.append("")
lines.append(
f"*Generated using [geeViz](https://geeviz.org/) reportLib"
f" and {self.model} for output summaries.*"
)
return "\n".join(lines)
def _render_pdf(self, output_path):
"""Render the report as PDF.
Uses kaleido to render Plotly charts as static PNG images, then
converts to PDF using (in order of preference):
1. Edge/Chrome headless ``--print-to-pdf`` (available on most systems)
2. ``pdfkit`` + ``wkhtmltopdf``
3. Fallback: saves a print-ready HTML with ``@page`` CSS directives
"""
import shutil
import subprocess
import tempfile
if not output_path:
output_path = self.title.replace(" ", "_") + ".pdf"
os.makedirs(os.path.dirname(os.path.abspath(output_path)), exist_ok=True)
# Use the theme that was active when thumbnails/GIFs were computed so
# the CSS background matches the baked-in raster backgrounds.
render_theme = self._computed_theme or self.theme
saved_theme = self.theme
self.theme = render_theme
try:
return self._render_pdf_inner(output_path)
finally:
self.theme = saved_theme
def _render_pdf_inner(self, output_path):
"""Internal PDF rendering (called by _render_pdf with theme set)."""
import shutil
import subprocess
import tempfile
# Build HTML with static chart images (no JS needed)
_t = _themeLib.get_theme(self.theme)
css = self._get_css()
# PDF overrides: full-bleed background, proper page breaks
css += textwrap.dedent(f"""
html, body {{
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}}
@page {{
margin: 0;
size: letter portrait;
}}
body {{
padding: 0.4in 0.5in 0.4in 0.5in;
}}
.report-header {{
margin: -0.4in -0.5in 16px -0.5in;
padding: 12px 0.5in;
}}
.table-wrapper {{
max-height: none !important;
overflow-y: visible !important;
overflow-x: visible !important;
}}
table {{ font-size: 9px; }}
thead {{ display: table-header-group; }}
tr {{ page-break-inside: avoid; }}
h2 {{ page-break-after: avoid; }}
.narrative {{ page-break-before: avoid; }}
.filmstrip img {{ page-break-inside: avoid; }}
.matrix-title {{ page-break-after: avoid; }}
.table-units {{ page-break-after: avoid; }}
.table-wrapper {{ page-break-before: auto; }}
.report-footer {{ display: none; }}
.pdf-footer-tpl {{ display: none; }}
.pdf-page-footer {{
display: flex; align-items: center; gap: 8px;
position: fixed; bottom: 0; left: 0.5in; right: 0.5in;
padding: 4px 0;
border-top: 1px solid {_t.border_hex};
color: {_t.muted_text_hex}; font-size: 9px;
background: {_t.bg_hex};
z-index: 100;
}}
.pdf-page-footer a {{ color: {_t.accent_hex}; text-decoration: none; }}
.pdf-page-footer img {{ height: 16px; width: auto; max-width: 80px;
border-radius: 3px; opacity: 0.7; }}
.pdf-page-footer .page-num {{ margin-left: auto; }}
/* Ensure content doesn't get hidden behind fixed footer */
body {{ padding-bottom: 40px; }}
""")
header_block, header_text_html, summary_html, footer_html = (
self._build_header_footer()
)
section_parts = [
self._render_section_html(sec, static_charts=True)
for sec in self._sections
]
if self.layout == "poster":
sections_html = (
'<div class="poster-grid">\n'
+ "\n\n".join(section_parts)
+ '\n</div>'
)
else:
sections_html = "\n\n".join(section_parts)
# Build PDF footer template (hidden) + JS to clone per page
icon_uri = self._get_icon_uri()
icon_img = f'<img src="{icon_uri}" alt="geeViz"> ' if icon_uri else ''
footer_content = (
f'{icon_img}'
f'Powered by: '
f'<a href="https://geeviz.org/">geeViz.reportLib</a> | '
f'<a href="https://gemini.google.com/">Gemini</a> {self.model} | '
f'<a href="https://earthengine.google.com/">Earth Engine</a>'
f'<span class="page-num"></span>'
)
# Fixed footer: position:fixed repeats on every printed page
pdf_footer_html = (
f'<div class="pdf-page-footer">{footer_content}</div>'
)
html = _PDF_HTML_TEMPLATE.format(
title=self.title,
css=css,
header_block=header_block,
header_text_html=header_text_html,
timestamp=datetime.now().strftime("%B %d, %Y at %I:%M %p"),
summary_html=summary_html,
sections_html=sections_html,
footer_html=footer_html,
pdf_footer_html=pdf_footer_html,
)
# Strategy 1: Edge or Chrome headless --print-to-pdf
browser = _find_browser()
if browser:
try:
tmp_html = os.path.join(tempfile.gettempdir(), "geeviz_report_tmp.html")
with open(tmp_html, "w", encoding="utf-8") as f:
f.write(html)
# Build the command
abs_pdf = os.path.abspath(output_path)
cmd = [
browser, "--headless", "--disable-gpu",
f"--print-to-pdf={abs_pdf}",
"--no-pdf-header-footer",
"--virtual-time-budget=3000",
]
if self.layout == "poster":
# 48x36 inch poster
cmd.append("--print-to-pdf-no-header")
cmd.append(tmp_html)
result = subprocess.run(cmd, capture_output=True, timeout=120)
os.remove(tmp_html)
if result.returncode == 0 and os.path.exists(abs_pdf):
print(f"PDF saved to: {output_path}")
return output_path
else:
stderr = result.stderr.decode(errors="replace")[:200]
print(f" Browser PDF failed: {stderr}")
except Exception as e:
print(f" Browser PDF failed: {e}")
# Strategy 2: pdfkit + wkhtmltopdf
try:
import pdfkit
orientation = "Landscape" if self.layout == "poster" else "Portrait"
options = {
"page-size": "Letter",
"orientation": orientation,
"margin-top": "0.4in",
"margin-right": "0.4in",
"margin-bottom": "0.4in",
"margin-left": "0.4in",
"encoding": "UTF-8",
"enable-local-file-access": "",
}
pdfkit.from_string(html, output_path, options=options)
print(f"PDF saved to: {output_path}")
return output_path
except ImportError:
pass
except Exception as e:
print(f" pdfkit failed: {e}")
# Strategy 3: Fallback — save print-ready HTML
fallback = output_path.rsplit(".", 1)[0] + "_printable.html"
with open(fallback, "w", encoding="utf-8") as f:
f.write(html)
print(
f"PDF conversion not available.\n"
f"Print-ready HTML saved to: {fallback}\n"
f"Open in a browser -> Print -> Save as PDF"
)
return fallback
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _find_browser():
"""Locate Edge or Chrome executable for headless PDF rendering.
Returns the path string if found, otherwise None.
"""
import shutil
# Check PATH first (works cross-platform)
for name in ("msedge", "microsoft-edge", "google-chrome", "chrome", "chromium"):
path = shutil.which(name)
if path:
return path
# Common install locations by platform
import sys
if sys.platform == "win32":
candidates = [
os.path.expandvars(r"%ProgramFiles(x86)%\Microsoft\Edge\Application\msedge.exe"),
os.path.expandvars(r"%ProgramFiles%\Microsoft\Edge\Application\msedge.exe"),
os.path.expandvars(r"%ProgramFiles%\Google\Chrome\Application\chrome.exe"),
os.path.expandvars(r"%ProgramFiles(x86)%\Google\Chrome\Application\chrome.exe"),
os.path.expandvars(r"%LOCALAPPDATA%\Google\Chrome\Application\chrome.exe"),
]
elif sys.platform == "darwin":
candidates = [
"/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
]
else: # Linux
candidates = [
"/usr/bin/microsoft-edge",
"/usr/bin/google-chrome",
"/usr/bin/chromium-browser",
"/usr/bin/chromium",
]
for c in candidates:
if os.path.isfile(c):
return c
return None
def _extract_image_bytes(html_str):
"""Extract raw image bytes from a base64-embedded HTML img tag.
Looks for ``src="data:image/...;base64,..."`` in the HTML and returns
the decoded bytes. Returns None if no base64 image found.
"""
import re
m = re.search(r'src="data:image/[^;]+;base64,([^"]+)"', html_str)
if m:
return base64.b64decode(m.group(1))
return None
def _escape_attr(html):
"""Escape HTML for use inside an attribute value (srcdoc)."""
return html.replace("&", "&").replace('"', """).replace("<", "<").replace(">", ">")