Skip to content
Binary file not shown.
15 changes: 15 additions & 0 deletions sample/templates/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# Brand template generation sample

This folder contains the contributed assets for the brand template generation scenario.

## Contents

| Path | Description |
|---|---|
| `PPTWORDTemplateGenerator_1_0_0_2.zip` | Power Platform solution package for the template generation scenario. |
| `scripts/templatize_docx.py` | Python helper that converts editable Word document text into named placeholders while preserving document structure and formatting. |

## Setup

1. Import `PPTWORDTemplateGenerator_1_0_0_2.zip` into a Power Platform environment with Copilot Studio on the **Early Release** channel.
2. Turn on Enhanced Task Completion in the agent's Generative AI settings.
156 changes: 156 additions & 0 deletions sample/templates/scripts/templatize_docx.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""
Convert a Word document into a template by replacing every shape's and
paragraph's text with a {{name}} placeholder. Preserves run formatting,
table structure, text boxes, and content controls (SDTs).

Walks:
- Body paragraphs and tables
- Text boxes inside drawings (w:txbxContent)
- Structured Document Tags / content controls (w:sdt)
- Headers and footers

Usage:
python templatize_docx.py input.docx output.docx
"""

import re
import sys

from docx import Document
from docx.oxml.ns import qn

W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"


def slugify(name: str) -> str:
name = re.sub(r"[^A-Za-z0-9]+", "_", name or "field").strip("_").lower()
return name or "field"


class PlaceholderNamer:
def __init__(self):
self.seen: dict[str, int] = {}

def next(self, raw_name: str) -> str:
base = slugify(raw_name)
count = self.seen.get(base, 0) + 1
self.seen[base] = count
return base if count == 1 else f"{base}_{count}"


def paragraph_text(p_elem) -> str:
parts = []
for t in p_elem.iter(qn("w:t")):
parts.append(t.text or "")
return "".join(parts)


def replace_paragraph_text(p_elem, token: str) -> None:
"""Replace paragraph text with {{token}}, keeping the first run's formatting."""
runs = p_elem.findall(qn("w:r"))
if not runs:
# No runs -- create a minimal run.
r = p_elem.makeelement(qn("w:r"), {})
t = p_elem.makeelement(qn("w:t"), {})
t.text = f"{{{{{token}}}}}"
r.append(t)
p_elem.append(r)
return

first_run = runs[0]

# Remove all but the first run.
for r in runs[1:]:
p_elem.remove(r)

# Inside the first run, drop everything except w:rPr (formatting), then add a fresh w:t.
rPr = first_run.find(qn("w:rPr"))
for child in list(first_run):
if child.tag != qn("w:rPr"):
first_run.remove(child)
if rPr is None:
# rPr was absent -- nothing extra to do.
pass

t = first_run.makeelement(qn("w:t"), {"{http://www.w3.org/XML/1998/namespace}space": "preserve"})
t.text = f"{{{{{token}}}}}"
first_run.append(t)


def container_name(elem) -> str:
"""Best-effort name for a container (text box, sdt, etc.)."""
# Text boxes: the parent drawing has a wp:docPr / wps:cNvPr with a name attr.
parent = elem.getparent()
while parent is not None:
for child in parent.iter():
tag = child.tag.split("}")[-1]
if tag in ("docPr", "cNvPr") and child.get("name"):
return child.get("name")
parent = parent.getparent() if hasattr(parent, "getparent") else None
# Don't walk forever.
break

# SDT alias / tag.
sdtPr = elem.find(qn("w:sdtPr"))
if sdtPr is not None:
alias = sdtPr.find(qn("w:alias"))
if alias is not None and alias.get(qn("w:val")):
return alias.get(qn("w:val"))
tag = sdtPr.find(qn("w:tag"))
if tag is not None and tag.get(qn("w:val")):
return tag.get(qn("w:val"))
return ""


def process_paragraphs_in(scope_elem, namer: PlaceholderNamer, default_prefix: str = "field") -> int:
"""Walk every w:p inside scope_elem and replace its text if non-empty."""
count = 0
for p in scope_elem.iter(qn("w:p")):
text = paragraph_text(p).strip()
if not text:
continue

# Try to derive a name from the nearest ancestor container.
name = ""
ancestor = p.getparent()
while ancestor is not None:
tag = ancestor.tag.split("}")[-1]
if tag in ("txbxContent", "sdtContent", "tc"):
name = container_name(ancestor.getparent() if tag == "sdtContent" else ancestor)
if name:
break
ancestor = ancestor.getparent() if hasattr(ancestor, "getparent") else None

token = namer.next(name or default_prefix)
replace_paragraph_text(p, token)
count += 1
return count


def templatize(src: str, dst: str) -> None:
doc = Document(src)
namer = PlaceholderNamer()
total = 0

