diff --git a/sample/templates/PPTWORDTemplateGenerator_1_0_0_2.zip b/sample/templates/PPTWORDTemplateGenerator_1_0_0_2.zip new file mode 100644 index 0000000..158da8d Binary files /dev/null and b/sample/templates/PPTWORDTemplateGenerator_1_0_0_2.zip differ diff --git a/sample/templates/README.md b/sample/templates/README.md new file mode 100644 index 0000000..305fe67 --- /dev/null +++ b/sample/templates/README.md @@ -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. diff --git a/sample/templates/scripts/templatize_docx.py b/sample/templates/scripts/templatize_docx.py new file mode 100644 index 0000000..e407fa0 --- /dev/null +++ b/sample/templates/scripts/templatize_docx.py @@ -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]) diff --git a/src/pages/index.astro b/src/pages/index.astro index 73bbbae..5ca6e3d 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -134,6 +134,15 @@ const base = import.meta.env.BASE_URL; href={`${base}scenarios/conversational-guidance`} image={`${base}images/scenario-reasoning.png`} /> + + @@ -531,7 +540,7 @@ const base = import.meta.env.BASE_URL; } .hero__subtitle { font-size: 0.95rem; - } + } .hero__actions { flex-direction: column; } diff --git a/src/pages/scenarios/brand-template-generation.astro b/src/pages/scenarios/brand-template-generation.astro new file mode 100644 index 0000000..a04f31b --- /dev/null +++ b/src/pages/scenarios/brand-template-generation.astro @@ -0,0 +1,193 @@ +--- +import Base from '../../layouts/Base.astro'; + +const base = import.meta.env.BASE_URL; +--- + + +
+
+ ← All scenarios + +
+ Template-Aware Generation +

Brand Template Generation

+

+ 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. +

+
+ +
+

The conversation

+

+ Use this with a brand-approved template when the source file's layout, logos, + and formatting must stay intact. +

+ +
    +
  1. +
    1
    +
    +
    You
    +
    +
    + Here's our standard customer QBR deck. Identify the shapes and logos + that should be preserved and turn the editable text into placeholders. +
    +
    +
    +
  2. +
  3. +
    +
    +
    Agent
    +
    +
    templatize_* preserves decorative shapes, logos, images, and layout
    +

    Replaces editable text frames with named placeholders such as {'{{exec_summary}}'} and {'{{section_1_title}}'}.

    +
    +
    +
  4. +
  5. +
    2
    +
    +
    You
    +
    +
    + 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. +
    +
    +
    +
  6. +
  7. +
    +
    +
    Agent
    +
    +

    Reads the placeholder list, determines which values can be inferred, and asks only for missing details.

    +
    fill_template writes placeholder values back in place
    +

    Returns the finished file with the original brand system intact.

    +
    +
    +
  8. +
+
+ +
+

Why this matters

+

+ 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. +

+
+ +
+

Setup

+
+
+

Import the solution

+

+ Import sample/templates/PPTWORDTemplateGenerator_1_0_0_2.zip + into a Power Platform environment with Copilot Studio on the + Early Release channel. +

+
+
+

Add the scripts

+

+ Use the scripts under sample/templates/scripts/ as the + template-processing tools. Add any companion fill script from the + contributed package before running the end-to-end flow. +

+
+
+

Enable ETC

+

+ 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. +

+
+
+
+ +
+

ETC capabilities demonstrated

+
+
+

Preserve brand fidelity

+

Shapes, images, logos, headers, footers, fonts, and layout stay untouched while editable text is replaced with structured placeholders.

+
+
+

Ask only when needed

+

The agent decides which placeholders require clarifying questions and which can be filled from context, files, or tool calls.

+
+
+

Chain file tools

+

Templatizing and filling are separate steps, letting the agent pass a placeholder map forward without regenerating the whole document.

+
+
+
+ +
+

Variants

+
+
+

Cowork + Skills

+

Run the same pipeline as a Cowork skill if you prefer that surface. The host changes, but the template-preserving flow is identical.

+
+
+

Word documents

+

Use the same flow for .docx files to preserve headers, footers, logos, tables, and body formatting.

+
+
+
+ +
+

Credit

+

Contributed by Betty Le (Microsoft, Cloud Solution Architect).

+
+
+
+ + +