diff --git a/CHANGELOG.md b/CHANGELOG.md index e2b1da4..58ff3e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,40 @@ snapshot commit; the development history before it is summarised here. ## Unreleased +- Slide master and layout editing (production feedback round 3, G5), measured in PowerPoint + for Mac 16. `deck.masters` / `deck.master()` give `Master` objects (`id` `M0`, `name`, + `part_path`, `layouts`, `slides`, `shapes`); `Layout` gets `id` (`L2`), `master`, `type`, + `slides`, `placeholder("body")`, `rename`, `duplicate` and `delete`. `Layout.shapes` and + `Master.shapes` are now the editable `Shape` a slide has (they were read-only + `LayoutShape` summaries, still importable), addressed `L.` / `M.` where `n` + is the `sldLayoutId` / `sldMasterId` less 2147483648 -- ids PowerPoint keeps on save, as it + keeps a slide's `sldId`; `deck.shape`, `deck.resolve` and the text API take them. Text, + run and paragraph formatting, fill, position, delete, replace_image, and `add_shape`, + `add_textbox`, `add_picture` (SVG too), `add_connector`, `add_table` on layouts and + masters. `shape.set_prompt(text)` / `shape.prompt`: a layout placeholder's prompt, the + first level written and `hasCustomPrompt="1"` set, as typing in Slide Master view does + (typing any text into a layout placeholder sets it too; formatting does not). + `deck.find_text(..., scope="slides" | "layouts" | "masters" | "all")` and + `outline_blocks(scope=)` (`TextBlock.part`; a placeholder holding only a field is left + out). `deck.set_footer(text, slide_number=, date="auto" | text | None, slides=, + skip_title=)` as Insert > Header & Footer writes it (Apply to All and Apply), and + `add_slide` then gives a new slide the layout's footers, as New Slide does. + `add_layout`, `duplicate_layout`, `rename_layout`, `delete_layout(reassign_to=)` as Slide + Master view does them (a layout in use is refused, as PowerPoint disables it). + `shape.inherited_by()` lists what inherits from a layout or master placeholder; deleting + one that something inherits from is refused. `deck.affected_slides(target)`. +- Tools: `ppt_set_text`, `ppt_format_text`, `ppt_set_shape`, `ppt_arrange` take `L2.5` / + `M0.7`, and the add tools `slide: L2` / `M0`; every result lists the slides an edit + `affects` (`checks.affects`) and checks them. `find_text` / `replace_text` take decks' + `stories`: `layouts`, `masters`, `all` (ooxml-edit 0.16.0); a replace that finds nothing on + the slides says when the text is on a layout or master. `describe` lists the masters, the + layouts' ids, their placeholders' ids and custom prompts, and the text on them that is not + a placeholder. The deferred `ppt_edit_master` (`set_prompt`, `set_footer`, `add_layout`, + `duplicate_layout`, `rename_layout`, `delete_layout`). Undo by slide (`scope`) finds a + change to the slide's layout or master and refuses it as `entangled` + (`DocumentFormat.undo_shared`, ooxml-edit 0.16.0). Every definition 11,779 estimated + (guard 11,800), the core 3,440 (about 5,091 counted); the strict set unchanged. Golden p17: + a template made ready for 2027 in Dutch. - SVG pictures (production feedback round 3, I1): `slide.add_picture` takes an SVG and stores it as PowerPoint 2016 and later store an inserted SVG, measured in PowerPoint for Mac 16: the PNG in `a:blip`, the SVG behind its `asvg:svgBlip` extension (`xmlns:asvg` on diff --git a/docs/USAGE.md b/docs/USAGE.md index 1e41e8c..78df15e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -752,14 +752,44 @@ print(body.type, body.idx, body.sizes[:3]) # -> None 1 (28.0, 24.0, 20.0 slide = deck.add_slide(layout) content = next(s for s in slide.shapes if s.placeholder == (None, 1)) print(content.placeholder) # -> Placeholder(type=None, idx=1) -print([shape.kind for shape in layout.shapes][:2]) # -> ['shape', 'shape'] +print([shape.id for shape in layout.shapes][:2]) # -> ['L2.2', 'L2.3'] ``` `layout.placeholders` lists what a slide made from the layout gets: each placeholder's `type` (`None` is a content placeholder, `"obj"`), `idx` (what a slide's placeholder matches it by), `name`, `bounds` (`left, top, width, height` in EMU) and the default text size of -each level. `shape.placeholder` is the same `(type, idx)` named tuple. `layout.shapes` are -read-only: layouts are edited in PowerPoint's slide master view. +each level. `shape.placeholder` is the same `(type, idx)` named tuple. + +### Slide masters and layouts + +`layout.shapes` and `deck.masters[0].shapes` are the same editable `Shape` objects a slide +has, addressed `L2.3` (a layout's `sldLayoutId` less 2147483648) and `M0.7` (the master's +`sldMasterId` likewise) -- ids PowerPoint keeps when it saves. An edit shows on every slide +built on the layout or master (`layout.slides`, `deck.affected_slides("M0.7")`). + +```python +from pptx_agent import Document + +deck = Document.new() +deck.add_slide("Title and Content").shapes[0].set_text("Findings") +legal = deck.masters[0].add_textbox(838200, 6721475, 7315200, 136525) +legal.set_text("\u00a9 2026 Example Corp") +for block in deck.find_text("2026", scope="all"): # slides, layouts and masters + deck.resolve(block.address).text = block.text.replace("2026", "2027") +print(deck.shape(legal.id).text, legal.id) # -> © 2027 Example Corp M0.7 +deck.layout("Title and Content").placeholder("body").set_prompt("Typ hier uw bevinding") +juridisch = deck.add_layout("Juridisch", after="Title and Content") # Insert Layout +dutch = deck.duplicate_layout("Title and Content", name="Inhoud NL") +deck.delete_layout("Comparison") # refused while a slide uses it (reassign_to=) +deck.set_footer("Example Corp B.V.", slide_number=True, date=None) # Header & Footer, Apply to All +print([s.placeholder.type for s in deck.slides[0].shapes]) # -> ['title', None, 'ftr', 'sldNum'] +``` + +`set_prompt` writes the first level and sets `hasCustomPrompt`, as typing in Slide Master +view does; `set_footer` writes what Insert > Header & Footer writes (the master's and every +layout's placeholders, the master's ``, each slide's date, footer and number +placeholders; `slides=` for Apply). Deleting a placeholder slides or layouts inherit from +is refused with their list (`shape.inherited_by()`). ### Theme colours, fonts and formatting as drawn diff --git a/pyproject.toml b/pyproject.toml index 0f32ae4..b208248 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,8 +48,9 @@ classifiers = [ # them (office.user_font_dirs, OOXML_FONT_DIRS). 0.14.0: Call.unlocked (render and check # on a snapshot, the lock let go) and slide ids in their slides. 0.15.0: chart formatting # (axis scales, colours, markers, combination charts, chart text, point labels, bubble and -# filled radar charts) and edit_chart's actions for it. -dependencies = ["lxml>=4.9", "ooxml-common>=0.8.0", "ooxml-edit>=0.15.0", "markdown-it-py>=3.0", +# filled radar charts) and edit_chart's actions for it. 0.16.0: DocumentFormat.undo_shared +# (undo by slide refuses a layout or master change) and decks' stories on find/replace. +dependencies = ["lxml>=4.9", "ooxml-common>=0.8.0", "ooxml-edit>=0.16.0", "markdown-it-py>=3.0", "mdit-py-plugins>=0.4"] [project.optional-dependencies] diff --git a/src/pptx_agent/__init__.py b/src/pptx_agent/__init__.py index f2c55c2..b57da43 100644 --- a/src/pptx_agent/__init__.py +++ b/src/pptx_agent/__init__.py @@ -97,7 +97,7 @@ measure_text) from .edit.ids import ShapeId from .edit.labels import LabelError -from .edit.slides import Layout, LayoutPlaceholder, LayoutShape, Placeholder +from .edit.slides import Layout, LayoutPlaceholder, LayoutShape, Master, Placeholder from .edit.table import Region, Table, TableCell from .edit.theme import Theme, ThemeFonts, ThemeRoles, Tint from .edit.presets import PRESETS @@ -137,6 +137,7 @@ "Layout", "LayoutPlaceholder", "LayoutShape", + "Master", "LineFormat", "MarkdownEscapeWarning", "NotFullStateSvg", diff --git a/src/pptx_agent/edit/document.py b/src/pptx_agent/edit/document.py index 874fa5b..5721d20 100644 --- a/src/pptx_agent/edit/document.py +++ b/src/pptx_agent/edit/document.py @@ -17,6 +17,7 @@ from typing import TYPE_CHECKING, BinaryIO, Iterator, NamedTuple if TYPE_CHECKING: # pragma: no cover + import datetime as _datetime_module # noqa: F401 from datetime import datetime from ..outline.read import TextBlock @@ -68,7 +69,7 @@ write_fill, ) from . import slides as _slides -from .slides import Layout, Placeholder +from .slides import Layout, Master, Placeholder from .table import Table, TableCell, _table_element from .text import Paragraph, Run, TextFrame from .ids import ShapeId, SlideShapeIndex, cnv_pr, read_stamp, write_stamp @@ -97,6 +98,9 @@ "hdr": "hdr", } +#: The placeholders Insert > Header & Footer fills: content, never a prompt. +_HEADER_FOOTER = ("dt", "ftr", "sldNum", "hdr") + _XFRM_PARENT: dict[str, str] = { "sp": "p:spPr", "pic": "p:spPr", @@ -707,6 +711,83 @@ def set_text(self, value: "str | TextSpec") -> "Shape": frame.set_text(value) return self + # -- layout prompts -------------------------------------------------------------------- + + @property + def prompt(self) -> str | None: + r"""A layout placeholder's custom prompt -- what an empty placeholder on a slide + built on the layout says while editing ("Type your finding here") -- or ``None``: + not a layout placeholder, or one that shows PowerPoint's own ("Click to add text"). + + For example:: + + deck.layout("Title and Content").placeholder("body").prompt + """ + node = self._ph_node() + if node is None or not self._on_layout() or node.get("hasCustomPrompt") not in ("1", "true"): + return None + return self.text + + def set_prompt(self, text: str) -> "Shape": + r"""Set a layout placeholder's prompt text, as typing it in Slide Master view does: + the first paragraph (the first level) gets ``text`` -- each ``" +"`` goes on to + the next level's paragraph -- formatting and the other levels kept, and the + placeholder is marked ``hasCustomPrompt="1"``, so slides on the layout show it in + an empty placeholder. Prompt text is never drawn in a show or an export. One undo + step; returns the shape. + + For example:: + + body = deck.layout("Title and Content").placeholder("body") + body.set_prompt("Typ hier uw bevinding") + """ + if not self._on_layout() or self._ph_node() is None: + raise ValueError(f"{self.id}: a prompt belongs to a layout's placeholder; this is " + f"{'not a placeholder' if self._ph_node() is None else 'on a ' + self._slide.kind}") + if self._ph_node().get("type") in _HEADER_FOOTER: + raise ValueError(f"{self.id}: a {self._ph_node().get('type')} placeholder has no " + "prompt; Document.set_footer fills it") + frame = self.text_frame + if frame is None: + raise ValueError(f"{self.id}: a {self.kind} holds no text") + lines = str(text).split("\n") + with self._slide.document.batch(): + for index, line in enumerate(lines): + if index < len(frame.paragraphs): + frame.paragraph(index).text = line + else: + frame.add_paragraph(line, like=index - 1) + self._mark_prompt() + return self + + def _ph_node(self) -> Element | None: + return find(self._element, f"{_nv_container(self._element)}/p:nvPr/p:ph") + + def _on_a_slide(self, what: str) -> None: + kind = getattr(self._slide, "kind", None) + if kind is not None: + raise ValueError(f"{self.id} is on a {kind}: {what} measures a slide's shape; check " + f"the slides it shows on (deck.affected_slides({self.id!r}))") + + def _on_layout(self) -> bool: + return getattr(self._slide, "kind", None) == "layout" + + def _mark_prompt(self) -> None: + node = self._ph_node() + if node is not None and node.get("hasCustomPrompt") != "1": + self._before_change() + node.set("hasCustomPrompt", "1") + self._slide._touch() + + def _text_written(self) -> None: + """Typing into a layout's placeholder makes its text the prompt (measured in + PowerPoint 16: ``hasCustomPrompt="1"``); the date, footer and slide-number ones + hold content, not a prompt.""" + node = self._ph_node() + if self._on_layout() and node is not None and node.get("type") not in _HEADER_FOOTER: + self._mark_prompt() + @property def text_frame(self) -> "TextFrame | None": """The shape's paragraphs and runs. Only autoshapes and text boxes (``kind`` @@ -738,6 +819,7 @@ def text_fit(self) -> "TextFit": fit = deck.shape("258.3").text_fit() fit.overflows, fit.needed, fit.available, fit.sizes[0] """ + self._on_a_slide("text_fit") _require_renderer() from .fit import measure @@ -754,6 +836,7 @@ def fit_height(self) -> int: box.height = box.fit_height() # exactly as tall as its text """ + self._on_a_slide("fit_height") _require_renderer() from .fit import fit_height @@ -1251,7 +1334,7 @@ def _list_styles(self) -> list: from . import inherit package = self._slide.document.package - master = inherit.master_of(package, inherit.layout_of(package, self._slide.part_path)) + master = self._slide._template_parts()[1] placeholder = self.placeholder own = self._element.find(f"{qn('p:txBody')}/{qn('a:lstStyle')}") layout_shape = self._slide._layout_placeholder(placeholder) if placeholder else None @@ -1280,7 +1363,7 @@ def body(shape): if layout_shape is not None: node = inherit.placeholder_of(layout_shape) kind = node.get("type") if node is not None else kind - master = inherit.master_of(package, inherit.layout_of(package, self._slide.part_path)) + master = self._slide._template_parts()[1] chain.append(body(inherit.master_placeholder(package, master, inherit.master_kind(kind)))) return chain @@ -1328,6 +1411,12 @@ def delete(self) -> None: shape.delete() """ + dependants = self.inherited_by() + if dependants: + raise ValueError( + f"{self.id}: {', '.join(dependants[:20])} inherit position and format from " + "this placeholder and would lose them; delete those first (or leave this " + "one: an empty layout placeholder draws nothing on a slide)") with self._slide.document.batch(): parent = self.parent_group self._before_change() @@ -1339,6 +1428,41 @@ def delete(self) -> None: if parent is not None: parent._refit() + def inherited_by(self) -> list[str]: + """What inherits position and format from this placeholder: for a layout's, the + placeholders on its slides that match it (``"257.3"``); for a master's, the + layouts' placeholders of its kind (``"L2.4"``). ``[]`` for anything else -- a + slide's shape, a layout's or master's own decoration -- which :meth:`delete` + removes freely; it refuses a placeholder something inherits from. + + For example:: + + deck.layout("Title and Content").placeholder("body").inherited_by() + """ + from . import inherit + + node = self._ph_node() + kind = getattr(self._slide, "kind", "slide") + if node is None or kind == "slide": + return [] + found = [] + if kind == "layout": + for slide in self._slide.document._slides_on(self._slide.part_path): + for shape in slide.shapes: + if shape.placeholder is not None and \ + slide._layout_placeholder(shape.placeholder) is self._element: + found.append(shape.id) + return found + wanted = inherit.master_kind(node.get("type")) + for layout in self._slide.document.layouts: + if layout.master_part != self._slide.part_path: + continue + for shape in layout.shapes: + if shape.placeholder is not None and \ + inherit.master_kind(shape.placeholder.type) == wanted: + found.append(shape.id) + return found + # -- pictures -------------------------------------------------------------------------- @property @@ -2531,6 +2655,13 @@ def replace(match: re.Match[str]) -> str: # -- internals ------------------------------------------------------------------------- + def _template_parts(self) -> tuple[str | None, str | None]: + """``(layout part, master part)`` the shapes here inherit from.""" + from . import inherit + + layout = inherit.layout_of(self.document.package, self.part_path) + return layout, inherit.master_of(self.document.package, layout) + def _sp_tree(self) -> Element: root = self.document.package.tree(self.part_path) if root is None: @@ -2685,6 +2816,71 @@ def __repr__(self) -> str: return f"" +class _TemplateSheet(Slide): + """A layout or master part as the host of its shapes -- what a :class:`Slide` is to a + slide's -- so they are the same :class:`Shape` objects, addressed ``L.`` and + ``M.``. Internal: the public objects are :class:`~pptx_agent.Layout` and + :class:`~pptx_agent.Master`. What only a slide has (notes, its position, rendering + on its own, a slide's delete and duplicate) is refused.""" + + def __init__(self, document: "Document", key: str, part_path: str) -> None: + super().__init__(document, key, part_path) # type: ignore[arg-type] + self.kind = "layout" if key.startswith("L") else "master" + + def _template_parts(self) -> tuple[str | None, str | None]: + from . import inherit + + if self.kind == "master": + return None, self.part_path + return self.part_path, inherit.master_of(self.document.package, self.part_path) + + @property + def layout(self) -> Layout | None: + return None + + def _layout_placeholder(self, placeholder): + return None + + def _inherited_xfrm(self, placeholder) -> Element | None: + """A layout placeholder without a frame of its own is drawn where the master's + placeholder of its kind is; a master's states its own.""" + from . import inherit + + if placeholder is None or self.kind == "master": + return None + master = self._template_parts()[1] + shape = inherit.master_placeholder(self.document.package, master, + inherit.master_kind(placeholder[0])) + return None if shape is None else find(shape, "p:spPr/a:xfrm") + + def _not_a_slide(self, what: str): + return ValueError(f"{self.slide_id} is a {self.kind}, not a slide: it has no {what}") + + @property + def index(self) -> int: # type: ignore[override] + raise self._not_a_slide("position in the slide list") + + def delete(self) -> None: + raise self._not_a_slide("slide delete; Document.delete_layout deletes a layout") + + def duplicate(self, index=None, *, notes=None): + raise self._not_a_slide("slide duplicate; Document.duplicate_layout copies a layout") + + @property + def notes(self) -> str: # type: ignore[override] + raise self._not_a_slide("speaker notes") + + @property + def has_notes(self) -> bool: # type: ignore[override] + return False + + def render_svg(self, **options) -> str: + raise self._not_a_slide("render of its own; render the slides it affects") + + def __repr__(self) -> str: + return f"<{self.kind.title()} {self.slide_id} {self.part_path}>" + + class _NotesHost: """A slide's speaker notes as a text frame's host: the notes page's notes placeholder, made (with a notes master, when the deck has none) the first time it is written.""" @@ -2775,6 +2971,7 @@ def __init__(self, package: OoxmlPackage) -> None: self.package = package self.history = History(package) self._slides: list[Slide] | None = None + self._sheets: dict[str, _TemplateSheet] = {} # -- lifecycle ------------------------------------------------------------------------- @@ -3164,26 +3361,213 @@ def layouts(self) -> list[Layout]: [layout.name for layout in deck.layouts] """ - return _slides.layouts(self.package) + return _slides.layouts(self.package, self) - def layout(self, name: str) -> Layout: - """A layout by name (``"Title and Content"``) or part path. + def layout(self, name: "str | Layout") -> Layout: + """A layout by name (``"Title and Content"``), address (``"L2"``) or part path. For example:: deck.layout("Title and Content") + deck.layout("L2") """ + if isinstance(name, Layout): + name = name.part_path for layout in self.layouts: - if name in (layout.name, layout.part_path): + if name in (layout.name, layout.part_path, layout.id): return layout - raise KeyError(f"no layout {name!r}; have {[layout.name for layout in self.layouts]}") + raise KeyError(f"no layout {name!r}; have " + f"{[f'{layout.id} {layout.name}' for layout in self.layouts]}") + + @property + def masters(self) -> list["Master"]: + """Every slide master, in order -- most decks have one. A master's shapes (a + logo, a legal line, the footer placeholders) show on every slide of its layouts; + they are editable :class:`Shape` objects, addressed ``M.``. + + For example:: + + master = deck.masters[0] + [(shape.id, shape.text) for shape in master.shapes if shape.text] + """ + return _slides.masters(self.package, self) + + def master(self, which: "str | int | Master" = 0) -> "Master": + """A master by address (``"M0"``), name, part path or position (``0``). + + For example:: + + deck.master("M0").placeholder("footer") + """ + found = self.masters + if isinstance(which, int) and not isinstance(which, bool): + return found[which] + if isinstance(which, _slides.Master): + which = which.part_path + for master in found: + if which in (master.id, master.name, master.part_path): + return master + raise KeyError(f"no master {which!r}; have {[m.id for m in found]}") + + def _template_sheet(self, part_path: str) -> "_TemplateSheet": + """The host of a layout's or master's shapes (cached until the next reset).""" + part_path = normalize_part_path(part_path) + sheet = self._sheets.get(part_path) + if sheet is None: + key = next((t.id for t in [*self.layouts, *self.masters] + if t.part_path == part_path), None) + if key is None: + raise KeyError(f"{part_path} is not a layout or master of this deck") + sheet = self._sheets[part_path] = _TemplateSheet(self, key, part_path) + return sheet + + def _template_by_key(self, key: str) -> "Layout | Master": + for template in [*self.layouts, *self.masters]: + if template.id == key: + return template + raise KeyError(f"no {'layout' if key.startswith('L') else 'master'} {key}; the deck " + f"has {[t.id for t in [*self.masters, *self.layouts]]}") + + def _slides_on(self, part_path: str) -> list[Slide]: + """The slides built on a layout -- or, for a master, on any of its layouts.""" + from . import inherit + + part_path = normalize_part_path(part_path) + out = [] + for slide in self.slides: + layout = inherit.layout_of(self.package, slide.part_path) + if part_path in (layout, inherit.master_of(self.package, layout)): + out.append(slide) + return out + + def affected_slides(self, target: "str | Layout | Master | Shape") -> list[Slide]: + """The slides an edit to ``target`` shows on: a layout's or master's slides (a + master's: every slide of its layouts), for one of their shapes too; a slide's + shape: that slide. ``target`` is an object or an address (``"L2"``, ``"M0.5"``, + ``"256.3"``). + + For example:: + + [slide.slide_id for slide in deck.affected_slides("M0")] + """ + from .ids import is_template_key + + if isinstance(target, Shape): + target = target._slide.slide_id + if isinstance(target, (_slides.Layout, _slides.Master)): + return target.slides + head = str(target).split("/")[0].split(".")[0] + if head.startswith("s:"): + head = head[2:] + if is_template_key(head): + return self._template_by_key(head).slides + return [self.slide(int(head))] + + # -- masters and layouts --------------------------------------------------------------- + + def _as_layout(self, layout: "Layout | str") -> Layout: + return self.layout(layout) + + def add_layout(self, name: str = "Custom Layout", *, master: "Master | str | None" = None, + after: "Layout | str | None" = None) -> Layout: + """A new, custom layout, as Slide Master view's Insert Layout makes one: a title + placeholder and the master's date, footer and slide-number placeholders, all + inheriting their place from the master. It goes after ``after`` (a layout or its + name or id), else last on ``master`` (default the first). Add shapes and + placeholders' prompts through it. One undo step. + + For example:: + + legal = deck.add_layout("Legal", after="Title and Content") + legal.add_textbox(457200, 1600200, 11277600, 4114800, text="...") + """ + from . import templates + + master = self.master(master) if master is not None else None + after = self.layout(after) if after is not None else None + return templates.add_layout(self, name, master, after) + + def duplicate_layout(self, layout: "Layout | str", *, name: str | None = None) -> Layout: + """A copy of ``layout`` right after it, as Slide Master view's Duplicate Layout + makes one: the same shapes and pictures, named ``name`` (default ``"1_"``), + with a new id. One undo step. + + For example:: + + dutch = deck.duplicate_layout("Title and Content", name="Inhoud NL") + """ + from . import templates + + return templates.duplicate_layout(self, self.layout(layout), name) + + def rename_layout(self, layout: "Layout | str", name: str) -> Layout: + """Rename a layout; its id and its slides stay. A name its master already has is + refused (layouts are found by name). One undo step. + + For example:: + + deck.rename_layout("L2", "Inhoud") + """ + from . import templates + + return templates.rename_layout(self, self.layout(layout), name) + + def delete_layout(self, layout: "Layout | str", *, + reassign_to: "Layout | str | None" = None) -> list[int]: + """Delete a layout. One a slide uses is refused -- PowerPoint does not offer it + either -- unless ``reassign_to`` names the layout those slides move to (their + placeholders then inherit from its placeholders of the same idx and type); returns + their ``sldId``. A master's last layout stays. One undo step. + + For example:: + + deck.delete_layout("Vertical Title and Text") + deck.delete_layout("Comparison", reassign_to="Two Content") + """ + from . import templates + + target = self.layout(reassign_to) if reassign_to is not None else None + return templates.delete_layout(self, self.layout(layout), target) + + def set_footer(self, text: str | None = None, *, slide_number: bool = True, + date: str | None = None, slides=None, skip_title: bool = True, + today: "_datetime_module.date | None" = None) -> list[int]: + """Insert > Header & Footer: the footer ``text``, the slide number and the date on + the slides, as PowerPoint writes them (measured in PowerPoint 16). + + ``date`` is ``"auto"`` (a date field PowerPoint updates; ``today`` sets the text + it holds until then, default today), any other text (a fixed date: ``"Oktober + 2026"``), or ``None`` (no date). ``text`` empty or ``None`` turns the footer off, + ``slide_number`` the number. + + ``slides`` ``None`` is **Apply to All**: the footer text and the date go on the + master's and every layout's placeholders too, the master's ```` records what + is on, and each slide gets its layout's date, footer and slide-number placeholders + (empty ``spPr``: they keep the layout's place) or, turned off, loses them. + ``skip_title`` leaves title slides (a ``"title"`` layout's) without them, as "Don't + show on title slide" does. ``slides`` (numbers from 1, ``Slide`` objects or + ``"s:"``) is **Apply**: only those slides change. Returns the ``sldId`` of + each slide changed. One undo step. + + For example:: + + deck.set_footer("© 2027 Example BV", slide_number=True, date=None) + """ + from ..outline.read import select + from . import templates + + chosen = None if slides is None else select(self, slides) + return templates.set_footer(self, text=text, slide_number=slide_number, date=date, + slides=chosen, skip_title=skip_title, today=today) def add_slide(self, layout: "Layout | str | None" = None, *, index: int | None = None) -> Slide: """A new slide from ``layout``, at 0-based ``index`` (default: last). - The slide gets the layout's placeholders -- title, body, picture and so on, not the - date, footer and slide number, which PowerPoint also leaves off -- empty, so they - inherit position and formatting from the layout. Without a layout, the last slide's + The slide gets the layout's placeholders -- title, body, picture and so on -- empty, + so they inherit position and formatting from the layout. The date, footer and slide + number come too, with the layout's content, once Header & Footer has been applied to + all (:meth:`set_footer`: the master's ```` says which), as PowerPoint's New + Slide does; until then PowerPoint leaves them off, and so does this. Without a layout, the last slide's is used (or the first layout of the first master). For example:: @@ -3206,6 +3590,9 @@ def add_slide(self, layout: "Layout | str | None" = None, *, index: int | None = package.add_relationship(part, REL_SLIDE_LAYOUT, layout.part_path) slide_id = _slides.insert_entry(package, part, index) self._reset_caches() + from .templates import footers_for_new_slide + + footers_for_new_slide(self, self.slide(slide_id)) return self.slide(slide_id) def delete_slide(self, slide: "Slide | int | str") -> None: @@ -3314,6 +3701,9 @@ def shape(self, shape_id: str | ShapeId) -> Shape: table = deck.shape("257.3#5").table """ parsed = ShapeId.parse(shape_id) + if isinstance(parsed.slide_id, str): + return self._template_sheet(self._template_by_key(parsed.slide_id).part_path) \ + .shape(parsed) return self.slide(parsed.slide_id).shape(parsed) def __iter__(self) -> Iterator[Slide]: @@ -3390,6 +3780,7 @@ def redo(self) -> bool: def _reset_caches(self) -> None: # restore() reparsed the trees, so every cached Element reference is stale. self._slides = None + self._sheets = {} # -- the full-state SVG ---------------------------------------------------------------- @@ -3473,7 +3864,7 @@ def overflows(self, slides=None, *, boxes: bool = False) -> list: return overflows(self, slides, boxes=boxes) - def outline_blocks(self, slides=None) -> "list[TextBlock]": + def outline_blocks(self, slides=None, *, scope: str = "slides") -> "list[TextBlock]": r"""Every piece of text in the deck, raw, with its address -- the outline's blocks as data, for editing rather than reading. @@ -3489,12 +3880,15 @@ def outline_blocks(self, slides=None) -> "list[TextBlock]": for block in deck.outline_blocks(slides=[1]): if block.text == "11.9%": deck.resolve(block.address).text = "12.1%" + + ``scope`` adds the text on layouts and masters: ``"slides"`` (the default), + ``"layouts"``, ``"masters"`` or ``"all"`` -- see :meth:`find_text`. """ from ..outline.read import outline_blocks - return outline_blocks(self, slides) + return outline_blocks(self, slides, scope) - def find_text(self, needle, slides=None) -> "list[TextBlock]": + def find_text(self, needle, slides=None, *, scope: str = "slides") -> "list[TextBlock]": r"""The :meth:`outline_blocks` whose text contains ``needle`` -- a substring, or a compiled :func:`re.compile` pattern (searched with ``pattern.search``). Speaker notes are searched too (kind ``"notes"``, address ``"/notes"``):: @@ -3502,10 +3896,19 @@ def find_text(self, needle, slides=None) -> "list[TextBlock]": for block in deck.find_text("4,285"): target = deck.resolve(block.address) # a Shape, TableCell or DiagramNode target.text = block.text.replace("4,285", "4,310") # formatting kept + + ``scope`` says where: ``"slides"`` (the default), ``"layouts"``, ``"masters"`` or + ``"all"``. Text on a layout or master -- a legal line, a footer year, a + placeholder's prompt -- shows on every slide built on it; its blocks have + ``slide`` 0, ``part`` the layout's or master's address and addresses such as + ``"L2.5"`` and ``"M0.7"``, which :meth:`resolve` and :meth:`shape` take:: + + for block in deck.find_text("2026", scope="all"): + deck.resolve(block.address).text = block.text.replace("2026", "2027") """ from ..outline.read import find_text - return find_text(self, needle, slides) + return find_text(self, needle, slides, scope) def insert_outline(self, markdown: str, *, at: int | None = None, layout_map: "dict | None" = None, images=None) -> list[Slide]: diff --git a/src/pptx_agent/edit/ids.py b/src/pptx_agent/edit/ids.py index b2df76c..35e9873 100644 --- a/src/pptx_agent/edit/ids.py +++ b/src/pptx_agent/edit/ids.py @@ -30,6 +30,7 @@ from __future__ import annotations +import functools from dataclasses import dataclass from ooxml_edit.stamp import ExtensionStamp @@ -54,18 +55,37 @@ } -@dataclass(frozen=True, order=True) +#: The lowest ``sldMasterId``/``sldLayoutId`` the schema allows: a layout's key is ``L`` +#: and its id less this (``L1`` is 2147483649), a master's ``M`` and its id less this. +TEMPLATE_ID_BASE = 2147483648 + + +def is_template_key(text: object) -> bool: + """Whether ``text`` names a layout (``"L3"``) or a master (``"M0"``).""" + return isinstance(text, str) and len(text) > 1 and text[0] in "LM" and text[1:].isdigit() + + +@functools.total_ordering +@dataclass(frozen=True) class ShapeId: - """A deck-wide shape address, rendered as ``"."``. + """A deck-wide shape address, rendered as ``"."`` -- or, for a shape on a + layout or a master, ``"L."`` / ``"M."``, where ``n`` is the + layout's ``sldLayoutId`` (the master's ``sldMasterId``) less 2147483648. Those ids are + as durable as a slide's ``sldId``: PowerPoint keeps them when it saves, while it + renumbers the parts. For example:: ShapeId.parse("257.3#5") # ShapeId(slide_id=257, local='3#5') + ShapeId.parse("L2.5") # ShapeId(slide_id='L2', local='5') """ - slide_id: int + slide_id: "int | str" local: str + def __lt__(self, other: "ShapeId") -> bool: + return (str(self.slide_id), self.local) < (str(other.slide_id), other.local) + def __str__(self) -> str: return f"{self.slide_id}.{self.local}" @@ -82,6 +102,8 @@ def parse(cls, text: str | "ShapeId") -> "ShapeId": slide, separator, local = text.partition(".") if not separator or not local: raise ValueError(f"malformed shape id {text!r}; expected '.'") + if is_template_key(slide): + return cls(slide, local) try: return cls(int(slide), local) except ValueError: @@ -103,7 +125,7 @@ class SlideShapeIndex: reordering shapes -- since index paths shift. """ - def __init__(self, slide_id: int, sp_tree: Element) -> None: + def __init__(self, slide_id: "int | str", sp_tree: Element) -> None: self.slide_id = slide_id self._by_id: dict[str, Element] = {} self._for_element: dict[int, str] = {} diff --git a/src/pptx_agent/edit/slides.py b/src/pptx_agent/edit/slides.py index 0676796..51d6e24 100644 --- a/src/pptx_agent/edit/slides.py +++ b/src/pptx_agent/edit/slides.py @@ -167,14 +167,14 @@ def bottom(self) -> int | None: @dataclass(frozen=True) class LayoutShape(_Frame): - """A shape on a layout, read-only: a layout is edited in PowerPoint's slide master view, - not here. ``id`` is its ``cNvPr@id``; ``kind`` as :attr:`Shape.kind`; ``bounds`` its - own frame, if it states one, and ``left``/``top``/``width``/``height`` the same one - by one -- as a :class:`Shape` has both. + """A read-only summary of a shape on a layout: ``id`` (its ``cNvPr@id``), ``kind``, + ``name``, ``placeholder``, ``bounds`` and ``text``. No longer what + :attr:`Layout.shapes` returns -- that is the editable :class:`~pptx_agent.Shape` now + -- but kept for code that builds one. For example:: - [(shape.kind, shape.name, shape.placeholder) for shape in layout.shapes] + LayoutShape("2", "shape", "Title 1", Placeholder("title", None), None, "Title") """ id: str @@ -185,14 +185,170 @@ class LayoutShape(_Frame): text: str +#: :meth:`Layout.placeholder` and :meth:`Master.placeholder`'s names -> the ``p:ph`` types +#: they match (``None``: a content placeholder, ``"obj"``). +PLACEHOLDER_NAMES: dict[str, tuple] = { + "title": ("title", "ctrTitle"), "body": ("body", None, "obj"), + "content": (None, "obj", "body"), "subtitle": ("subTitle",), "date": ("dt",), + "footer": ("ftr",), "slide_number": ("sldNum",), "header": ("hdr",), + "picture": ("pic",), "chart": ("chart",), "table": ("tbl",), +} + + +class _TemplatePart: + """What a layout and a master share: their shapes -- the same editable + :class:`~pptx_agent.Shape` a slide has, addressed ``L.`` / ``M.`` -- and + adding shapes, pictures, connectors and tables to them.""" + + part_path: str + _document: object + _key: str + + @property + def id(self) -> str: + """The address of this layout (``"L2"``) or master (``"M0"``): its + ``sldLayoutId`` (``sldMasterId``) less 2147483648, as durable as a slide's id -- + PowerPoint keeps it when it saves. Its shapes are ``"L2."``. + + For example:: + + deck.layout("Title and Content").id # 'L2' + """ + return self._key + + def _sheet(self): + if self._document is None: + raise ValueError(f"this {type(self).__name__} was made by hand; take it from " + f"deck.{type(self).__name__.lower()}s") + return self._document._template_sheet(self.part_path) + + @property + def shapes(self) -> list: + """Every shape, back to front (groups' members after their group), as editable + :class:`~pptx_agent.Shape` objects: text, formatting, fill, position, delete, + replace_image -- what a slide's shapes do. An edit here shows on every slide + built on it (:attr:`slides`). + + For example:: + + [(shape.id, shape.name, shape.text) for shape in deck.masters[0].shapes] + """ + return self._sheet().shapes + + def shape(self, shape_id: str): + """A shape by its address, ``"L2.5"`` (or the local part, ``"5"``). + + For example:: + + deck.layout("Title and Content").shape("L2.3").set_prompt("Typ hier") + """ + text = str(shape_id) + if "." not in text: + text = f"{self._key}.{text}" + return self._sheet().shape(text) + + def placeholder(self, which: "str | int"): + """The placeholder ``which`` names: ``"title"``, ``"body"`` (or ``"content"``: a + content placeholder), ``"subtitle"``, ``"date"``, ``"footer"``, ``"slide_number"``, + ``"picture"``, a ``p:ph`` type (``"ctrTitle"``) -- the first such, back to front -- + or an ``idx`` (``1``). + + For example:: + + deck.layout("Title and Content").placeholder("body").set_prompt("Typ hier") + """ + types = PLACEHOLDER_NAMES.get(which) if isinstance(which, str) else None + for shape in self.shapes: + ph = shape.placeholder + if ph is None: + continue + if isinstance(which, int) and not isinstance(which, bool): + if ph.idx == which: + return shape + elif types is not None and ph.type in types: + return shape + elif ph.type == which: + return shape + have = [(shape.id, shape.placeholder.type or "content", shape.placeholder.idx) + for shape in self.shapes if shape.placeholder is not None] + raise KeyError(f"no placeholder {which!r} on {self._key}; it has {have}") + + @property + def slides(self) -> list: + """The slides an edit here shows on: a layout's slides, or every slide of the + master's layouts, in presentation order. + + For example:: + + [slide.slide_id for slide in deck.layout("Title Slide").slides] + """ + return self._document._slides_on(self.part_path) + + def add_shape(self, *args, **kwargs): + """Add an autoshape here, as :meth:`Slide.add_shape ` + does; every slide built on it draws it, behind the slide's own shapes. + + For example:: + + deck.masters[0].add_shape("rect", 0, 0, 12192000, 228600, fill="accent1") + """ + return self._sheet().add_shape(*args, **kwargs) + + def add_textbox(self, *args, **kwargs): + """Add a text box here, as :meth:`Slide.add_textbox ` + does -- a legal line, a classification mark. + + For example:: + + deck.masters[0].add_textbox(457200, 6400800, 7000000, 300000, + text="© 2027 Example BV") + """ + return self._sheet().add_textbox(*args, **kwargs) + + def add_picture(self, *args, **kwargs): + """Add a picture here (SVG too), as :meth:`Slide.add_picture + ` does -- a logo every slide shows. + + For example:: + + deck.masters[0].add_picture("logo.svg", left=10972800, top=228600, width=914400) + """ + return self._sheet().add_picture(*args, **kwargs) + + def add_connector(self, *args, **kwargs): + """Add a connector here, as :meth:`Slide.add_connector + ` does -- a rule under the title. + + For example:: + + layout.add_connector("straight", (457200, 1371600), (11734800, 1371600)) + """ + return self._sheet().add_connector(*args, **kwargs) + + def add_table(self, *args, **kwargs): + """Add a table here, as :meth:`Slide.add_table ` does. + + For example:: + + layout.add_table(2, 2, 457200, 1600200, 4572000, 914400) + """ + return self._sheet().add_table(*args, **kwargs) + + @dataclass(frozen=True) -class Layout: - """A slide layout a new slide can be made from, and what it offers. +class Layout(_TemplatePart): + """A slide layout: what a new slide is made from, what it offers, and -- since round 3 + -- an editable part of its own, as PowerPoint's Slide Master view edits it. + + ``id`` is its address (``"L2"``); :attr:`shapes` are editable :class:`Shape` objects + (``"L2.3"``), and every slide built on the layout (:attr:`slides`) shows an edit. + :meth:`rename`, :meth:`duplicate` and :meth:`delete` change the layout list. For example:: layout = deck.layout("Title and Content") print([(ph.type, ph.idx, ph.bounds) for ph in layout.placeholders]) + layout.placeholder("body").set_prompt("Typ hier uw bevinding") deck.add_slide(layout) """ @@ -200,6 +356,8 @@ class Layout: part_path: str master_part: str _package: object = field(default=None, compare=False, hash=False, repr=False) + _document: object = field(default=None, compare=False, hash=False, repr=False) + _key: str = field(default="", compare=False, hash=False, repr=False) @property def placeholders(self) -> list[LayoutPlaceholder]: @@ -235,33 +393,58 @@ def placeholders(self) -> list[LayoutPlaceholder]: return out @property - def shapes(self) -> list[LayoutShape]: - """Every shape on the layout, back to front, read-only (:class:`LayoutShape`): - placeholders and the layout's own decoration. + def master(self) -> "Master": + """The master this layout belongs to. For example:: - [shape.name for shape in deck.layout("Title Slide").shapes] + deck.layout("Title Slide").master.id # 'M0' """ - from . import inherit + return next(master for master in self._document.masters + if master.part_path == self.master_part) - out = [] - for shape in self._tree(): - ph = inherit.placeholder_of(shape) - placeholder = None - if ph is not None: - idx = ph.get("idx") - placeholder = Placeholder(ph.get("type"), - int(idx) if idx and idx.isdigit() else None) - properties = shape.find(f".//{qn('p:cNvPr')}") - texts = ["".join(t.text or "" for t in paragraph.iter(qn("a:t"))) - for paragraph in shape.findall(f"{qn('p:txBody')}/{qn('a:p')}")] - out.append(LayoutShape( - id=(properties.get("id") if properties is not None else None) or "", - kind=_KINDS.get(local_name(shape), "unknown"), name=_name_of(shape), - placeholder=placeholder, bounds=inherit.frame_of(shape), - text="\n".join(texts))) - return out + @property + def type(self) -> str | None: + """The layout's ``type`` (``"title"``, ``"obj"``, ``"twoObj"``...); ``None`` for a + custom layout. A slide on a ``"title"`` layout is a title slide, which + :meth:`Document.set_footer` leaves out by default, as PowerPoint does. + + For example:: + + deck.layout("Title Slide").type # 'title' + """ + root = self._require_package().tree(self.part_path) + return None if root is None else root.get("type") + + def rename(self, name: str) -> "Layout": + """Rename the layout (``cSld@name``, what Slide Master view shows); returns it as + renamed. See :meth:`Document.rename_layout`. + + For example:: + + deck.layout("Title and Content").rename("Inhoud") + """ + return self._document.rename_layout(self, name) + + def duplicate(self, name: str | None = None) -> "Layout": + """A copy right after this one, as Slide Master view's Duplicate Layout makes it. + See :meth:`Document.duplicate_layout`. + + For example:: + + dutch = deck.layout("Title and Content").duplicate("Inhoud NL") + """ + return self._document.duplicate_layout(self, name=name) + + def delete(self, *, reassign_to: "Layout | str | None" = None) -> None: + """Delete this layout. See :meth:`Document.delete_layout`: one in use is refused + unless ``reassign_to`` names the layout its slides move to. + + For example:: + + deck.layout("Vertical Title and Text").delete() + """ + self._document.delete_layout(self, reassign_to=reassign_to) def _require_package(self): if self._package is None: @@ -274,7 +457,54 @@ def _tree(self) -> list[Element]: return [] if tree is None else [shape for shape in tree if local_name(shape) in _KINDS] def __repr__(self) -> str: - return f"" + return f"" + + +@dataclass(frozen=True) +class Master(_TemplatePart): + """A slide master: the shapes every slide of its layouts shows (a logo, a legal line, + the footer placeholders), its text styles, its theme -- editable as Slide Master view + edits it, its shapes the same :class:`Shape` objects a slide has (``"M0.5"``). + + ``name`` is the master's own (``cSld@name``) or, when it has none, its theme's, as + PowerPoint shows it; :attr:`layouts` are its layouts in their order. + + For example:: + + master = deck.masters[0] + legal = next(s for s in master.shapes if "2026" in s.text) + legal.set_text(legal.text.replace("2026", "2027")) + """ + + name: str + part_path: str + _document: object = field(default=None, compare=False, hash=False, repr=False) + _key: str = field(default="", compare=False, hash=False, repr=False) + + @property + def layouts(self) -> list[Layout]: + """This master's layouts, in its order. + + For example:: + + [layout.name for layout in deck.masters[0].layouts] + """ + return [layout for layout in self._document.layouts + if layout.master_part == self.part_path] + + def add_layout(self, name: str = "Custom Layout", *, after: "Layout | str | None" = None + ) -> Layout: + """A new layout on this master, as Slide Master view's Insert Layout makes it. + See :meth:`Document.add_layout`. + + For example:: + + legal = deck.masters[0].add_layout("Legal") + """ + return self._document.add_layout(name, master=self, after=after) + + def __repr__(self) -> str: + return f"" _KINDS = {"sp": "shape", "pic": "picture", "cxnSp": "connector", "grpSp": "group", @@ -289,7 +519,41 @@ def _name_of(shape: Element) -> str: # -- reading ----------------------------------------------------------------------------- -def layouts(package: PresentationPackage) -> list[Layout]: +def template_key(prefix: str, node: Element, fallback: int) -> str: + """``L`` / ``M``: the ``sldLayoutId``/``sldMasterId`` less 2147483648 (a file + whose id is missing or below the schema's minimum gets ``fallback`` added to it).""" + from .ids import TEMPLATE_ID_BASE + + raw = node.get("id") or "" + value = int(raw) if raw.isdigit() else 0 + return f"{prefix}{value - TEMPLATE_ID_BASE if value >= TEMPLATE_ID_BASE else value + fallback}" + + +def masters(package: PresentationPackage, document=None) -> list[Master]: + """Every slide master, in ``sldMasterIdLst`` order.""" + presentation = package.presentation_part() + root = package.tree(presentation) + found: list[Master] = [] + for node in findall(root, "p:sldMasterIdLst/p:sldMasterId"): + part = package.related_part(presentation, node.get(qn("r:id"))) + master_root = package.tree(part) if part else None + if master_root is None: + continue + common = find(master_root, "p:cSld") + name = (common.get("name") if common is not None else None) or _theme_name(package, part) + found.append(Master(name, part, document, template_key("M", node, 1 << 20))) + return found + + +def _theme_name(package: PresentationPackage, master_part: str) -> str: + from ..oxml.package import REL_THEME + + themes = package.related_parts_of_type(master_part, REL_THEME) + root = package.tree(themes[0]) if themes else None + return (root.get("name") if root is not None else None) or "" + + +def layouts(package: PresentationPackage, document=None) -> list[Layout]: """Every layout, master by master, in each master's own order.""" presentation = package.presentation_part() root = package.tree(presentation) @@ -306,7 +570,8 @@ def layouts(package: PresentationPackage) -> list[Layout]: continue common = find(layout_root, "p:cSld") name = (common.get("name") if common is not None else None) or "" - found.append(Layout(name, part, master, package)) + found.append(Layout(name, part, master, package, document, + template_key("L", layout_id, 1 << 21))) return found @@ -590,6 +855,8 @@ def make_root(tag: str, namespaces: dict[str, str]) -> Element: "Layout", "LayoutPlaceholder", "LayoutShape", + "Master", + "masters", "Placeholder", "insert_entry", "layouts", diff --git a/src/pptx_agent/edit/table.py b/src/pptx_agent/edit/table.py index 08e7260..bd52e26 100644 --- a/src/pptx_agent/edit/table.py +++ b/src/pptx_agent/edit/table.py @@ -815,7 +815,7 @@ def _list_styles(self) -> list: shape = self._table._shape() package = shape._slide.document.package - master = inherit.master_of(package, inherit.layout_of(package, shape._slide.part_path)) + master = shape._slide._template_parts()[1] own = self._tc().find(f"{qn('a:txBody')}/{qn('a:lstStyle')}") return inherit.list_styles(package, master, own, None, None, False) diff --git a/src/pptx_agent/edit/templates.py b/src/pptx_agent/edit/templates.py new file mode 100644 index 0000000..fcad788 --- /dev/null +++ b/src/pptx_agent/edit/templates.py @@ -0,0 +1,567 @@ +"""Slide masters and layouts as PowerPoint's Slide Master view edits them, and Insert > +Header & Footer. + +A layout or master shape is edited through the same :class:`~pptx_agent.Shape` a slide's +is (:attr:`Layout.shapes `, :attr:`Master.shapes +`); this module changes the *list* of layouts and fills the +footer placeholders. What each writes was measured in PowerPoint 16 (Mac), and it writes +the same: + +* **Duplicate layout** (Slide Master view, Insert > Duplicate Layout): a copy of the part, + right after the original, named ``"1_"``; a new ``sldLayoutId``, one more than the + largest master or layout id; the master's relationship and the content type. +* **Insert layout**: ``preserve="1" userDrawn="1"`` and no ``type`` (a custom layout), a + title placeholder and the master's date, footer and slide-number placeholders with their + text; placed after the layout named, else last. +* **Rename**: ``cSld@name`` alone. +* **Delete**: PowerPoint disables it for a layout a slide uses; so does this, unless the + slides are moved to another layout first (``reassign_to``). The part, its relationship + and its ``sldLayoutId`` go; other ids stay. +* **Header & Footer, Apply to All**: the footer text and the date (an updating + ``datetime1`` field, or fixed text) on the master's and *every* layout's placeholders; + ```` on the master saying which are off (``hdr="0"`` always); on each slide the + layout's ``dt``, ``ftr`` and ``sldNum`` placeholders copied in (empty ``spPr``, so they + inherit position) or, turned off, removed; a title slide (a ``"title"`` layout's) left + out with ``showSpecialPlsOnTitleSld="0"``. **Apply** (some slides) changes only those + slides. + +Layout and master edits show on every slide built on them, so the tools report the slides +each one ``affects`` and check them. +""" + +from __future__ import annotations + +import copy +import uuid +from datetime import date as _date +from typing import TYPE_CHECKING, Iterable + +from ..oxml.package import REL_SLIDE_LAYOUT, REL_SLIDE_MASTER +from ..oxml.xml import ( + NAMESPACES, + Element, + append_in_order, + find, + findall, + local_name, + make, + qn, + register_child_order, + remove, + serialize, + subelement, +) +from .ids import TEMPLATE_ID_BASE + +if TYPE_CHECKING: # pragma: no cover + from .document import Document, Slide + from .slides import Layout, Master + +CT_SLIDE_LAYOUT = "application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml" + +register_child_order({ + "p:sldMaster": ("p:cSld", "p:clrMap", "p:sldLayoutIdLst", "p:transition", "p:timing", + "p:hf", "p:txStyles", "p:extLst"), + "p:sldLayoutIdLst": ("p:sldLayoutId", "p:extLst"), + "p:sldLayout": ("p:cSld", "p:clrMapOvr", "p:transition", "p:timing", "p:hf", "p:extLst"), +}) + +#: The Header & Footer placeholders, in the order PowerPoint appends them to a slide. +FOOTER_TYPES = ("dt", "ftr", "sldNum") +_GUID_NAMESPACE = uuid.UUID("6f1d3c52-8a7e-4a43-9a51-2d1c6c0e9b7d") + + +# -- the layout list --------------------------------------------------------------------------- + + +def _next_template_id(package) -> int: + """One more than the largest ``sldMasterId`` or ``sldLayoutId`` in the deck: they share + one id space (PowerPoint gives a duplicated layout max + 1).""" + used = [TEMPLATE_ID_BASE - 1] + presentation = package.tree(package.presentation_part()) + for node in findall(presentation, "p:sldMasterIdLst/p:sldMasterId"): + if (node.get("id") or "").isdigit(): + used.append(int(node.get("id"))) + master = package.related_part(package.presentation_part(), node.get(qn("r:id"))) + root = package.tree(master) if master else None + for entry in findall(root, "p:sldLayoutIdLst/p:sldLayoutId") if root is not None else (): + if (entry.get("id") or "").isdigit(): + used.append(int(entry.get("id"))) + return max(used) + 1 + + +def _entry_for(package, layout: "Layout") -> Element: + root = package.tree(layout.master_part) + for node in findall(root, "p:sldLayoutIdLst/p:sldLayoutId"): + if package.related_part(layout.master_part, node.get(qn("r:id"))) == layout.part_path: + return node + raise KeyError(f"{layout.part_path} is not in its master's layout list") + + +def _list_layout(document: "Document", master_part: str, part: str, + after: "Layout | None") -> str: + """Relate ``part`` to its master and list it after ``after`` (else last); returns the + new layout's key.""" + package = document.package + rel_id = package.add_relationship(master_part, REL_SLIDE_LAYOUT, part) + if not package.related_parts_of_type(part, REL_SLIDE_MASTER): + package.add_relationship(part, REL_SLIDE_MASTER, master_part) + identifier = _next_template_id(package) + node = make("p:sldLayoutId", id=str(identifier), r__id=rel_id) + root = package.tree(master_part) + id_list = subelement(root, "p:sldLayoutIdLst") + if after is not None: + _entry_for(package, after).addnext(node) + else: + entries = findall(id_list, "p:sldLayoutId") + if entries: + entries[-1].addnext(node) + else: + id_list.insert(0, node) + package.mark_dirty(master_part) + return f"L{identifier - TEMPLATE_ID_BASE}" + + +def _unique_name(document: "Document", master_part: str, wanted: str, *, + numbered: bool) -> str: + names = {layout.name for layout in document.layouts if layout.master_part == master_part} + if not numbered: + return wanted + k = 1 + while f"{k}_{wanted}" in names: + k += 1 + return f"{k}_{wanted}" + + +def _check_name(document: "Document", master_part: str, name: str, + skip: str | None = None) -> str: + name = str(name).strip() + if not name: + raise ValueError("a layout needs a name") + for layout in document.layouts: + if layout.master_part == master_part and layout.name == name \ + and layout.part_path != skip: + raise ValueError(f"the master already has a layout named {name!r} ({layout.id}); " + "layouts are found by name, so names stay unique") + return name + + +def duplicate_layout(document: "Document", layout: "Layout", name: str | None = None + ) -> "Layout": + package = document.package + target_name = _check_name(document, layout.master_part, name) if name is not None \ + else _unique_name(document, layout.master_part, layout.name, numbered=True) + with document.batch(): + document.history.checkpoint() + # Everything a layout relates to is shared: its master, its pictures. + part = package.copy_part(layout.part_path, share=lambda rel: True) + root = package.tree(part) + find(root, "p:cSld").set("name", target_name) + package.mark_dirty(part) + key = _list_layout(document, layout.master_part, part, layout) + document._reset_caches() + return document.layout(key) + + +def add_layout(document: "Document", name: str, master: "Master | None", + after: "Layout | None") -> "Layout": + package = document.package + if master is None: + master = after.master if after is not None else document.masters[0] + if after is not None and after.master_part != master.part_path: + raise ValueError(f"{after.id} belongs to {after.master.id}, not {master.id}") + name = _check_name(document, master.part_path, name) + with document.batch(): + document.history.checkpoint() + part = package.unused_part_name("ppt/slideLayouts/slideLayout{n}.xml") + package.add_part(part, _new_layout_xml(document, master, name), CT_SLIDE_LAYOUT, + override=True) + key = _list_layout(document, master.part_path, part, after) + document._reset_caches() + return document.layout(key) + + +def _new_layout_xml(document: "Document", master: "Master", name: str) -> bytes: + """A custom layout as Insert Layout makes one: a title, and the master's date, footer + and slide-number placeholders with their text, all inheriting their place.""" + from . import inherit + from .slides import make_root + + package = document.package + root = make_root("p:sldLayout", {p: NAMESPACES[p] for p in ("a", "r", "p")}) + root.set("preserve", "1") + root.set("userDrawn", "1") + common = subelement(root, "p:cSld") + common.set("name", name) + tree = subelement(common, "p:spTree") + group = subelement(tree, "p:nvGrpSpPr") + group.append(make("p:cNvPr", id="1", name="")) + group.append(make("p:cNvGrpSpPr")) + group.append(make("p:nvPr")) + xfrm = subelement(subelement(tree, "p:grpSpPr"), "a:xfrm") + for tag, attributes in (("a:off", {"x": "0", "y": "0"}), ("a:ext", {"cx": "0", "cy": "0"}), + ("a:chOff", {"x": "0", "y": "0"}), + ("a:chExt", {"cx": "0", "cy": "0"})): + xfrm.append(make(tag, **attributes)) + # idx and sz as the master's other layouts give them (10, 11, 12 in Office's own). + known: dict[str, Element] = {} + for layout in master.layouts: + for shape in document._template_sheet(layout.part_path)._sp_tree(): + ph = inherit.placeholder_of(shape) + if ph is not None and ph.get("type") in FOOTER_TYPES and ph.get("type") not in known: + known[ph.get("type")] = ph + next_id = 2 + for kind in ("title", *FOOTER_TYPES): + source = inherit.master_placeholder(package, master.part_path, inherit.master_kind(kind)) + if source is None: + continue + sp = make("p:sp") + nv = make("p:nvSpPr") + source_pr = find(source, "p:nvSpPr/p:cNvPr") + label = (source_pr.get("name") if source_pr is not None else None) or "Placeholder" + label = " ".join(label.split()[:-1]) if label.split()[-1].isdigit() else label + if kind == "title": + label = "Title" + nv.append(make("p:cNvPr", id=str(next_id), name=f"{label} {next_id - 1}")) + locks = make("p:cNvSpPr") + locks.append(make("a:spLocks", noGrp="1")) + nv.append(locks) + nv_pr = make("p:nvPr") + ph = make("p:ph", type=kind) + template = known.get(kind) + for attribute in ("sz", "idx"): + value = template.get(attribute) if template is not None else None + if value is None and kind != "title": + value = {"sz": {"dt": "half"}.get(kind, "quarter"), + "idx": str(10 + FOOTER_TYPES.index(kind))}[attribute] + if value is not None: + ph.set(attribute, value) + nv_pr.append(ph) + nv.append(nv_pr) + sp.append(nv) + sp.append(make("p:spPr")) + body = make("p:txBody") + body.append(make("a:bodyPr")) + body.append(make("a:lstStyle")) + paragraphs = findall(source, "p:txBody/a:p")[:1] + for paragraph in paragraphs: + body.append(copy.deepcopy(paragraph)) + if not paragraphs: + body.append(make("a:p")) + sp.append(body) + tree.append(sp) + next_id += 1 + color_map = subelement(root, "p:clrMapOvr") + color_map.append(make("a:masterClrMapping")) + return serialize(root) + + +def rename_layout(document: "Document", layout: "Layout", name: str) -> "Layout": + name = _check_name(document, layout.master_part, name, skip=layout.part_path) + package = document.package + with document.batch(): + document.history.checkpoint() + find(package.tree(layout.part_path), "p:cSld").set("name", name) + package.mark_dirty(layout.part_path) + document._reset_caches() + return document.layout(layout.part_path) + + +def delete_layout(document: "Document", layout: "Layout", + reassign_to: "Layout | None") -> list[int]: + """Delete ``layout``; returns the ``sldId`` of each slide moved to ``reassign_to``.""" + package = document.package + using = layout.slides + if using and reassign_to is None: + raise ValueError( + f"{layout.id} {layout.name!r} is in use by slide(s) " + f"{', '.join(f's:{s.slide_id}' for s in using)}; PowerPoint does not delete a " + "layout in use either. Move them to another layout (reassign_to) or delete them " + "first") + if reassign_to is not None and reassign_to.part_path == layout.part_path: + raise ValueError("reassign_to is the layout being deleted") + if len([lay for lay in document.layouts if lay.master_part == layout.master_part]) == 1: + raise ValueError(f"{layout.id} is its master's only layout") + moved = [] + with document.batch(): + document.history.checkpoint() + for slide in using: + for rel_id, rel in list(package.relationships(slide.part_path).items()): + if rel.type == REL_SLIDE_LAYOUT: + package.remove_relationship(slide.part_path, rel_id) + package.add_relationship(slide.part_path, REL_SLIDE_LAYOUT, reassign_to.part_path) + moved.append(slide.slide_id) + entry = _entry_for(package, layout) + rel_id = entry.get(qn("r:id")) + remove(entry) + package.mark_dirty(layout.master_part) + package.release(layout.master_part, [rel_id]) + document._reset_caches() + return moved + + +# -- Header & Footer --------------------------------------------------------------------------- + + +def _date_text(language: str, today: _date) -> str: + """``datetime1``'s text: the short date as the deck's language writes it.""" + if language.lower().startswith("en-us"): + return f"{today.month}/{today.day}/{today.year}" + return f"{today.day}/{today.month}/{today.year}" + + +def _guid(*parts: object) -> str: + return "{" + str(uuid.uuid5(_GUID_NAMESPACE, ":".join(map(str, parts)))).upper() + "}" + + +def _paragraph(sp: Element) -> Element: + body = subelement(sp, "p:txBody") + subelement(body, "a:bodyPr") + subelement(body, "a:lstStyle") + paragraphs = findall(body, "a:p") + for extra in paragraphs[1:]: + remove(extra) + return paragraphs[0] if paragraphs else subelement(body, "a:p") + + +def _clear(paragraph: Element) -> Element | None: + """Empty a paragraph but for its ``a:pPr``; returns the first run's (or field's) + ``a:rPr``, to keep its formatting.""" + keep = None + for child in list(paragraph): + if local_name(child) in ("r", "fld") and keep is None: + keep = child.find(qn("a:rPr")) + if local_name(child) not in ("pPr", "endParaRPr"): + remove(child) + return keep + + +def _insert_content(paragraph: Element, node: Element, language: str) -> None: + end = paragraph.find(qn("a:endParaRPr")) + if end is None: + end = make("a:endParaRPr", lang=language) + paragraph.append(end) + end.addprevious(node) + + +def _rpr(kept: Element | None, language: str, smart: bool) -> Element: + rpr = copy.deepcopy(kept) if kept is not None else make("a:rPr") + rpr.set("lang", rpr.get("lang") or language) + if smart: + rpr.set("smtClean", "0") + return rpr + + +def _write_footer_text(sp: Element, text: str, language: str) -> None: + paragraph = _paragraph(sp) + kept = _clear(paragraph) + if text: + run = make("a:r") + run.append(_rpr(kept, language, smart=False)) + subelement(run, "a:t").text = text + _insert_content(paragraph, run, language) + + +def _write_date(sp: Element, date: str, language: str, today: _date, part: str) -> None: + paragraph = _paragraph(sp) + old = paragraph.find(qn("a:fld")) + kind = old.get("type") if old is not None else None + kept = _clear(paragraph) + if date == "auto": + if not (kind or "").startswith("datetime") or kind == "datetimeFigureOut": + kind = "datetime1" + node = make("a:fld", id=_guid(part, "dt"), type=kind) + node.append(_rpr(kept, language, smart=True)) + subelement(node, "a:t").text = _date_text(language, today) + else: + node = make("a:r") + node.append(_rpr(kept, language, smart=False)) + subelement(node, "a:t").text = date + _insert_content(paragraph, node, language) + + +def _placeholders(tree: Element, kind: str) -> list[Element]: + from . import inherit + + return [shape for shape in tree if local_name(shape) == "sp" + and (inherit.placeholder_of(shape) is not None) + and inherit.placeholder_of(shape).get("type") == kind] + + +def _copy_to_slide(slide: "Slide", source: Element, kind: str, number: int, *, text: str, + date: str | None, language: str, today: _date) -> Element: + """A slide's ``dt``/``ftr``/``sldNum`` placeholder copied from its layout's, as Header & + Footer adds one: its name and ``p:ph``, an empty ``spPr`` (it inherits its place).""" + from . import inherit + + sp = make("p:sp") + nv = make("p:nvSpPr") + source_pr = find(source, "p:nvSpPr/p:cNvPr") + nv.append(make("p:cNvPr", id=str(slide._next_shape_id()), + name=(source_pr.get("name") if source_pr is not None else None) + or f"{kind} placeholder")) + locks = make("p:cNvSpPr") + locks.append(make("a:spLocks", noGrp="1")) + nv.append(locks) + nv_pr = make("p:nvPr") + ph = make("p:ph") + for attribute in ("type", "orient", "sz", "idx"): + value = inherit.placeholder_of(source).get(attribute) + if value is not None: + ph.set(attribute, value) + nv_pr.append(ph) + nv.append(nv_pr) + sp.append(nv) + sp.append(make("p:spPr")) + body = make("p:txBody") + body.append(make("a:bodyPr")) + body.append(make("a:lstStyle")) + paragraph = make("a:p") + body.append(paragraph) + sp.append(body) + if kind == "sldNum": + field = next(iter(source.iter(qn("a:fld"))), None) + node = make("a:fld", id=field.get("id") if field is not None else _guid( + slide.part_path, "sldNum"), type="slidenum") + node.append(make("a:rPr", lang=language, smtClean="0")) + subelement(node, "a:t").text = str(number) + paragraph.append(node) + paragraph.append(make("a:endParaRPr", lang=language)) + elif kind == "ftr": + _write_footer_text(sp, text, language) + else: + _write_date(sp, date, language, today, slide.part_path) + append_in_order(slide._sp_tree(), sp) + return sp + + +def footers_for_new_slide(document: "Document", slide: "Slide") -> bool: + """After Header & Footer's Apply to All a slide made with New Slide gets its layout's + date, footer and slide-number placeholders, content and all, for each the master's + ```` has on -- and none on a title slide when ``showSpecialPlsOnTitleSld`` is + ``0`` (measured: PowerPoint's New Slide). A master without ```` (Header & Footer + never applied) gives none. Returns whether any was added.""" + from . import inherit + + package = document.package + layout_part = inherit.layout_of(package, slide.part_path) + master_part = inherit.master_of(package, layout_part) + master = package.tree(master_part) if master_part else None + hf = master.find(qn("p:hf")) if master is not None else None + if hf is None: + return False + layout = package.tree(layout_part) + presentation = package.tree(package.presentation_part()) + if layout.get("type") == "title" and presentation.get("showSpecialPlsOnTitleSld") in ( + "0", "false"): + return False + tree = find(layout, "p:cSld/p:spTree") + added = False + for kind in FOOTER_TYPES: + if hf.get(kind) in ("0", "false") or _placeholders(slide._sp_tree(), kind): + continue + source = _placeholders(tree, kind) + if not source: + continue + sp = _copy_to_slide(slide, source[0], kind, slide.index + 1, text="", date="auto", + language=_language(document), today=_date.today()) + paragraph = find(sp, "p:txBody/a:p") + for child in list(paragraph): + remove(child) + for child in find(source[0], "p:txBody/a:p") if find(source[0], "p:txBody/a:p")\ + is not None else (): + paragraph.append(copy.deepcopy(child)) + if kind == "sldNum": + for text in paragraph.iter(qn("a:t")): + text.text = str(slide.index + 1) + added = True + if added: + slide._touch() + return added + + +def _language(document: "Document") -> str: + from .authoring import default_language + + return default_language(document.package) + + +def set_footer(document: "Document", *, text: str | None, slide_number: bool, + date: str | None, slides: "Iterable[Slide] | None", skip_title: bool, + today: _date | None) -> list[int]: + """See :meth:`Document.set_footer`; returns the ``sldId`` of every slide changed.""" + from . import inherit + from .authoring import default_language + + package = document.package + language = default_language(package) + today = today or _date.today() + text = text or "" + if date is not None and not str(date).strip(): + date = None + wanted = {"dt": date is not None, "ftr": bool(text), "sldNum": bool(slide_number)} + every = slides is None + targets = list(document.slides) if every else list(slides) + changed: list[int] = [] + with document.batch(): + document.history.checkpoint() + if every: + presentation = package.tree(package.presentation_part()) + if skip_title: + presentation.set("showSpecialPlsOnTitleSld", "0") + elif presentation.get("showSpecialPlsOnTitleSld") is not None: + del presentation.attrib["showSpecialPlsOnTitleSld"] + package.mark_dirty(package.presentation_part()) + for master in document.masters: + parts = [master.part_path, *[lay.part_path for lay in master.layouts]] + for part in parts: + tree = find(package.tree(part), "p:cSld/p:spTree") + for sp in _placeholders(tree, "ftr") if text else (): + _write_footer_text(sp, text, language) + for sp in _placeholders(tree, "dt") if date is not None else (): + _write_date(sp, date, language, today, part) + package.mark_dirty(part) + hf = subelement(package.tree(master.part_path), "p:hf") + for attribute in list(hf.attrib): + del hf.attrib[attribute] + for kind in ("sldNum", "hdr", "ftr", "dt"): + if not wanted.get(kind, False): + hf.set(kind, "0") + for slide in targets: + layout_part = inherit.layout_of(package, slide.part_path) + layout_tree = find(package.tree(layout_part), "p:cSld/p:spTree") \ + if layout_part else None + title_slide = layout_part is not None and \ + package.tree(layout_part).get("type") == "title" + tree = slide._sp_tree() + touched = False + for kind in FOOTER_TYPES: + existing = _placeholders(tree, kind) + show = wanted[kind] and not (skip_title and title_slide) + if not show: + for sp in existing: + remove(sp) + touched = True + continue + if existing: + for sp in existing: + if kind == "ftr": + _write_footer_text(sp, text, language) + elif kind == "dt": + _write_date(sp, date, language, today, slide.part_path) + touched = True + continue + source = _placeholders(layout_tree, kind) if layout_tree is not None else [] + if source: + _copy_to_slide(slide, source[0], kind, slide.index + 1, text=text, + date=date, language=language, today=today) + touched = True + if touched: + slide._touch() + changed.append(slide.slide_id) + document._reset_caches() + return changed + + +__all__ = ["CT_SLIDE_LAYOUT", "FOOTER_TYPES", "add_layout", "delete_layout", "duplicate_layout", + "footers_for_new_slide", "rename_layout", "set_footer"] diff --git a/src/pptx_agent/edit/text.py b/src/pptx_agent/edit/text.py index e4552b6..2f4d074 100644 --- a/src/pptx_agent/edit/text.py +++ b/src/pptx_agent/edit/text.py @@ -229,6 +229,14 @@ def _after_change(self) -> None: ... def _batch(self) -> ContextManager[None]: ... +def _text_written(host) -> None: + """Tell the host its text (not its formatting) changed: a layout placeholder marks + its text as a custom prompt then, as PowerPoint does when one types into it.""" + hook = getattr(host, "_text_written", None) + if hook is not None: + hook() + + @contextmanager def _editing(host: TextHost) -> Iterator[None]: """One undo step: batch, checkpoint, edit, mark the part dirty.""" @@ -388,6 +396,7 @@ def set_text(self, value: "str | TextSpec") -> "TextFrame": if spec is not None: with self._resolve()._batch(): apply_spec(self, spec) + _text_written(self._resolve()) return self value = str(value) _warn_if_escaped(value, self.text, self.address) @@ -396,6 +405,7 @@ def set_text(self, value: "str | TextSpec") -> "TextFrame": body = host._text_body(True) assert body is not None _replace_body_text(body, value) + _text_written(host) return self def add_paragraph(self, text: str = "", *, index: int | None = None, @@ -737,6 +747,7 @@ def text(self, value: str) -> None: _warn_if_escaped(value, self.text, self.address) with self._change() as element: _rebuild_paragraph(element, value) + _text_written(self._frame._host()) @property def runs(self) -> list["Run"]: @@ -1293,6 +1304,7 @@ def text(self, value: str) -> None: _warn_if_escaped(value, self.text, self.address) with self._change() as element: subelement(element, "a:t").text = value + _text_written(self._paragraph._frame._host()) def delete(self) -> None: """Remove the run. diff --git a/src/pptx_agent/edit/theme.py b/src/pptx_agent/edit/theme.py index 9fdaff3..a5779f9 100644 --- a/src/pptx_agent/edit/theme.py +++ b/src/pptx_agent/edit/theme.py @@ -309,8 +309,7 @@ def theme_fill_style(slide, index: int) -> Element | None: the format scheme's ``fillStyleLst``, 1001 and up its ``bgFillStyleLst``; ``None`` for 0 (no fill) or past the list.""" package = slide.document.package - layout = inherit.layout_of(package, slide.part_path) - master = inherit.master_of(package, layout) or first_master(package) + master = slide._template_parts()[1] or first_master(package) part = theme_part(package, master) if master else None root = package.tree(part) if part else None if root is None or index <= 0: @@ -335,8 +334,8 @@ def theme_for(target) -> Theme: target = target._slide if isinstance(target, Slide): package = target.document.package - layout = inherit.layout_of(package, target.part_path) - master = inherit.master_of(package, layout) or first_master(package) + layout, master = target._template_parts() + master = master or first_master(package) overrides = [] for part in (layout, target.part_path): root = package.tree(part) if part else None diff --git a/src/pptx_agent/outline/read.py b/src/pptx_agent/outline/read.py index a7dec71..3cf8e3f 100644 --- a/src/pptx_agent/outline/read.py +++ b/src/pptx_agent/outline/read.py @@ -60,7 +60,9 @@ class TextBlock: shape's line breaks, which ``Shape.text`` reads as ``"\n"``. ``kind`` is ``"title"``, ``"text"``, ``"cell"``, ``"smartart"`` (a diagram node, ``/node``) or ``"notes"`` (the slide's speaker notes, ``/notes``, a :class:`TextFrame`); - ``slide`` is the 1-based slide number:: + on a layout, ``"prompt"`` too (a placeholder's custom prompt). ``slide`` is the 1-based + slide number -- ``0`` for text on a layout or master, whose ``part`` is then its address + (``"L2"``, ``"M0"``; ``None`` on a slide):: for block in deck.outline_blocks(): print(block.address, block.text) # 256.29 11.9% @@ -70,50 +72,84 @@ class TextBlock: kind: str text: str slide: int + part: "str | None" = None -def outline_blocks(document: "Document", slides=None) -> list[TextBlock]: +#: :meth:`Document.find_text`'s scopes: where text is looked for. +SCOPES = ("slides", "layouts", "masters", "all") + + +def outline_blocks(document: "Document", slides=None, scope: str = "slides" + ) -> list[TextBlock]: """See :meth:`pptx_agent.edit.document.Document.outline_blocks`.""" + if scope not in SCOPES: + raise ValueError(f"scope is one of {', '.join(SCOPES)}, not {scope!r}") + out: list[TextBlock] = [] + if scope in ("slides", "all"): + for slide in select(document, slides): + out += _blocks(slide, slide.index + 1, None) + if scope in ("masters", "all"): + for master in document.masters: + out += _blocks(master._sheet(), 0, master.id) + if scope in ("layouts", "all"): + for layout in document.layouts: + out += _blocks(layout._sheet(), 0, layout.id) + return out + + +def _blocks(slide, number: int, part: "str | None") -> list[TextBlock]: + """One slide's (or layout's, or master's) text blocks, title first.""" out: list[TextBlock] = [] - for slide in select(document, slides): - number = slide.index + 1 - shapes = _flatten(_top(slide)) - title = next((s for s in shapes if s.kind == "shape" and s.placeholder is not None - and s.placeholder[0] in TITLE_TYPES), None) - ordered = ([title] if title is not None else []) + [s for s in shapes if s is not title] - for shape in ordered: - kind = local_name(shape._element) - if kind == "sp": - text = shape.text_frame.text + shapes = _flatten(_top(slide)) + title = next((s for s in shapes if s.kind == "shape" and s.placeholder is not None + and s.placeholder[0] in TITLE_TYPES), None) + ordered = ([title] if title is not None else []) + [s for s in shapes if s is not title] + for shape in ordered: + kind = local_name(shape._element) + if kind == "sp": + text = shape.text_frame.text + if text and part is not None and _fields_only(shape._element): + continue # a date or number PowerPoint fills, not text + if text: + kind_name = "title" if shape is title else ( + "prompt" if part and shape.prompt is not None else "text") + out.append(TextBlock(shape.id, kind_name, text, number, part)) + elif kind == "graphicFrame" and shape.has_table: + table = shape.table + for cell in table.iter_cells(): + if cell.is_spanned: + continue + text = cell.text if text: - out.append(TextBlock(shape.id, "title" if shape is title else "text", text, - number)) - elif kind == "graphicFrame" and shape.has_table: - table = shape.table - for cell in table.iter_cells(): - if cell.is_spanned: - continue - text = cell.text - if text: - out.append(TextBlock(cell.address, "cell", text, number)) - elif kind == "graphicFrame" and shape.has_diagram: - for k, node in enumerate(shape.diagram.nodes): - if node.text: - out.append(TextBlock(f"{shape.id}/node{k}", "smartart", node.text, - number)) - notes = slide.notes - if notes: - out.append(TextBlock(f"{slide.slide_id}/notes", "notes", notes, number)) + out.append(TextBlock(cell.address, "cell", text, number, part)) + elif kind == "graphicFrame" and shape.has_diagram: + for k, node in enumerate(shape.diagram.nodes): + if node.text: + out.append(TextBlock(f"{shape.id}/node{k}", "smartart", node.text, + number, part)) + notes = slide.notes if part is None else "" + if notes: + out.append(TextBlock(f"{slide.slide_id}/notes", "notes", notes, number)) return out -def find_text(document: "Document", needle, slides=None) -> list[TextBlock]: +def _fields_only(shape) -> bool: + """Whether a shape's text is all fields (``a:fld``: a date, a slide number), with no + run of its own.""" + fields = list(shape.iter(qn("a:fld"))) + return bool(fields) and not any((node.text or "").strip() + for run in shape.iter(qn("a:r")) + for node in run.iter(qn("a:t"))) + + +def find_text(document: "Document", needle, slides=None, scope: str = "slides" + ) -> list[TextBlock]: """See :meth:`pptx_agent.edit.document.Document.find_text`.""" if isinstance(needle, re.Pattern): - return [b for b in outline_blocks(document, slides) if needle.search(b.text)] + return [b for b in outline_blocks(document, slides, scope) if needle.search(b.text)] if not isinstance(needle, str) or not needle: raise TypeError("find_text takes a non-empty string or a compiled re.Pattern") - return [b for b in outline_blocks(document, slides) if needle in b.text] + return [b for b in outline_blocks(document, slides, scope) if needle in b.text] def select(document: "Document", slides) -> list["Slide"]: diff --git a/src/pptx_agent/oxml/package.py b/src/pptx_agent/oxml/package.py index 56e36a0..5ab8125 100644 --- a/src/pptx_agent/oxml/package.py +++ b/src/pptx_agent/oxml/package.py @@ -29,6 +29,7 @@ REL_NOTES_MASTER = _REL + "notesMaster" REL_HYPERLINK = _REL + "hyperlink" REL_CHART = _REL + "chart" +REL_THEME = _REL + "theme" CT_SLIDE = "application/vnd.openxmlformats-officedocument.presentationml.slide+xml" diff --git a/src/pptx_agent/tools/GUIDANCE.md b/src/pptx_agent/tools/GUIDANCE.md index 8a5145c..e787580 100644 --- a/src/pptx_agent/tools/GUIDANCE.md +++ b/src/pptx_agent/tools/GUIDANCE.md @@ -107,6 +107,30 @@ if left: Validation is already a gate: `save_document` refuses a deck with new validation problems, and only the application can allow them (`Toolbox(allow_new_problems=True)`). +## Masters and layouts: when to edit them, and `affects` + +Text that every slide shows -- a legal line, the footer's year and entity, a classification +mark, a placeholder's prompt -- lives on the slide master or a layout, not on the slides. +`describe` lists it (`masters[].text`, `layouts[].text`, prompts on the placeholders) and +`find_text` / `replace_text` reach it with `stories: all` (addresses `M0.7`, `L2.5`). + +- **Edit the master or layout when the change is the deck's, not one slide's**: next year's + footer, the legal text in the deck's language, prompt text, a logo. One edit, every slide. + Edit a slide when only that slide should differ: a slide's own placeholder text and + formatting override the layout's and stay as they are. +- **Header & Footer is `ppt_edit_master set_footer`**, not text typed into slides: it fills + the master's and layouts' placeholders and puts the layout's footer, number and date + placeholders on each slide, as PowerPoint does, so a later layout change still reaches them. +- **Every result says which slides an edit `affects`** (`checks.affects`: `{"M0": ["s:256", + ...]}`) and runs its fit, collision and empty-placeholder checks on all of them -- a longer + legal line can collide on one slide only. Render one or two of them; the layout itself has + no render of its own. +- **Deleting what slides inherit from is refused**: a layout placeholder slides take their + position and format from, a master placeholder layouts take theirs from, a layout in use + (move its slides first: `delete_layout` with `reassign_to`). +- **Undo**: plain `undo` takes back a master or layout edit like any other. Undo by slide + (`scope`) refuses one (`entangled`), because it is every slide's change, not that slide's. + ## Model routing | Work | Model | Evidence (main arm, per run) | diff --git a/src/pptx_agent/tools/README.md b/src/pptx_agent/tools/README.md index c554db7..68964ca 100644 --- a/src/pptx_agent/tools/README.md +++ b/src/pptx_agent/tools/README.md @@ -35,7 +35,7 @@ ooxml-edit's Responses round-trip test). Chat Completions has not been run live. | `ppt_graphics` | `ppt_add_shape`, `ppt_add_connector`, `ppt_arrange`, `ppt_design_facts` | | `ppt_objects` | `ppt_add_picture`, `ppt_add_table`, `ppt_edit_table` | | `ppt_review` | `ppt_comments` | -| `ppt_slides` | `ppt_add_slide`, `ppt_draft_slides`, `ppt_manage_slides`, `ppt_set_theme` | +| `ppt_slides` | `ppt_add_slide`, `ppt_draft_slides`, `ppt_manage_slides`, `ppt_set_theme`, `ppt_edit_master` | ## Setup (every provider) diff --git a/src/pptx_agent/tools/SUPPORTED.md b/src/pptx_agent/tools/SUPPORTED.md index f75712f..ef7e7f6 100644 --- a/src/pptx_agent/tools/SUPPORTED.md +++ b/src/pptx_agent/tools/SUPPORTED.md @@ -11,13 +11,13 @@ facts about the slides it touched (see [GUIDANCE.md](GUIDANCE.md)). | Open a deck or template; a new deck, from nothing or from a template | `open_document`, `new_document` | inputs are blobs the application registered; a template's slides are dropped from a new deck | | Save as a deck, a template or a text outline | `save_document` (`pptx`, `potx`, `outline`) | refuses new validation problems; lists overflows, collisions and empty placeholders still there | | Close a document | `close_document` | the session holds a few documents at a time | -| The deck at a glance | `describe` | slides with ids, titles, layouts and content areas; theme colours, fonts, roles and tints; the deck's table styles (its default, its own, how the built-in names go); layouts and their placeholders; comment counts | +| The deck at a glance | `describe` | slides with ids, titles, layouts and content areas; theme colours, fonts, roles and tints; the deck's table styles (its default, its own, how the built-in names go); masters and layouts with their ids (`M0`, `L2`), placeholders (and their ids and custom prompts) and the text on them that is not a placeholder (a legal line); comment counts | | Read slides | `ppt_read_slides` (`outline`, `geometry`, `svg`) | outline Markdown with ids; every shape's box, fill, outline and text; a compact SVG view | | Read a text input (CSV, Markdown, plain text) | `read_blob` | a page at a time | -| Find and replace text | `find_text`, `replace_text` | slides, notes, tables and SmartArt; `expect: one` or `all` | +| Find and replace text | `find_text`, `replace_text` | slides, notes, tables and SmartArt; `expect: one` or `all`; `stories: layouts`, `masters` or `all` reach the text on layouts and masters (a footer year, a legal line, a prompt), addresses `L2.5`, `M0.7` | | Look at slides | `render` | PNG, at most 4 a call; says which text the image leaves out for want of a font (`missing_glyphs`) | | Fit, collisions, validation, design facts | `check` | overflows, collisions (`boxes` too), shapes off the slide, empty placeholders ("Click to add text"), problem facts, design facts, validation | -| Undo and redo | `undo` | one call is one step; refs come back with the state | +| Undo and redo | `undo` | one call is one step; refs come back with the state; undo by slide (`scope`) refuses a change to a layout or master the slide shows (`entangled`: other slides show it too) | | Several calls as one step | `batch` | in order, all or nothing, checks once at the end | | Slides from a layout, with title, body and notes | `ppt_add_slide` | the deck's own layouts and placeholders | | Slides from a Markdown outline | `ppt_draft_slides` | into the deck's layouts | @@ -35,6 +35,10 @@ facts about the slides it touched (see [GUIDANCE.md](GUIDANCE.md)). | Charts: new from data; read; values, categories, series, titles, axis titles, legend, data labels, gap width, axis scales, gridlines, series and point colours and markers, a column series as a line, chart text, point labels | `edit_chart` | column, stacked column, bar, stacked bar, line, pie, scatter, radar (lines), filled radar, bubble, as PowerPoint inserts each; point labels from the workbook as PowerPoint's Value From Cells; the embedded workbook changes with the chart | | SmartArt: node text, adding and removing nodes | `edit_smartart` | diagrams already in the deck; PowerPoint lays the diagram out again on opening | | The theme's colours and fonts | `ppt_set_theme` | every theme reference in the deck follows | +| Slide master and layout shapes: text, formatting, fill, position, size, delete, pictures; new text boxes, shapes, pictures (SVG too), connectors and tables on them | `ppt_set_text`, `ppt_format_text`, `ppt_set_shape`, `ppt_arrange`, `ppt_add_shape`, `ppt_add_picture`, `ppt_add_connector`, `ppt_add_table` with addresses `L2.5`, `M0.7` (`slide: L2` or `M0` to add) | the same edits a slide's shapes take; every result lists the slides it `affects` and checks them; a slide's own placeholder text and overrides stay; deleting a layout or master placeholder that slides or layouts inherit from is refused, with the list | +| A layout placeholder's prompt text ("Typ hier uw bevinding") | `ppt_edit_master` `set_prompt` | the first level (`\n`: the next ones), formatting and levels kept, `hasCustomPrompt` set, as typing it in Slide Master view does; shown in an empty placeholder while editing, never in a show or export | +| Header & Footer: footer text, slide number, date (updating or fixed), not on title slides | `ppt_edit_master` `set_footer` | Apply to All (masters, every layout and every slide, as PowerPoint writes it) or Apply (`slides`); turning one off removes it from the slides | +| Layouts: add (a custom layout), duplicate, rename, delete | `ppt_edit_master` `add_layout`, `duplicate_layout`, `rename_layout`, `delete_layout` | as Slide Master view does; a layout in use is not deleted unless its slides move to another (`reassign_to`); names stay unique on a master; ids (`L2`) are the file's own and PowerPoint keeps them | | Document properties | `set_properties` | title, author, language, subject | | Comments: threads on a slide or a shape, replies, resolving | `ppt_comments` (`list`, `add`, `reply`, `resolve`, `reopen`, `edit`, `delete`) | PowerPoint's modern (threaded) comments | | Design facts of a slide | `ppt_design_facts` | palette, sets of like shapes and legends, empty regions, alignment, vocabulary, text sizes, lines over text: facts, no verdicts | @@ -52,6 +56,8 @@ facts about the slides it touched (see [GUIDANCE.md](GUIDANCE.md)). - **Hyperlinks:** no links between slides, and no listing or editing of links. (A web address can be put on a run of text through the text spec's `hyperlink`; nothing more.) - **Action buttons** and other click or hover actions. -- **Slide master and layout editing:** using the deck's existing layouts is supported; - changing a master or a layout is not (the theme's colours and fonts can be set). +- **Masters themselves:** a master's shapes, its layouts and Header & Footer can be edited + (above); adding or deleting a slide master, the master's text styles (the levels' sizes and + bullets) and backgrounds are not. Text fit is measured on the slides a layout or master + edit affects, not on the layout itself. - The older, non-threaded comments (`ppt/comments/comment.xml`) are not read or written. diff --git a/src/pptx_agent/tools/__init__.py b/src/pptx_agent/tools/__init__.py index c7724ca..6d101d2 100644 --- a/src/pptx_agent/tools/__init__.py +++ b/src/pptx_agent/tools/__init__.py @@ -70,6 +70,7 @@ import io import zipfile +from ooxml_edit.opc import rels_path_for from ooxml_edit.tools import CORE, DocumentFormat, ToolGroup, shared from ..edit.chart import ChartDataError, ChartDataWarning @@ -89,7 +90,8 @@ - Addresses: s:256 a slide (its id; it never changes; slide numbers 1, 2... are \ positions). 256.5 a shape (256.5#2 when the deck numbered two shapes alike), 256.5/p1 a \ paragraph, 256.5/p1/r0 a run, 256.7/cell1,2 a table cell (row, column from 0), 256/notes \ -the speaker notes. The text tools take all of these. +the speaker notes. L2.5, M0.7: a layout's or master's shape; an edit there shows on every \ +slide built on it (checks.affects). The text tools take all of these. - Positions and sizes are points from the slide's top-left corner. - Text: text keeps the formatting of what it replaces; paragraphs (the text spec) states \ runs, bullets and frame. To size a box, measure first: ppt_add_shape with measure true \ @@ -163,8 +165,21 @@ def _slide_scope(deck, scope: str) -> list[str]: number = slide_id_of(str(scope)) target = slide(deck, str(number) if number is not None else str(scope), field="scope") - return deck.package.reachable_parts( + own = deck.package.reachable_parts( target.part_path, lambda rel: not rel.type.endswith(_SHARED_RELATIONSHIPS)) + # Its layout and master too, so the latest change to what the slide shows is found -- + # and refused as entangled when it was a layout's or master's (_template_parts). + layout, master = target._template_parts() + return own + [part for path in (layout, master) if path + for part in (path, rels_path_for(path))] + + +def _template_parts(deck) -> list[str]: + """``undo``'s shared parts on a deck: every layout and master (and their + relationships), which every slide on them shows -- a change to one is no one slide's + to take back (``DocumentFormat.undo_shared``).""" + return [part for template in [*deck.masters, *deck.layouts] + for part in (template.part_path, rels_path_for(template.part_path))] FORMAT = DocumentFormat( @@ -175,7 +190,8 @@ def _slide_scope(deck, scope: str) -> list[str]: errors={LabelError: ("label_not_found", lambda exc: exc.candidates[:50]), ChartDataError: "refused", FullStateError: "refused"}, summary=_summary, checks=checks, strict_first=STRICT_FIRST, - undo_scope=_slide_scope, restored=lambda deck: deck._reset_caches(), + undo_scope=_slide_scope, undo_shared=_template_parts, + restored=lambda deck: deck._reset_caches(), configure=_configure) GROUPS = [*shared.GROUPS, diff --git a/src/pptx_agent/tools/common.py b/src/pptx_agent/tools/common.py index 1c0e366..31765cf 100644 --- a/src/pptx_agent/tools/common.py +++ b/src/pptx_agent/tools/common.py @@ -66,6 +66,38 @@ def slide(deck, address: str, *, field: str = "slide"): valid_options=[f"s:{s.slide_id}" for s in deck.slides][:50]) from None +def template_key_of(address: str) -> str | None: + """The layout or master an address is on: ``L2``, ``L2.5``, ``M0.7/p0`` -> ``L2``, + ``L2``, ``M0``; ``None`` for a slide's.""" + from ..edit.ids import is_template_key + + head = str(address).split("/")[0].split(".")[0] + return head if is_template_key(head) else None + + +def host(deck, address: str, *, field: str = "slide"): + """Where new shapes go: a slide (``s:256``), or a layout (``L2``) or master (``M0``), + whose shapes every slide built on it shows.""" + key = template_key_of(address) + if key is None: + return slide(deck, address, field=field) + try: + return deck._template_by_key(key)._sheet() + except KeyError: + raise ToolError("not_found", f"no layout or master {address!r}", field=field, + valid_options=template_options(deck)) from None + + +def template_options(deck) -> list[str]: + return [f"{t.id} ({t.name})" for t in [*deck.masters, *deck.layouts]][:50] + + +def place(target) -> str: + """A slide's or a layout's (master's) address as results print it: ``s:256``, ``L2``.""" + sid = target.slide_id + return sid if isinstance(sid, str) else f"s:{sid}" + + def resolve(deck, address: str, *, field: str = "target"): """What ``address`` names (a shape, paragraph, run, cell, notes); ``not_found`` lists the shapes on its slide.""" @@ -86,6 +118,13 @@ def shape(deck, address: str, *, field: str = "target"): def _shapes_near(deck, address: str) -> list[str]: + key = template_key_of(address) + if key is not None: + try: + target = deck._template_by_key(key) + except KeyError: + return template_options(deck) + return [f"{s.id} ({s.kind}{': ' + s.name if s.name else ''})" for s in target.shapes][:50] sid = slide_id_of(address) try: target = deck.slide(f"s:{sid}") if sid is not None else None @@ -411,9 +450,14 @@ def checks(entry, touched) -> dict[str, Any]: and the validation delta against the document as opened.""" deck = entry.document order = {s.slide_id: n for n, s in enumerate(deck.slides)} - slide_ids = sorted({sid for sid in touched if isinstance(sid, int) and sid in order}, - key=order.get) + affects = _affects(deck, touched) + wanted = {sid for sid in touched if isinstance(sid, int)} + for sids in affects.values(): + wanted.update(int(sid[2:]) for sid in sids) + slide_ids = sorted({sid for sid in wanted if sid in order}, key=order.get) out: dict[str, Any] = {"slides": [f"s:{sid}" for sid in slide_ids]} + if affects: + out["affects"] = affects out.update(facts(deck, slide_ids)) layout = layout_json(deck, [t for t in touched if isinstance(t, str)]) if layout: @@ -422,6 +466,21 @@ def checks(entry, touched) -> dict[str, Any]: return out +def _affects(deck, touched) -> dict[str, list[str]]: + """For each layout or master a call changed (``L2``, ``M0``): the slides that show the + change, which the checks then measure.""" + out: dict[str, list[str]] = {} + for item in touched: + key = template_key_of(item) if isinstance(item, str) else None + if key is None or key in out: + continue + try: + out[key] = [f"s:{s.slide_id}" for s in deck._template_by_key(key).slides] + except KeyError: + continue # deleted by the call + return out + + def layout_json(deck, shapes: Iterable[str]) -> list[dict[str, str]]: """Layout facts about ``shapes`` (addresses), slide by slide, at most :data:`LAYOUT_LIMIT`: near-alignment, uneven gaps, outlier text sizes, labels far from @@ -454,6 +513,9 @@ def touch_address(call, address: str) -> None: call.touch(sid) if "." in str(address).split("/")[0]: call.touch(str(address).split("/")[0]) + key = template_key_of(address) + if key is not None: + call.touch(key) def touch_shape(call, shape) -> None: @@ -491,6 +553,6 @@ def color_json(color, shape=None) -> dict[str, str] | None: return {k: v for k, v in data.items() if v is not None} -__all__ = ["KIND", "box_emu", "box_pt", "checks", "emu", "facts", "fit_json", "layout_json", - "library_errors", "pt", "resolve", "shape", "slide", "slide_id_of", "spec_of", - "text_params", "write_text"] +__all__ = ["KIND", "box_emu", "box_pt", "checks", "emu", "facts", "fit_json", "host", + "layout_json", "library_errors", "place", "pt", "resolve", "shape", "slide", + "slide_id_of", "spec_of", "template_key_of", "text_params", "write_text"] diff --git a/src/pptx_agent/tools/read.py b/src/pptx_agent/tools/read.py index 8a7f046..7bdc560 100644 --- a/src/pptx_agent/tools/read.py +++ b/src/pptx_agent/tools/read.py @@ -30,10 +30,40 @@ def describe(call, doc): roles = theme.roles ramps = {slot: [t.color for t in tints] for slot, tints in theme.ramps.items()} layouts = [] + several = len(deck.masters) > 1 for layout in deck.layouts: - layouts.append({"name": layout.name, "placeholders": [ - {"type": p.type or "content", "idx": p.idx, "box": box_pt(p.bounds)} - for p in layout.placeholders]}) + shapes = layout.shapes + placeholders = [] + for p, shape in zip(layout.placeholders, + [s for s in shapes if s.placeholder is not None + and s.parent_group is None]): + entry = {"id": shape.id, "type": p.type or "content", "idx": p.idx, + "box": box_pt(p.bounds)} + if shape.prompt is not None: + entry["prompt"] = shape.prompt + placeholders.append(entry) + item = {"id": layout.id, "name": layout.name, "placeholders": placeholders} + if several: + item["master"] = layout.master.id + text = _own_text(shapes) + if text: + item["text"] = text + layouts.append(item) + masters = [] + for master in deck.masters: + item = {"id": master.id, "name": master.name} + text = _own_text(master.shapes) + if text: + item["text"] = text + footer = {} + for kind, label in (("ftr", "footer"), ("dt", "date")): + shape = next((s for s in master.shapes if s.placeholder is not None + and s.placeholder.type == kind), None) + if shape is not None and shape.text: + footer[label] = {"id": shape.id, "text": shape.text} + if footer: + item["footer"] = footer + masters.append(item) data = { "doc": doc, "size": {"w": pt(width), "h": pt(height)}, "slides": slides, "theme": {"colors": dict(theme.colors), "fonts": {"major": theme.fonts.major, @@ -41,6 +71,7 @@ def describe(call, doc): "roles": {"primary": roles.primary, "secondary": roles.secondary, "neutral": roles.neutral, "highlight": roles.highlight}, "tints": ramps, "table_styles": _table_styles(deck)}, + "masters": masters, "layouts": layouts, "properties": {"title": deck.title, "author": deck.author, "language": deck.language}, "baseline_problems": [str(p) for p in call.entry.baseline_problems], @@ -53,6 +84,13 @@ def describe(call, doc): return Result(summary=f"{len(slides)} slide(s), {len(layouts)} layout(s)", data=data) +def _own_text(shapes) -> list[dict[str, str]]: + """A layout's or master's text that is not a placeholder -- a legal line, a + classification mark -- which every slide built on it shows.""" + return [{"id": shape.id, "text": shape.text} for shape in shapes + if shape.placeholder is None and shape.kind == "shape" and shape.text.strip()] + + def _table_styles(deck) -> dict[str, Any]: """The deck's table styles in brief: its default, its own (a template's), and how the built-in names go.""" diff --git a/src/pptx_agent/tools/shapes.py b/src/pptx_agent/tools/shapes.py index f392015..44cc918 100644 --- a/src/pptx_agent/tools/shapes.py +++ b/src/pptx_agent/tools/shapes.py @@ -18,7 +18,7 @@ #: The dash styles offered (the library takes all of ECMA's eleven). DASHES = ["solid", "dash", "dot", "dashDot", "lgDash", "sysDash", "sysDot"] from .common import (ANCHOR, AUTOFIT, KIND, box_emu, box_pt, emu, frame_of, insets_param, - library_errors, pt, resolve, shape as shape_at, slide as slide_at, + library_errors, place, pt, resolve, shape as shape_at, host as slide_at, spec_of, text_params, write_text) from .text import COMMON_PRESETS, preset_of @@ -161,7 +161,7 @@ def ppt_add_shape(call, doc, slide, items, measure=False, key=None): if item.get("fit_height"): fits[shape.id] = {"h": pt(shape.height)} call.touch(target.slide_id, *made) - return Result(summary=f"Added {len(made)} shape(s) to s:{target.slide_id}", created=made, + return Result(summary=f"Added {len(made)} shape(s) to {place(target)}", created=made, data={"heights": fits} if fits else None) diff --git a/src/pptx_agent/tools/shared_tools.py b/src/pptx_agent/tools/shared_tools.py index 6a418ad..c45ae74 100644 --- a/src/pptx_agent/tools/shared_tools.py +++ b/src/pptx_agent/tools/shared_tools.py @@ -12,7 +12,8 @@ from ooxml_edit.tools import Result, ToolError, collect_warnings, page_list, shared -from .common import KIND, facts, library_errors, numbers, slide_id_of, validate_delta +from .common import (KIND, facts, library_errors, numbers, slide_id_of, template_key_of, + validate_delta) from ..edit.document import font_dirs_of from .render import png_size, render_slides_reporting @@ -103,6 +104,40 @@ def _pattern(text: str, regex: bool): field="text") from None +#: ``stories`` on a deck: where find_text and replace_text look. ``body`` (the default) is +#: the slides; ``layouts``, ``masters`` and ``all`` add the text that shows on every slide +#: built on them (a legal line, a footer year, a placeholder's prompt). +_DECK_STORIES = {None: "slides", "body": "slides", "layouts": "layouts", "masters": "masters", + "all": "all"} + + +def _scope(stories: str | None) -> str: + if stories not in _DECK_STORIES: + raise ToolError("invalid_arguments", f"stories is {stories!r}; a deck takes " + "body (its slides), layouts, masters or all", field="stories", + valid_options=["body", "layouts", "masters", "all"]) + return _DECK_STORIES[stories] + + +def _blocks(deck, slides, stories): + scope = _scope(stories) + if scope in ("layouts", "masters") and slides is not None: + raise ToolError("invalid_arguments", f"slides narrows the slides; stories={stories} " + "searches no slides", field="slides") + chosen = numbers(deck, slides) if scope in ("slides", "all") else None + return deck.outline_blocks(chosen, scope=scope) + + +def _match(block, start: int, end: int) -> dict[str, Any]: + data = {"address": block.address, "kind": block.kind} + if block.part is not None: + data["on"] = block.part + else: + data["slide"] = block.slide + data["context"] = _context(block.text, start, end) + return data + + def _context(text: str, start: int, end: int, width: int = 40) -> str: left = max(0, start - width) right = min(len(text), end + width) @@ -116,17 +151,14 @@ def find_text(call, doc, text, regex=False, slides=None, range=None, stories=Non cursor=None): if range is not None: raise _deck_only("range", "slides") - if stories is not None: - raise _deck_only("stories", "slides") deck = call.document pattern = _pattern(text, regex) matches = [] - for block in deck.outline_blocks(numbers(deck, slides)): + for block in _blocks(deck, slides, stories): for found in pattern.finditer(block.text): if found.end() == found.start(): continue - matches.append({"address": block.address, "kind": block.kind, "slide": block.slide, - "context": _context(block.text, found.start(), found.end())}) + matches.append(_match(block, found.start(), found.end())) page, total, next_cursor = page_list(matches, cursor=cursor, limit=call.limits.max_list_items) return Result(summary=f"{total} match(es) for {text!r}", data=page, total=total, next_cursor=next_cursor) @@ -138,18 +170,23 @@ def replace_text(call, doc, find, replace, expect, regex=False, slides=None, ran stories=None): if range is not None: raise _deck_only("range", "slides") - if stories is not None: - raise _deck_only("stories", "slides") deck = call.document pattern = _pattern(find, regex) hits = [] - for block in deck.outline_blocks(numbers(deck, slides)): + for block in _blocks(deck, slides, stories): count = sum(1 for m in pattern.finditer(block.text) if m.end() > m.start()) if count: hits.append((block, count)) total = sum(count for _, count in hits) if expect == "one" and total != 1: if total == 0: + elsewhere = [] if _scope(stories) == "all" else [ + block.address for block in deck.outline_blocks(scope="all") + if block.part is not None and pattern.search(block.text)] + if elsewhere: + raise ToolError("not_found", f"{find!r} is not on the slides but on a layout or " + f"master ({', '.join(elsewhere[:5])}): stories=all reaches it", + field="stories", valid_options=["all", "layouts", "masters"]) raise ToolError("not_found", f"{find!r} is not in the deck", field="find") raise ToolError("ambiguous", f"{find!r} occurs {total} times; expect=one needs " "exactly one: narrow it, or use expect=all", @@ -164,8 +201,18 @@ def replace_text(call, doc, find, replace, expect, regex=False, slides=None, ran sid = slide_id_of(block.address) if sid is not None: call.touch(sid) - return Result(summary=f"Replaced {total} occurrence(s) in {len(changed)} place(s)", - changed=changed, data={"count": total}) + key = template_key_of(block.address) + if key is not None: + call.touch(key) + result = Result(summary=f"Replaced {total} occurrence(s) in {len(changed)} place(s)", + changed=changed, data={"count": total}) + if not total and _scope(stories) != "all": + elsewhere = [block.address for block in deck.outline_blocks(scope="all") + if block.part is not None and pattern.search(block.text)] + if elsewhere: + result.warnings.append(f"{find!r} is on a layout or master too " + f"({', '.join(elsewhere[:5])}): stories=all reaches it") + return result def _span(pattern, text): diff --git a/src/pptx_agent/tools/slides.py b/src/pptx_agent/tools/slides.py index cfbc62e..16a544d 100644 --- a/src/pptx_agent/tools/slides.py +++ b/src/pptx_agent/tools/slides.py @@ -9,10 +9,11 @@ from typing import Any -from ooxml_edit.tools import Result, ToolError, array, integer, obj, string, tool +from ooxml_edit.tools import Result, ToolError, array, boolean, integer, obj, string, tool from ..edit.theme import MAPPED, SLOTS -from .common import KIND, box_pt, library_errors, slide as slide_at +from .common import (KIND, box_pt, library_errors, numbers, resolve, slide as slide_at, + template_key_of, template_options) SLIDES = "ppt_slides" _KEY = string("Retry key: a repeat with the same key makes nothing new.", optional=True) @@ -233,4 +234,110 @@ def ppt_set_theme(call, doc, colors=None, fonts=None): "neutral": roles.neutral, "highlight": roles.highlight}}) -TOOLS = [ppt_add_slide, ppt_draft_slides, ppt_manage_slides, ppt_set_theme] +def _layouts_json(deck) -> list[dict[str, Any]]: + return [{"id": layout.id, "name": layout.name, "slides": len(layout.slides)} + for layout in deck.layouts] + + +def _template_layout(deck, address: str | None, field: str): + if not address: + raise ToolError("invalid_arguments", f"give {field}: a layout, L2 or its name", + field=field, valid_options=template_options(deck)) + try: + return deck.layout(str(address).split(".")[0] if template_key_of(address) else address) + except KeyError: + raise ToolError("not_found", f"no layout {address!r}", field=field, + valid_options=[f"{lay.id} ({lay.name})" for lay in deck.layouts][:50] + ) from None + + +@tool("ppt_edit_master", + "Slide masters and layouts: a layout placeholder's prompt text, Insert > Header & " + "Footer, and adding, duplicating, renaming or deleting layouts. Their shapes' text, " + "fill and box: ppt_set_text, ppt_set_shape (L2.5, M0.7); new shapes: the add tools " + "with slide L2 or M0. Returns the slides affected.", + {"doc": string("Document id."), + "action": string("What to do.", enum=["set_prompt", "set_footer", "add_layout", + "duplicate_layout", "rename_layout", + "delete_layout"]), + "target": string("set_prompt: the layout placeholder, L2.3. Layout actions: the " + "layout, L2 or its name (add_layout: the one to follow).", + optional=True), + "text": string("set_prompt: the prompt, \\n for the next level. set_footer: the " + "footer; empty: none. add, duplicate, rename: the name.", + optional=True), + "reassign_to": string("delete_layout: the layout its slides move to.", optional=True), + "slide_number": boolean("set_footer. Default true.", optional=True), + "date": string("set_footer: auto (updates), fixed text, or leave out for none.", + optional=True), + "slides": array(integer(minimum=1), "set_footer: only these slides (Apply). Default " + "all (Apply to All: masters and layouts too).", optional=True), + "on_title_slide": boolean("set_footer. Default false.", optional=True)}, + kind=KIND, group=SLIDES, mutates=True, refs=("target",)) +@library_errors +def ppt_edit_master(call, doc, action, target=None, text=None, reassign_to=None, + slide_number=None, date=None, slides=None, on_title_slide=None): + deck = call.document + if action == "set_prompt": + if text is None: + raise ToolError("invalid_arguments", "set_prompt needs text", field="text") + shape = resolve(deck, target or "", field="target") + shape.set_prompt(text) + key = template_key_of(shape.id) + call.touch(key, shape.id) + return Result(summary=f"Set the prompt of {shape.id}", changed=[shape.id], + data={"prompt": shape.prompt, + "note": "a prompt shows in an empty placeholder while editing, " + "never in a show or an export"}) + if action == "set_footer": + chosen = numbers(deck, slides) if slides is not None else None + today = call.session.now().date() + changed = deck.set_footer(text, slide_number=True if slide_number is None + else slide_number, date=date, slides=chosen, + skip_title=not on_title_slide, today=today) + for sid in changed: + call.touch(sid) + if slides is None: + for master in deck.masters: + call.touch(master.id) + scope = "all slides, masters and layouts" if slides is None else "the slides given" + return Result(summary=f"Set the header and footer on {scope}; {len(changed)} slide(s) " + "changed", changed=[f"s:{sid}" for sid in changed], + data={"slides": [f"s:{sid}" for sid in changed]}) + if action == "add_layout": + after = _template_layout(deck, target, "target") if target else None + layout = deck.add_layout(text or "Custom Layout", after=after) + call.touch(layout.id) + return Result(summary=f"Added layout {layout.id} {layout.name!r}", created=[layout.id], + data={"layout": layout.id, "shapes": [ + {"id": shape.id, "placeholder": shape.placeholder.type or "content"} + for shape in layout.shapes if shape.placeholder is not None], + "layouts": _layouts_json(deck)}) + layout = _template_layout(deck, target, "target") + if action == "duplicate_layout": + copy = deck.duplicate_layout(layout, name=text) + call.touch(copy.id) + return Result(summary=f"Duplicated {layout.id} as {copy.id} {copy.name!r}", + created=[copy.id], data={"layout": copy.id, + "layouts": _layouts_json(deck)}) + if action == "rename_layout": + if not text: + raise ToolError("invalid_arguments", "rename_layout needs text, the new name", + field="text") + renamed = deck.rename_layout(layout, text) + return Result(summary=f"Renamed {layout.id} to {renamed.name!r}", changed=[layout.id], + data={"layouts": _layouts_json(deck)}) + if action == "delete_layout": + to = _template_layout(deck, reassign_to, "reassign_to") if reassign_to else None + moved = deck.delete_layout(layout, reassign_to=to) + for sid in moved: + call.touch(sid) + data: dict[str, Any] = {"layouts": _layouts_json(deck)} + if moved: + data["moved"] = [f"s:{sid}" for sid in moved] + return Result(summary=f"Deleted layout {layout.id} {layout.name!r}", + removed=[layout.id], data=data) + raise ToolError("invalid_arguments", f"unknown action {action!r}", field="action") + + +TOOLS = [ppt_add_slide, ppt_draft_slides, ppt_manage_slides, ppt_set_theme, ppt_edit_master] diff --git a/src/pptx_agent/tools/tables.py b/src/pptx_agent/tools/tables.py index 6053ad6..3cf4073 100644 --- a/src/pptx_agent/tools/tables.py +++ b/src/pptx_agent/tools/tables.py @@ -15,7 +15,7 @@ from ..edit.tablestyles import FLAGS from .common import (KIND, box_emu, emu, library_errors, shape as shape_at, - slide as slide_at) + host as slide_at) from .shapes import OBJECTS, _box _KEY = string("Retry key: a repeat with the same key makes nothing new.", optional=True) diff --git a/tests/golden/grading/TASK_p17.md b/tests/golden/grading/TASK_p17.md new file mode 100644 index 0000000..c11686c --- /dev/null +++ b/tests/golden/grading/TASK_p17.md @@ -0,0 +1,11 @@ +# Task: the template for 2027, in Dutch + +`input/example-template.pptx` is Example Corp's presentation template as the design team left it in 2026, in English. The Dutch entity, Example Corp B.V., will use it from January 2027. + +- **The legal line** every slide shows at the bottom says "© 2027 Example Corp B.V. Vertrouwelijk. Alle rechten voorbehouden." -- no 2026, no Ltd., nothing in English left in it. +- **The legal notice** on the "Legal" layout is in Dutch: "Dit document is vertrouwelijk en uitsluitend bestemd voor de geadresseerde. Het mag niet worden gekopieerd of doorgegeven zonder schriftelijke toestemming van Example Corp B.V." Rename the layout to "Juridisch". +- **The prompt** an empty content placeholder shows on the "Title and Content" layout reads "Typ hier uw bevinding". +- **Header & Footer:** slide numbers and the footer "Example Corp B.V." on every slide but the title slide, as Insert › Header & Footer does it (Apply to All), no date. +- The deck's editing language is Dutch (nl-NL). The slides' own text does not change. + +Save the result as `work/example-template.pptx`. diff --git a/tests/golden/grading/check_p17.py b/tests/golden/grading/check_p17.py new file mode 100644 index 0000000..cbe5ec2 --- /dev/null +++ b/tests/golden/grading/check_p17.py @@ -0,0 +1,95 @@ +"""P17: a corporate template made ready for a new year and language -- the legal line on the +master, the legal notice on a layout, a layout's prompt, Header & Footer (round 3: master and +layout editing, G5).""" +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) # common.py beside it +from common import INPUTS, main, norm # noqa: E402 + +from pptx_agent import Document # noqa: E402 + +FOLDER = Path(os.environ.get("GOLDEN_INPUTS", str(INPUTS))) / "p17-template-new-year" / "input" +LEGAL = "© 2027 Example Corp B.V. Vertrouwelijk. Alle rechten voorbehouden." +NOTICE = ("Dit document is vertrouwelijk en uitsluitend bestemd voor de geadresseerde. Het mag " + "niet worden gekopieerd of doorgegeven zonder schriftelijke toestemming van Example " + "Corp B.V.") +P = "{http://schemas.openxmlformats.org/presentationml/2006/main}" +A = "{http://schemas.openxmlformats.org/drawingml/2006/main}" + + +def _footer_placeholders(slide): + return {shape.placeholder.type: shape for shape in slide.shapes + if shape.placeholder is not None and shape.placeholder.type in ("dt", "ftr", "sldNum")} + + +def body(c, out: Path): + deck = Document.open(out) + src = Document.open(FOLDER / "example-template.pptx") + c.check("validate() clean", not deck.validate(), [str(p) for p in deck.validate()]) + c.check("the slides' own text unchanged", + [[norm(s.text) for s in slide.shapes if s.placeholder is None + or s.placeholder.type not in ("dt", "ftr", "sldNum")] for slide in deck.slides] + == [[norm(s.text) for s in slide.shapes] for slide in src.slides], + [slide.title for slide in deck.slides]) + master = deck.masters[0] + legal = [s for s in master.shapes if s.placeholder is None and norm(s.text)] + c.check("the master's legal line, in Dutch, for 2027", [norm(s.text) for s in legal] == [LEGAL], + [s.text for s in legal]) + c.check("the legal line kept its place and size", + legal and legal[0].bounds == src.masters[0].shapes[-1].bounds + and legal[0].paragraphs[0].runs[0].size == 8, legal and legal[0].bounds) + left = [b.address for b in deck.find_text("2026", scope="all")] + \ + [b.address for b in deck.find_text("Ltd", scope="all")] + \ + [b.address for b in deck.find_text("Confidential", scope="all")] + c.check("no 2026, Ltd. or English legal text anywhere", not left, left) + names = [layout.name for layout in deck.layouts] + c.check("the Legal layout is Juridisch now", "Juridisch" in names and "Legal" not in names, names) + if "Juridisch" in names: + notice = [s for s in deck.layout("Juridisch").shapes if s.placeholder is None + and norm(s.text)] + c.check("its notice in Dutch", [norm(s.text) for s in notice] == [NOTICE], + [s.text for s in notice]) + c.check("the legal slide still on it", + [s.layout.name for s in deck.slides][-1] == "Juridisch", + [s.layout.name for s in deck.slides]) + body_ph = deck.layout("Title and Content").placeholder("body") + c.check("the content prompt in Dutch, as a custom prompt", + body_ph.prompt is not None and norm(body_ph.prompt.split("\n")[0]) == + "Typ hier uw bevinding", body_ph.prompt) + c.check("the other levels' prompts kept", len(body_ph.paragraphs) == 5, + len(body_ph.paragraphs)) + title_slide, *rest = deck.slides + c.check("no footer on the title slide", not _footer_placeholders(title_slide), + list(_footer_placeholders(title_slide))) + ok, detail = True, [] + for number, slide in enumerate(rest, 2): + found = _footer_placeholders(slide) + detail.append((slide.slide_id, sorted(found))) + number_field = found.get("sldNum") and found["sldNum"]._element.find(f".//{A}fld") + if sorted(found) != ["ftr", "sldNum"] or number_field is None \ + or number_field.get("type") != "slidenum" \ + or norm(found["ftr"].text) != "Example Corp B.V.": + ok = False + c.check("footer and slide number on every other slide, no date", ok, detail) + presentation = deck.package.tree(deck.package.presentation_part()) + c.check("Don't show on title slide recorded", presentation.get("showSpecialPlsOnTitleSld") + == "0", presentation.get("showSpecialPlsOnTitleSld")) + hf = deck.package.tree(master.part_path).find(f"{P}hf") + c.check("the master's Header & Footer record", hf is not None + and dict(hf.attrib) == {"hdr": "0", "dt": "0"}, None if hf is None else dict(hf.attrib)) + layout_footers = {layout.name: norm(layout.placeholder("footer").text) + for layout in deck.layouts + if any(s.placeholder and s.placeholder.type == "ftr" for s in layout.shapes)} + c.check("every layout's footer says it too", set(layout_footers.values()) == {"Example Corp B.V."}, + layout_footers) + c.check("the editing language is Dutch", deck.language == "nl-NL", deck.language) + svg = deck.render_svg([3])[0] + c.check("slide 3 draws the legal line, the footer and its number", + "Vertrouwelijk" in svg and "Example Corp B.V." in svg and ">3<" in svg.replace(" ", ""), + [word for word in ("Vertrouwelijk", "Example Corp B.V.") if word not in svg]) + + +if __name__ == "__main__": + main("p17", body) diff --git a/tests/golden/transcripts/p17.json b/tests/golden/transcripts/p17.json new file mode 100644 index 0000000..2ff0bb9 --- /dev/null +++ b/tests/golden/transcripts/p17.json @@ -0,0 +1,150 @@ +{ + "task": "p17-template-new-year", + "inputs": [ + { + "file": "input/example-template.pptx", + "as": "document", + "sha256": "3e9607c24740fb8662dc3061ca99edab44d5d82377f5a3eef03ce19ce4f783ff", + "content_sha256": "01bace7afdc9afd121c90e25c62af863cbd67423b9d96c3d229808e614dfdee9" + } + ], + "calls": [ + { + "tool": "describe", + "arguments": { + "doc": "d1" + }, + "expect": { + "ok": true + } + }, + { + "tool": "find_text", + "arguments": { + "doc": "d1", + "text": "2026", + "stories": "all" + }, + "expect": { + "ok": true + } + }, + { + "tool": "batch", + "arguments": { + "ops": [ + { + "tool": "ppt_set_text", + "arguments": { + "doc": "d1", + "items": [ + { + "target": "M0.7", + "text": "© 2027 Example Corp B.V. Vertrouwelijk. Alle rechten voorbehouden." + }, + { + "target": "L12.6", + "text": "Dit document is vertrouwelijk en uitsluitend bestemd voor de geadresseerde. Het mag niet worden gekopieerd of doorgegeven zonder schriftelijke toestemming van Example Corp B.V." + } + ] + } + }, + { + "tool": "ppt_edit_master", + "arguments": { + "doc": "d1", + "action": "set_prompt", + "target": "L2.3", + "text": "Typ hier uw bevinding" + } + }, + { + "tool": "ppt_edit_master", + "arguments": { + "doc": "d1", + "action": "rename_layout", + "target": "L12", + "text": "Juridisch" + } + }, + { + "tool": "set_properties", + "arguments": { + "doc": "d1", + "language": "nl-NL" + } + } + ] + }, + "expect": { + "ok": true + } + }, + { + "tool": "ppt_edit_master", + "arguments": { + "doc": "d1", + "action": "set_footer", + "text": "Example Corp B.V.", + "slide_number": true + }, + "expect": { + "ok": true + } + }, + { + "tool": "find_text", + "arguments": { + "doc": "d1", + "text": "Confidential|Ltd|2026", + "regex": true, + "stories": "all" + }, + "expect": { + "ok": true + } + }, + { + "tool": "render", + "arguments": { + "doc": "d1", + "slides": [ + 3 + ] + }, + "expect": { + "ok": true + } + }, + { + "tool": "check", + "arguments": { + "doc": "d1" + }, + "expect": { + "ok": true + } + }, + { + "tool": "save_document", + "arguments": { + "doc": "d1", + "name": "example-template.pptx", + "format": "pptx" + }, + "expect": { + "ok": true + } + } + ], + "output": { + "name": "example-template.pptx", + "sha256": "428cf890915153bf7a7e9d527d719afeea5cc7909338aedec936f2630a9bbc03", + "calls": 8, + "content_sha256": "259c1f0f03348fe8676a0506705563a967ae9e6604fc1e68443bb39db3d4f375" + }, + "check": { + "total": 18, + "passed": 18 + } +} diff --git a/tests/golden_inputs.py b/tests/golden_inputs.py index 5114500..785fd91 100644 --- a/tests/golden_inputs.py +++ b/tests/golden_inputs.py @@ -1,8 +1,9 @@ """Build the inputs of the trial tasks p2-p6, of the spike's graphics tasks p7, p8, o1 and m1, of p10 (a review in comments), p11 (SmartArt), p12 (a rebrand through the theme), p14 (a chart and a table on a branded template), p15 (a maturity gap chart and an -effort-against-impact matrix) and p16 (an approach slide: four phases with SVG icons, evenly -spaced), for the golden transcripts (test_golden.py). +effort-against-impact matrix), p16 (an approach slide: four phases with SVG icons, evenly +spaced) and p17 (a corporate template made ready for a new year and language: its master, +layouts, prompts and footer), for the golden transcripts (test_golden.py). The trial's own builder (recovered from its coordinator's transcript), run against this repository's fixtures, writes ``//input/...``. p1's template is the committed @@ -615,6 +616,39 @@ def p16() -> None: (folder / name).write_bytes(_ICON.format(body).encode("utf-8")) +P17_LEGAL_EN = ("This document is confidential and intended solely for the addressee. It may " + "not be copied or passed on without the written consent of Example Corp.") + + +def p17() -> None: + """A corporate template as the design team left it in 2026, in English: a legal line on + the master, a custom prompt in the content layout, a "Legal" layout with its notice, and + four slides on it -- no footers yet.""" + folder = out("p17-template-new-year") + deck = Document.new() + deck.title = "Example Corp template" + deck.created = deck.modified = DATE + master = deck.masters[0] + legal = master.add_textbox(838200, 6721475, 7315200, 136525, name="Legal line") + legal.set_text("\u00a9 2026 Example Corp Ltd. Confidential. All rights reserved.") + legal.paragraphs[0].runs[0].size = 8 + legal.text_frame.insets = (0, 0, 0, 0) + deck.layout("Title and Content").placeholder("body").set_prompt("Type your finding here") + notice = deck.add_layout("Legal", after="Title and Content") + box = notice.add_textbox(838200, 1825625, 10515600, 1371600, name="Legal notice") + box.set_text(P17_LEGAL_EN) + cover = deck.add_slide("Title Slide") + cover.shapes[0].set_text("Annual plan") + cover.shapes[1].set_text("Example Corp") + for title, body in (("Market", "Demand grew in every region."), + ("Findings", "Three of five units met their targets.")): + slide = deck.add_slide("Title and Content") + slide.shapes[0].set_text(title) + slide.shapes[1].set_text(body) + deck.add_slide("Legal").shapes[0].set_text("Legal notice") + deck.save(folder / "example-template.pptx") + + BUILDERS = {"p2-quarterly-update": p2, "p3-split-slide": p3, "p4-process-diagram": p4, "p5-rebrand": p5, "p6-restructure-deck": p6, "p7-phased-approach": halden("p7-phased-approach"), @@ -623,7 +657,8 @@ def p16() -> None: "m1-priority-matrix": halden("m1-priority-matrix", (3, "Prioritisation")), "p10-review-comments": p10, "p11-update-smartart": p11, "p12-theme-rebrand": p12, "p14-brand-chart-table": p14, - "p15-maturity-charts": p15, "p16-approach-icons": p16} + "p15-maturity-charts": p15, "p16-approach-icons": p16, + "p17-template-new-year": p17} #: Builders that draw from scratch what is otherwise committed (``--draw``). diff --git a/tests/test_docstrings.py b/tests/test_docstrings.py index 036c0ae..538e716 100644 --- a/tests/test_docstrings.py +++ b/tests/test_docstrings.py @@ -17,7 +17,7 @@ "Chart", "Series", "SeriesList", "Diagram", "Fill", "LineFormat", "Color", "Layout", "Adjustments", "Hyperlink", "Bullet", "GradientStop", "Arrowhead", "Region", "ShapeId", "TextBlock", "ApplyReport", "LabelError", "MarkdownEscapeWarning", - "TemplateOpenedWarning", "Placeholder", "LayoutPlaceholder", "LayoutShape", + "TemplateOpenedWarning", "Placeholder", "LayoutPlaceholder", "LayoutShape", "Master", "Theme", "ThemeFonts", "EffectiveParagraph", "TextFit", "Overflow", "ImageSize"] #: Plumbing, or inherited from list: no example needed. EXEMPT = {"Chart.address", "Chart.part", "Chart.point_count", "Diagram.address", diff --git a/tests/test_golden.py b/tests/test_golden.py index 96e75f4..96c267b 100644 --- a/tests/test_golden.py +++ b/tests/test_golden.py @@ -5,9 +5,10 @@ slide with its empty placeholder deleted, and a table in the template's own style without banding) and p15 (a maturity gap chart -- columns, a highlighted point, target markers, a fixed axis, no gridlines, 10 pt text -- and an effort-against-impact bubble matrix labelled -from the workbook) and p16 (an approach slide: four phases, each an SVG icon above its label, -spaced equally across the content area with ppt_arrange's distribute and align), done with -the tools alone and replayed. +from the workbook), p16 (an approach slide: four phases, each an SVG icon above its label, +spaced equally across the content area with ppt_arrange's distribute and align) and p17 (a +corporate template made ready for 2027 in Dutch: the master's legal line, a layout's legal +notice and name, a layout's prompt, Header & Footer), done with the tools alone and replayed. Each transcript in ``golden/transcripts`` is a reference solution as tool calls -- what a model would send, with no code and no files -- recorded with what every call returned. @@ -128,7 +129,7 @@ def test_the_goldens_cover_every_task_in_few_calls(): tasks = {json.loads(p.read_text())["task"].split("-")[0]: json.loads(p.read_text()) for p in TRANSCRIPTS} assert sorted(tasks) == ["m1", "o1", "p1", "p10", "p11", "p12", "p13", "p14", "p15", "p16", - "p2", "p3", "p4", "p5", "p6", "p7", "p8", "p9"] + "p17", "p2", "p3", "p4", "p5", "p6", "p7", "p8", "p9"] for transcript in tasks.values(): assert len(transcript["calls"]) <= 10 assert all(step["tool"] in {tool.name for tool in TOOLS} for step in transcript["calls"]) diff --git a/tests/test_layouts.py b/tests/test_layouts.py index b9eb04b..972980e 100644 --- a/tests/test_layouts.py +++ b/tests/test_layouts.py @@ -8,7 +8,7 @@ import pytest from conftest import FIXTURE_DIR -from pptx_agent import Document, Layout, LayoutPlaceholder, LayoutShape, Placeholder +from pptx_agent import Document, Layout, LayoutPlaceholder, LayoutShape, Placeholder, Shape TEMPLATE = FIXTURE_DIR / "generated" / "trial" / "company-template.potx" @@ -57,18 +57,20 @@ def test_a_template_with_its_own_names(): assert title.name == "Google Shape;45;p6" -def test_layout_shapes_are_read_only(): +def test_layout_shapes_are_editable_shapes(): deck = Document.new() layout = deck.layout("Title Slide") shapes = layout.shapes - assert all(isinstance(shape, LayoutShape) for shape in shapes) + assert all(isinstance(shape, Shape) for shape in shapes) assert [shape.placeholder.type for shape in shapes] == ["ctrTitle", "subTitle", "dt", "ftr", "sldNum"] - with pytest.raises(dataclasses.FrozenInstanceError): - shapes[0].name = "x" + assert [shape.id for shape in shapes][:2] == ["L1.2", "L1.3"] + shapes[0].name = "x" + assert deck.layout("Title Slide").shapes[0].name == "x" with pytest.raises(AttributeError): layout.shapes = [] assert isinstance(layout, Layout) and layout == deck.layouts[0] + assert LayoutShape is not None # still importable for code that builds one def test_a_hand_made_layout_says_where_to_get_one(): diff --git a/tests/test_masters.py b/tests/test_masters.py new file mode 100644 index 0000000..a62081f --- /dev/null +++ b/tests/test_masters.py @@ -0,0 +1,482 @@ +"""Slide masters and layouts as editable parts (production feedback round 3, G5). + +What PowerPoint writes for each edit was measured in PowerPoint 16 (Mac): the probes are in +the round's notes; the assertions below are what they showed.""" + +from __future__ import annotations + +import datetime as dt +import io +import json +import re +import zipfile + +import pytest +from lxml import etree +from ooxml_edit.tools import Toolbox + +from pptx_agent import Document, Layout, Master, Shape +from pptx_agent.tools import FORMAT, GROUPS, TOOLS + +P = "{http://schemas.openxmlformats.org/presentationml/2006/main}" +A = "{http://schemas.openxmlformats.org/drawingml/2006/main}" +CLOCK = lambda: dt.datetime(2027, 1, 4, 9, 0, tzinfo=dt.timezone.utc) # noqa: E731 +LEGAL = ('' + '' + '' + '' + '© 2026 Example Corp. Confidential.' + '') + + +def _patched(content: bytes, edits: dict) -> bytes: + source, out = zipfile.ZipFile(io.BytesIO(content)), io.BytesIO() + with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as target: + for name in source.namelist(): + data = source.read(name) + if name in edits: + data = edits[name](data.decode()).encode() + target.writestr(name, data) + return out.getvalue() + + +def template(*, ids: bool = False) -> bytes: + """A title slide and two content slides; a legal line on the master; with ``ids``, the + master and layout ids as a template from elsewhere has them (not 2147483648 + n).""" + deck = Document.new() + deck.add_slide("Title Slide").shapes[0].set_text("Plan 2027") + for number in (2, 3): + deck.add_slide("Title and Content").shapes[0].set_text(f"Slide {number}") + edits = {"ppt/slideMasters/slideMaster1.xml": + lambda x: x.replace("", LEGAL + "", 1)} + if ids: + edits["ppt/presentation.xml"] = lambda x: x.replace('id="2147483648"', + 'id="2147483700"') + master = edits["ppt/slideMasters/slideMaster1.xml"] + edits["ppt/slideMasters/slideMaster1.xml"] = lambda x: re.sub( + r'sldLayoutId id="21474836(\d\d)"', + lambda m: f'sldLayoutId id="{2147483800 + 3 * int(m.group(1))}"', master(x)) + return _patched(deck.to_bytes(), edits) + + +def part(deck: Document, name: str) -> etree._Element: + return etree.fromstring(deck.to_bytes() and zipfile.ZipFile(io.BytesIO(deck.to_bytes())) + .read(name)) + + +# -- objects and addresses --------------------------------------------------------------------- + + +def test_masters_and_layouts_have_ids_from_the_file_and_editable_shapes(): + deck = Document.open(template()) + master = deck.masters[0] + assert isinstance(master, Master) and master.id == "M0" and master.name == "Office Theme" + assert master.part_path == "ppt/slideMasters/slideMaster1.xml" + assert [layout.id for layout in master.layouts] == [f"L{n}" for n in range(1, 12)] + legal = next(shape for shape in master.shapes if shape.name == "Legal footer") + assert isinstance(legal, Shape) and legal.id == "M0.50" + assert deck.shape("M0.50") .text.startswith("© 2026") + assert deck.resolve("M0.50/p0/r0").text.startswith("© 2026") + layout = deck.layout("L2") + assert isinstance(layout, Layout) and layout.name == "Title and Content" + assert layout.master == master and layout.type == "obj" + assert [slide.slide_id for slide in layout.slides] == [257, 258] + assert [slide.slide_id for slide in master.slides] == [256, 257, 258] + assert [s.slide_id for s in deck.affected_slides("L2.3")] == [257, 258] + + +def test_ids_are_the_sld_layout_ids_so_they_survive_a_save(): + """PowerPoint keeps sldMasterId and sldLayoutId when it saves (measured): the key is the + id less 2147483648, not a position.""" + deck = Document.open(template(ids=True)) + assert deck.masters[0].id == "M52" + assert [layout.id for layout in deck.layouts][:3] == ["L299", "L302", "L305"] + assert deck.shape("M52.50").name == "Legal footer" + again = Document.open(deck.to_bytes()) + assert [layout.id for layout in again.layouts] == [layout.id for layout in deck.layouts] + + +def test_a_master_text_box_is_edited_like_a_slide_shape_and_undone(): + deck = Document.open(template()) + legal = deck.shape("M0.50") + legal.set_text(legal.text.replace("2026", "2027")) + legal.paragraphs[0].runs[0].bold = True + legal.set_solid_fill("accent1") + legal.top = 6350000 + assert deck.shape("M0.50").text == "© 2027 Example Corp. Confidential." + assert deck.shape("M0.50").paragraphs[0].runs[0].size == 10 + assert deck.shape("M0.50").top == 6350000 + for _ in range(4): + deck.undo() + assert deck.shape("M0.50").text.startswith("© 2026") + + +def test_adding_shapes_and_pictures_to_a_layout_and_a_master(tmp_path): + from images import png + + deck = Document.open(template()) + box = deck.layout("Title Slide").add_textbox(457200, 457200, 3000000, 400000) + box.set_text("Vertrouwelijk") + assert box.id.startswith("L1.") + logo = deck.masters[0].add_picture(png(64, 64, lambda x, y: (0, 0, 255)), 10900000, 228600, width=914400) + assert logo.id.startswith("M0.") and logo.kind == "picture" + rect = deck.masters[0].add_shape("rect", 0, 0, 12192000, 100000) + assert rect.id.startswith("M0.") + assert deck.validate() == [] + again = Document.open(deck.to_bytes()) + assert any(s.text == "Vertrouwelijk" for s in again.layout("Title Slide").shapes) + assert again.shape(logo.id).image_part is not None + + +def test_text_fit_is_measured_on_the_slides_not_the_layout(): + deck = Document.open(template()) + with pytest.raises(ValueError, match="affected_slides"): + deck.shape("M0.50").text_fit() + + +# -- prompts ----------------------------------------------------------------------------------- + + +def test_set_prompt_writes_the_first_level_and_marks_it_custom(): + """Measured: typing over the first line of a layout's body placeholder in Slide Master + view sets hasCustomPrompt="1" and keeps the other levels.""" + deck = Document.open(template()) + body = deck.layout("Title and Content").placeholder("body") + assert body.prompt is None + size = body.paragraphs[0].runs[0].effective_size + body.set_prompt("Typ hier uw bevinding") + body = deck.layout("Title and Content").placeholder("body") + assert body.prompt.split("\n")[:2] == ["Typ hier uw bevinding", "Second level"] + assert len(body.paragraphs) == 5 + assert body.paragraphs[0].runs[0].effective_size == size + ph = body._element.find(f".//{P}ph") + assert ph.get("hasCustomPrompt") == "1" and ph.get("idx") == "1" + deck.undo() + assert deck.layout("Title and Content").placeholder("body").prompt is None + + +def test_typing_into_a_layout_placeholder_is_a_prompt_and_formatting_is_not(): + deck = Document.open(template()) + title = deck.layout("Title Slide").placeholder("title") + title.paragraphs[0].runs[0].bold = True + assert deck.layout("Title Slide").placeholder("title").prompt is None + deck.layout("Title Slide").placeholder("subtitle").set_text("Ondertitel") + assert deck.layout("Title Slide").placeholder("subtitle").prompt == "Ondertitel" + # the footer placeholders hold content, never a prompt + deck.layout("Title Slide").placeholder("footer").set_text("Example BV") + assert deck.layout("Title Slide").placeholder("footer").prompt is None + with pytest.raises(ValueError, match="layout"): + deck.masters[0].placeholder("body").set_prompt("x") + with pytest.raises(ValueError, match="set_footer"): + deck.layout("L2").placeholder("footer").set_prompt("x") + + +# -- find and replace -------------------------------------------------------------------------- + + +def test_find_text_scopes(): + deck = Document.open(template()) + assert deck.find_text("2026") == [] + found = deck.find_text("2026", scope="all") + # the master's legal line; the date placeholders hold only a field: not text + assert [(block.address, block.part, block.slide) for block in found] == [ + ("M0.50", "M0", 0)] + assert deck.find_text("Slide 2", scope="layouts") == [] + assert [b.address for b in deck.find_text("Slide 2", scope="all")] == ["257.2"] + layout_titles = deck.find_text("Click to edit Master title", scope="layouts") + assert [b.part for b in layout_titles][:2] == ["L1", "L2"] + with pytest.raises(ValueError, match="scope"): + deck.find_text("x", scope="everything") + + +# -- deleting what others inherit from --------------------------------------------------------- + + +def test_deleting_a_placeholder_slides_inherit_from_is_refused(): + deck = Document.open(template()) + body = deck.layout("Title and Content").placeholder("body") + assert body.inherited_by() == ["257.3", "258.3"] + with pytest.raises(ValueError, match="257.3, 258.3"): + body.delete() + master_date = deck.masters[0].placeholder("date") + assert "L2.4" in master_date.inherited_by() + with pytest.raises(ValueError, match="inherit"): + master_date.delete() + # a layout placeholder no slide uses, and a master's own text box, go + deck.layout("Two Content").placeholder("title").delete() + deck.shape("M0.50").delete() + assert deck.find_text("2026", scope="all") == [] + + +# -- the layout list --------------------------------------------------------------------------- + + +def _master_ids(deck): + root = deck.package.tree(deck.masters[0].part_path) + return [node.get("id") for node in root.iter(f"{P}sldLayoutId")] + + +def test_duplicate_layout_as_powerpoint_does(): + deck = Document.open(template()) + copy = deck.duplicate_layout("Title and Content") + assert copy.name == "1_Title and Content" and copy.id == "L12" + assert [layout.name for layout in deck.layouts][:3] == [ + "Title Slide", "Title and Content", "1_Title and Content"] + assert _master_ids(deck)[2] == "2147483660" + assert copy.type == "obj" and len(copy.shapes) == len(deck.layout("L2").shapes) + assert deck.duplicate_layout("Title and Content").name == "2_Title and Content" + named = deck.duplicate_layout("L2", name="Inhoud NL") + assert named.name == "Inhoud NL" + data = deck.to_bytes() + types = zipfile.ZipFile(io.BytesIO(data)).read("[Content_Types].xml").decode() + assert f'PartName="/{copy.part_path}"' in types + assert deck.validate() == [] + slide = deck.add_slide("Inhoud NL") + assert slide.layout.id == named.id + + +def test_add_layout_as_insert_layout_makes_one(): + deck = Document.open(template()) + legal = deck.add_layout("Juridisch", after="Title and Content") + assert [layout.name for layout in deck.layouts][2] == "Juridisch" + root = deck.package.tree(legal.part_path) + assert root.get("preserve") == "1" and root.get("userDrawn") == "1" + assert root.get("type") is None and legal.type is None + assert [(s.placeholder.type, s.placeholder.idx) for s in legal.shapes] == [ + ("title", None), ("dt", 10), ("ftr", 11), ("sldNum", 12)] + assert legal.placeholder("title").text == "Click to edit Master title style" + assert all(s._element.find(f"{P}spPr") is not None and len(s._element.find(f"{P}spPr")) == 0 + for s in legal.shapes) + with pytest.raises(ValueError, match="already has a layout"): + deck.add_layout("Juridisch") + assert deck.validate() == [] + + +def test_rename_and_delete_layouts(): + deck = Document.open(template()) + renamed = deck.layout("Title and Content").rename("Inhoud") + assert renamed.id == "L2" and deck.layout("Inhoud").slides[0].slide_id == 257 + with pytest.raises(ValueError, match="already has a layout"): + deck.rename_layout("Inhoud", "Title Slide") + with pytest.raises(ValueError, match="in use by slide.*s:257, s:258"): + deck.delete_layout("Inhoud") + unused = deck.layout("Two Content") + deck.delete_layout(unused) + assert "Two Content" not in [layout.name for layout in deck.layouts] + assert unused.part_path not in deck.package.part_names + assert "2147483652" not in _master_ids(deck) + moved = deck.delete_layout("Inhoud", reassign_to="Title Only") + assert moved == [257, 258] + assert [slide.layout.name for slide in deck.slides] == ["Title Slide", "Title Only", + "Title Only"] + assert deck.slides[1].shapes[0].text == "Slide 2" + assert deck.validate() == [] + deck.undo() + assert [slide.layout.name for slide in deck.slides][1] == "Inhoud" + + +# -- Header & Footer --------------------------------------------------------------------------- + + +def _footers(deck, part_path): + tree = deck.package.tree(part_path) + out = [] + for sp in tree.iter(f"{P}sp"): + ph = sp.find(f".//{P}ph") + if ph is not None and ph.get("type") in ("dt", "ftr", "sldNum"): + out.append((ph.get("type"), "".join(t.text or "" for t in sp.iter(f"{A}t")))) + return out + + +def test_set_footer_apply_to_all_as_powerpoint_writes_it(): + deck = Document.open(template()) + changed = deck.set_footer("© 2027 Example Corp", slide_number=True, date="auto", + today=dt.date(2027, 1, 5)) + assert changed == [257, 258] # not the title slide + presentation = deck.package.tree("ppt/presentation.xml") + assert presentation.get("showSpecialPlsOnTitleSld") == "0" + master = deck.package.tree("ppt/slideMasters/slideMaster1.xml") + hf = master.find(f"{P}hf") + assert dict(hf.attrib) == {"hdr": "0"} + assert [etree.QName(c).localname for c in master][:5] == [ + "cSld", "clrMap", "sldLayoutIdLst", "hf", "txStyles"] + for path in ("ppt/slideMasters/slideMaster1.xml", deck.layout("L2").part_path, + deck.layout("L7").part_path): + found = dict(_footers(deck, path)) + assert found["ftr"] == "© 2027 Example Corp" and found["dt"] == "1/5/2027" + slide = deck.slides[1] + assert _footers(deck, slide.part_path) == [ + ("dt", "1/5/2027"), ("ftr", "© 2027 Example Corp"), ("sldNum", "2")] + dt_shape = next(s for s in slide.shapes if s.placeholder and s.placeholder.type == "dt") + field = dt_shape._element.find(f".//{A}fld") + assert field.get("type") == "datetime1" + assert len(dt_shape._element.find(f"{P}spPr")) == 0 # it keeps the layout's place + number = next(s for s in slide.shapes if s.placeholder and s.placeholder.type == "sldNum") + layout_number = deck.layout("L2").placeholder("slide_number") + assert number._element.find(f".//{A}fld").get("id") == \ + layout_number._element.find(f".//{A}fld").get("id") + assert _footers(deck, deck.slides[0].part_path) == [] + assert deck.validate() == [] + # again: updated in place, nothing doubled + deck.set_footer("© 2028 Example Corp", date="Januari 2028") + assert _footers(deck, slide.part_path) == [ + ("dt", "Januari 2028"), ("ftr", "© 2028 Example Corp"), ("sldNum", "2")] + + +def test_a_new_slide_gets_the_footers_once_header_and_footer_was_applied_to_all(): + """Measured: after Apply to All, PowerPoint's New Slide copies the layout's date, footer + and slide-number placeholders with their content; before it, none.""" + deck = Document.open(template()) + assert [s.placeholder.type for s in deck.add_slide("Title and Content").shapes] == [ + "title", None] + deck.set_footer("Example BV", date="Januari 2027") + slide = deck.add_slide("Title and Content") + assert _footers(deck, slide.part_path) == [("dt", "Januari 2027"), ("ftr", "Example BV"), + ("sldNum", "5")] + assert _footers(deck, deck.add_slide("Title Slide").part_path) == [] + deck.set_footer("Example BV", slide_number=False) + assert [k for k, _ in _footers(deck, deck.add_slide("Title and Content").part_path)] == [ + "ftr"] + assert deck.validate() == [] + + +def test_set_footer_off_removes_the_slides_placeholders_and_says_so_on_the_master(): + deck = Document.open(template()) + deck.set_footer("Example", date="auto") + deck.set_footer(None, slide_number=False, date=None) + assert all(_footers(deck, slide.part_path) == [] for slide in deck.slides) + hf = deck.package.tree("ppt/slideMasters/slideMaster1.xml").find(f"{P}hf") + assert dict(hf.attrib) == {"sldNum": "0", "hdr": "0", "ftr": "0", "dt": "0"} + assert dict(_footers(deck, "ppt/slideMasters/slideMaster1.xml"))["ftr"] == "Example" + + +def test_set_footer_on_some_slides_leaves_the_master_alone(): + deck = Document.open(template()) + before = etree.tostring(deck.package.tree("ppt/slideMasters/slideMaster1.xml")) + deck.set_footer("Only here", slides=[2], skip_title=False) + assert etree.tostring(deck.package.tree("ppt/slideMasters/slideMaster1.xml")) == before + assert _footers(deck, deck.slides[1].part_path) == [("ftr", "Only here"), ("sldNum", "2")] + assert _footers(deck, deck.slides[2].part_path) == [] + deck.set_footer("All", skip_title=False) + assert "showSpecialPlsOnTitleSld" not in deck.package.tree("ppt/presentation.xml").attrib + assert [kind for kind, _ in _footers(deck, deck.slides[0].part_path)] == ["ftr", "sldNum"] + + +# -- the tools --------------------------------------------------------------------------------- + + +@pytest.fixture +def tools(): + toolbox = Toolbox(TOOLS, formats=[FORMAT], groups=GROUPS) + session = toolbox.session(clock=CLOCK) + session.open(template(), name="template.pptx") + yield toolbox, session + toolbox.close() + + +def call(tools, name, arguments): + toolbox, session = tools + return toolbox.dispatch(session, name, {"doc": "d1", **arguments}) + + +def test_describe_lists_masters_layout_ids_prompts_and_their_text(tools): + call(tools, "ppt_edit_master", {"action": "set_prompt", "target": "L2.3", + "text": "Typ hier"}) + data = call(tools, "describe", {}).data + assert data["masters"][0]["id"] == "M0" + assert data["masters"][0]["text"] == [ + {"id": "M0.50", "text": "© 2026 Example Corp. Confidential."}] + layout = data["layouts"][1] + assert layout["id"] == "L2" and layout["name"] == "Title and Content" + assert layout["placeholders"][1]["id"] == "L2.3" + assert layout["placeholders"][1]["prompt"].startswith("Typ hier\n") + + +def test_find_and_replace_reach_layouts_and_masters_with_stories(tools): + assert call(tools, "find_text", {"text": "2026"}).data == [] + found = call(tools, "find_text", {"text": "2026", "stories": "all"}).data + assert found == [{"address": "M0.50", "kind": "text", "on": "M0", + "context": "© 2026 Example Corp. Confidential."}] + missed = call(tools, "replace_text", {"find": "2026", "replace": "2027", "expect": "all"}) + assert missed.data == {"count": 0} and "stories=all" in missed.warnings[0] + one = call(tools, "replace_text", {"find": "2026", "replace": "2027", "expect": "one"}) + assert one.error.code == "not_found" and "M0.50" in one.error.message + done = call(tools, "replace_text", {"find": "2026", "replace": "2027", "expect": "one", + "stories": "masters"}) + assert done.ok and done.checks["affects"] == {"M0": ["s:256", "s:257", "s:258"]} + assert done.checks["slides"] == ["s:256", "s:257", "s:258"] + bad = call(tools, "find_text", {"text": "x", "stories": "layouts", "slides": [1]}) + assert bad.error.code == "invalid_arguments" + + +def test_set_text_and_set_shape_take_layout_and_master_addresses(tools): + result = call(tools, "ppt_set_text", {"items": [{"target": "M0.50", + "text": "© 2027 Voorbeeld BV"}]}) + assert result.ok and result.checks["affects"] == {"M0": ["s:256", "s:257", "s:258"]} + moved = call(tools, "ppt_set_shape", {"items": [{"target": "L2.3", "y": 150}]}) + assert moved.ok and moved.checks["affects"] == {"L2": ["s:257", "s:258"]} + refused = call(tools, "ppt_arrange", {"targets": ["L2.3"], "action": "delete"}) + assert refused.error.code == "invalid_arguments" and "257.3" in refused.error.message + added = call(tools, "ppt_add_shape", {"slide": "L2", "items": [ + {"preset": "textbox", "box": {"x": 36, "y": 500, "w": 400, "h": 20}, + "text": "Juridische tekst"}]}) + assert added.ok and added.created[0].startswith("L2.") + missing = call(tools, "ppt_set_text", {"items": [{"target": "L99.3", "text": "x"}]}) + assert missing.error.code == "not_found" + + +def test_edit_master_tool_actions(tools): + footer = call(tools, "ppt_edit_master", {"action": "set_footer", "text": "Voorbeeld BV", + "date": "auto"}) + assert footer.ok and footer.data == {"slides": ["s:257", "s:258"]} + toolbox, session = tools + deck = session.entry("d1").document + assert dict(_footers(deck, deck.slides[1].part_path))["dt"] == "1/4/2027" # en-US: M/D/YYYY + dup = call(tools, "ppt_edit_master", {"action": "duplicate_layout", "target": "L2", + "text": "Inhoud NL"}) + assert dup.data["layout"] == "L12" and dup.created == ["L12"] + added = call(tools, "ppt_edit_master", {"action": "add_layout", "text": "Juridisch", + "target": "Inhoud NL"}) + assert added.data["layout"] == "L13" + renamed = call(tools, "ppt_edit_master", {"action": "rename_layout", "target": "L13", + "text": "Juridisch NL"}) + assert {"id": "L13", "name": "Juridisch NL", "slides": 0} in renamed.data["layouts"] + refused = call(tools, "ppt_edit_master", {"action": "delete_layout", "target": "L2"}) + assert refused.error.code == "invalid_arguments" and "s:257" in refused.error.message + moved = call(tools, "ppt_edit_master", {"action": "delete_layout", "target": "L2", + "reassign_to": "L12"}) + assert moved.data["moved"] == ["s:257", "s:258"] + gone = call(tools, "ppt_edit_master", {"action": "delete_layout", "target": "L13"}) + assert gone.removed == ["L13"] + saved = call(tools, "save_document", {"name": "out.pptx", "format": "pptx"}) + assert saved.ok, saved.error + + +def test_undo_by_slide_treats_a_layout_edit_as_every_slides(tools): + call(tools, "ppt_set_text", {"items": [{"target": "257.2", "text": "Mine"}]}) + call(tools, "ppt_edit_master", {"action": "set_prompt", "target": "L2.3", + "text": "Typ hier"}) + refused = call(tools, "undo", {"scope": "257"}) + assert refused.error.code == "entangled" + assert "slideLayout2.xml" in refused.error.message + # a slide on another layout is not held up by it + call(tools, "ppt_set_text", {"items": [{"target": "256.2", "text": "Titel"}]}) + assert call(tools, "undo", {"scope": "256"}).ok + # plain undo takes the layout edit back + assert call(tools, "undo", {}).ok + toolbox, session = tools + deck = session.entry("d1").document + assert deck.layout("L2").placeholder("body").prompt is None + assert deck.slide(257).shapes[0].text == "Mine" + + +def test_a_layout_edit_on_slides_not_in_scope_is_not_found_by_their_undo(tools): + call(tools, "ppt_set_text", {"items": [{"target": "256.2", "text": "Titel"}]}) + call(tools, "ppt_edit_master", {"action": "set_prompt", "target": "L2.3", + "text": "Typ hier"}) + undone = call(tools, "undo", {"scope": "256"}) + assert undone.ok + toolbox, session = tools + deck = session.entry("d1").document + assert deck.slide(256).shapes[0].text == "Plan 2027" + assert deck.layout("L2").placeholder("body").prompt.startswith("Typ hier") diff --git a/tests/test_tools.py b/tests/test_tools.py index 2fd6fac..e5587c9 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -54,11 +54,16 @@ #: optional parameters ppt_arrange no longer fits Claude's strict limit of 24 optional #: parameters beside the others, so it is sent non-strict (the dispatcher still validates #: it) and ppt_add_table takes its place: 29 free-text strings and 24 optional parameters -#: over the strict tools, within the limits of 32 and 24. +#: over the strict tools, within the limits of 32 and 24. Round 3's master and layout +#: editing (G5) adds the deferred ppt_edit_master (prompts, Header & Footer, the layout +#: list; about 430), layout and master addresses in the prompt and decks' ``stories`` +#: (layouts, masters, all) on find_text and replace_text: 11,779 for every definition, the +#: core 3,440 (about 5,091 counted); the guard rises to 11,800. The strict set is unchanged: +#: ``stories`` was already an optional parameter of replace_text, only its enum grew. CHARS_PER_TOKEN = 3.5 COUNTED_PER_ESTIMATED = 1.48 CORE_COUNTED_BUDGET = 5500 -ALL_GUARD = 11400 +ALL_GUARD = 11800 def estimate_tokens(definitions) -> int: diff --git a/tests/test_usage.py b/tests/test_usage.py index 2d819ab..c2dbdd8 100644 --- a/tests/test_usage.py +++ b/tests/test_usage.py @@ -38,14 +38,14 @@ def _check(*values, expected: str) -> None: def test_the_guide_has_the_common_tasks(): titles = [title for title, _ in _examples()] - assert len(titles) == 28, titles + assert len(titles) == 29, titles for wanted in ("Units", "Text frames", "Collisions", "Measure text", "change its colours", "Open a deck", "Find text", "mixed formatting", "Tables", "Charts", "SmartArt", "Fills and outlines", "connectors and groups", "Slides", "insert_outline", "Speaker notes", "text fit", "Layouts", "Theme", "Pictures", "SVG pictures", "Full-state SVG", "New decks and templates", "Render, validate, save", "Undo", "One text spec", "Agent tools", - "Review comments"): + "Review comments", "Slide masters and layouts"): assert any(wanted in title for title in titles), wanted