# Body
total += process_paragraphs_in(doc.element.body, namer, default_prefix="body")

# Headers and footers
for section in doc.sections:
for hf in (section.header, section.footer,
section.first_page_header, section.first_page_footer,
section.even_page_header, section.even_page_footer):
try:
total += process_paragraphs_in(hf._element, namer, default_prefix="header_footer")
except Exception:
continue

doc.save(dst)
print(f"Done. {total} placeholder(s) written to {dst}")


if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: python templatize_docx.py input.docx output.docx")
sys.exit(1)
templatize(sys.argv[1], sys.argv[2])
11 changes: 10 additions & 1 deletion src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,15 @@ const base = import.meta.env.BASE_URL;
href={`${base}scenarios/conversational-guidance`}
image={`${base}images/scenario-reasoning.png`}
/>

<ScenarioCard
title="Brand Template Generation"
capability="Template-Aware Generation"
description="The agent preserves a customer's brand-styled PowerPoint or Word layout, fills placeholders from context, and returns the file without regenerating the design."
href={`${base}scenarios/brand-template-generation`}
image={`${base}images/scenario-files.png`}
badge="Sample assets"
/>
</div>
</div>
</section>
Expand Down Expand Up @@ -531,7 +540,7 @@ const base = import.meta.env.BASE_URL;
}
.hero__subtitle {
font-size: 0.95rem;
}
}
.hero__actions {
flex-direction: column;
}
Expand Down
193 changes: 193 additions & 0 deletions src/pages/scenarios/brand-template-generation.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
---
import Base from '../../layouts/Base.astro';

const base = import.meta.env.BASE_URL;
---

<Base title="Brand Template Generation | Enhanced Task Completion">
<article class="scenario">
<div class="container">
<a href={base} class="back">&larr; All scenarios</a>

<header class="scenario__header">
<span class="badge badge--primary">Template-Aware Generation</span>
<h1>Brand Template Generation</h1>
<p class="scenario__lead">
The agent reads a brand-styled PowerPoint or Word template, preserves its
shapes, logos, and formatting, and fills placeholders with contextual content
from the user's request and prior tool calls.
</p>
</header>

<section class="convo">
<h2>The conversation</h2>
<p class="convo__intro">
Use this with a brand-approved template when the source file's layout, logos,
and formatting must stay intact.
</p>

<ol class="timeline">
<li class="tl-user">
<div class="tl-marker">1</div>
<div class="tl-content">
<div class="tl-label">You</div>
<div class="try-prompt">
<blockquote class="try-prompt__text">
Here's our standard customer QBR deck. Identify the shapes and logos
that should be preserved and turn the editable text into placeholders.
</blockquote>
</div>
</div>
</li>
<li class="tl-agent">
<div class="tl-marker"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
<div class="tl-content">
<div class="tl-label">Agent</div>
<div class="tl-steps">
<div class="tl-tool"><span class="tl-tool-name">templatize_*</span> preserves decorative shapes, logos, images, and layout</div>
<p>Replaces editable text frames with named placeholders such as <code>{'{{exec_summary}}'}</code> and <code>{'{{section_1_title}}'}</code>.</p>
</div>
</div>
</li>
<li class="tl-user">
<div class="tl-marker">2</div>
<div class="tl-content">
<div class="tl-label">You</div>
<div class="try-prompt">
<blockquote class="try-prompt__text">
Using this templatized deck, generate a Q1 review for Contoso. The
exec summary should pull from the email thread I shared, and the
metrics slide should use the figures from the attached spreadsheet.
</blockquote>
</div>
</div>
</li>
<li class="tl-agent">
<div class="tl-marker"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg></div>
<div class="tl-content">
<div class="tl-label">Agent</div>
<div class="tl-steps">
<p>Reads the placeholder list, determines which values can be inferred, and asks only for missing details.</p>
<div class="tl-tool"><span class="tl-tool-name">fill_template</span> writes placeholder values back in place</div>
<p>Returns the finished file with the original brand system intact.</p>
</div>
</div>
</li>
</ol>
</section>

<section class="scenario__section">
<h2>Why this matters</h2>
<p class="scenario__body">
Most "make me a deck" agents regenerate slides from scratch and lose the
customer's brand system in the process: fonts shift, logos move, footers
disappear, and color palettes drift. This scenario keeps the original file as
the source of truth and only writes into the spots that are meant to change.
</p>
</section>

<section class="scenario__section">
<h2>Setup</h2>
<div class="value-grid">
<div class="value-card">
<h3>Import the solution</h3>
<p>
Import <code>sample/templates/PPTWORDTemplateGenerator_1_0_0_2.zip</code>
into a Power Platform environment with Copilot Studio on the
<strong>Early Release</strong> channel.
</p>
</div>
<div class="value-card">
<h3>Add the scripts</h3>
<p>
Use the scripts under <code>sample/templates/scripts/</code> as the
template-processing tools. Add any companion fill script from the
contributed package before running the end-to-end flow.
</p>
</div>
<div class="value-card">
<h3>Enable ETC</h3>
<p>
Turn on Enhanced Task Completion in the agent's Generative AI settings so
the agent can plan across placeholder discovery, clarifying questions, and
final file generation.
</p>
</div>
</div>
</section>

<section class="scenario__section">
<h2>ETC capabilities demonstrated</h2>
<div class="value-grid">
<div class="value-card">
<h3>Preserve brand fidelity</h3>
<p>Shapes, images, logos, headers, footers, fonts, and layout stay untouched while editable text is replaced with structured placeholders.</p>
</div>
<div class="value-card">
<h3>Ask only when needed</h3>
<p>The agent decides which placeholders require clarifying questions and which can be filled from context, files, or tool calls.</p>
</div>
<div class="value-card">
<h3>Chain file tools</h3>
<p>Templatizing and filling are separate steps, letting the agent pass a placeholder map forward without regenerating the whole document.</p>
</div>
</div>
</section>

<section class="scenario__section">
<h2>Variants</h2>
<div class="value-grid">
<div class="value-card">
<h3>Cowork + Skills</h3>
<p>Run the same pipeline as a Cowork skill if you prefer that surface. The host changes, but the template-preserving flow is identical.</p>
</div>
<div class="value-card">
<h3>Word documents</h3>
<p>Use the same flow for <code>.docx</code> files to preserve headers, footers, logos, tables, and body formatting.</p>
</div>
</div>
</section>

<section class="scenario__section">
<h2>Credit</h2>
<p class="scenario__body">Contributed by Betty Le (Microsoft, Cloud Solution Architect).</p>
</section>
</div>
</article>
</Base>

<style>
.scenario { padding: var(--space-3xl) 0; }
.back { display: inline-flex; align-items: center; gap: 4px; font-size: 0.85rem; font-weight: 500; color: var(--c-text-muted); margin-bottom: var(--space-xl); }
.back:hover { color: var(--c-primary); }
.scenario__header { max-width: 680px; }
.scenario__header h1 { margin-top: var(--space-md); }
.scenario__lead { margin-top: var(--space-lg); font-size: 1.05rem; color: var(--c-text-secondary); line-height: 1.7; }
.scenario__section { margin-top: var(--space-3xl); }
.scenario__section h2 { margin-bottom: var(--space-lg); }
.scenario__body { max-width: 760px; color: var(--c-text-secondary); line-height: 1.7; }

.convo { margin-top: var(--space-3xl); }
.convo h2 { margin-bottom: var(--space-sm); }
.convo__intro { color: var(--c-text-secondary); margin-bottom: var(--space-xl); font-size: 0.92rem; }
.timeline { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 2px; }
.timeline li { display: grid; grid-template-columns: 40px 1fr; gap: var(--space-md); padding: var(--space-md) 0; }
.tl-marker { width: 36px; height: 36px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; font-size: 0.78rem; font-weight: 700; }
.tl-user .tl-marker { background: var(--c-primary); color: white; }
.tl-agent .tl-marker { background: var(--c-surface-raised); border: 2px solid var(--c-border); color: var(--c-text-muted); }
.tl-content { min-width: 0; }
.tl-label { font-size: 0.7rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 8px; }
.tl-user .tl-label { color: var(--c-primary); }
.tl-agent .tl-label { color: var(--c-text-muted); }
.tl-user .try-prompt { background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-md); padding: var(--space-md); }
.tl-user .try-prompt__text { font-size: 0.92rem; color: var(--c-text); line-height: 1.6; border: none; padding: 0; margin: 0; }
.tl-steps { font-size: 0.88rem; color: var(--c-text-secondary); line-height: 1.65; padding: var(--space-md); background: var(--c-surface-raised); border-radius: var(--radius-md); border: 1px solid var(--c-border-subtle); }
.tl-steps p { margin-top: 8px; }
.tl-tool { display: flex; align-items: baseline; gap: 8px; padding: 3px 0; }
.tl-tool-name { font-family: var(--font-mono); font-size: 0.78em; font-weight: 600; color: var(--c-primary); background: var(--c-primary-light); padding: 2px 8px; border-radius: 4px; white-space: nowrap; }

.value-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: var(--space-lg); }
.value-card { padding: var(--space-lg); background: var(--c-surface-raised); border: 1px solid var(--c-border-subtle); border-radius: var(--radius-md); }
.value-card h3 { font-size: 0.95rem; margin-bottom: var(--space-xs); }
.value-card p { font-size: 0.85rem; color: var(--c-text-secondary); line-height: 1.6; }
</style>