Source code for md2star.pptx_clone

"""Seam-E assembler: clone the designer's own example slide, then replace its text.

:mod:`md2star.pptx_assemble` (Seam D) instantiates a *layout* and overlays
guaranteed-safe boxes in it. That is predictable, and it is also the ceiling of
what a layout can give you: a layout carries the background and the placeholder
boxes, and **nothing else**. The accent bars, the numbered step chevrons, the
decorative rules, the little bespoke shapes that make a deck look like *itself*
live on the designer's **example slides**, one level further in.

So this module takes the other half of the template. For a slide whose chosen
layout has an example page in the catalog (``LayoutInfo.example_pages``), it
**clones that page** and replaces its content: the designer's filler title
becomes yours, their filler bullets become yours, their stand-in picture slot
takes your image, and everything they drew around that content stays exactly
where they drew it. Typography is inherited rather than forced, which is the
whole point — a cloned paragraph keeps the designer's face, size, colour and
bullet glyph, so the result is their composition with your words in it.

Three rules keep that from being a liability.

**Nothing the designer wrote may ship.** This is the hard one, and it is the
same bug class as speaker notes printed on a slide: a template's filler text is
*their* copy ("Lorem ipsum", "Client name here"), and publishing it inside your
deck is worse than an empty box. So every text frame on a cloned slide is
either filled with your content or retired (:func:`_retire`) — there is no
third outcome — and a page whose content is not *restatable* at all (a chart, a
table, SmartArt, a group with words in it) is refused outright rather than
cloned with the designer's numbers still in it.

**A bad carrier is refused, not forced.** :func:`can_carry` decides, *before*
anything is cloned, whether an example page can hold this slide's content: a
three-column design needs at least three bullets, an image slide needs a
picture to replace. When it cannot, :func:`compose` returns ``None`` and the
caller falls back to Seam D, which can place anything. A predictable slide
beats a gutted composition.

**Legibility is still enforced.** Inheriting the designer's typography is the
feature; inheriting *unreadable* typography is not. Any run that declares a
size under :data:`md2star.pptx_audit.LEGIBLE_MIN_PT` is lifted to that floor. A
run that declares *no* size is left alone: it inherits from the designer's own
master, which is presentation-grade by construction — and the audit reads
declared sizes for the same reason.

Dependency direction is one-way, like the audit's: this module imports the
assembler's placeholder vocabulary and image placement, and
:func:`md2star.pptx_assemble.assemble` imports *this* one inside its own body,
so the pair never forms an import cycle.

Specified in ``docs/design/pptx-template-intelligence.md`` §10.4 (and §4.3 for
the A-D seams this one extends).

Author
------
[Warith HARCHAOUI](https://linkedin.com/in/warith-harchaoui/)
"""

from __future__ import annotations

import copy
import math
import re
from dataclasses import dataclass, field
from pathlib import Path

from pptx.enum.text import MSO_AUTO_SIZE
from pptx.opc.constants import RELATIONSHIP_TYPE as RT
from pptx.oxml.ns import qn
from pptx.util import Pt

from .logging import get_logger
from .pptx_assemble import (
    EMU_PER_INCH,
    LATENT_KINDS,
    TITLE_KINDS,
    add_credit,
    add_image,
    is_ornament,
    placeholder_kind,
)
from .pptx_audit import LEGIBLE_MIN_PT
from .pptx_fit import AVG_CHAR_WIDTH_EM, EMU_PER_PT, FIT_SLACK, LINE_SPACING

logger = get_logger(__name__)

#: A text frame counts as a *peer* of the biggest one on the slide — one column
#: of a multi-column design, rather than a caption or a footnote — when it has
#: at least this share of the biggest one's area. Peers split the bullets
#: between them; everything else is retired.
PEER_AREA_RATIO = 0.4

#: A picture covering at least this share of the canvas is a *content* slot
#: (the designer's stand-in photo), so it is replaced by the slide's own image,
#: or removed with it when the slide has none. Anything smaller is furniture —
#: a logo, an icon, a rule — and stays exactly where the designer put it.
CONTENT_PICTURE_AREA = 0.04

#: Relationship ids are per-part, so every ``r:*`` attribute in the copied XML
#: has to be re-pointed at the clone's own relationship for the same target.
_REL_ATTRS = tuple(
    qn(f"r:{name}") for name in ("id", "embed", "link", "pict", "dm", "lo", "qs", "cs")
)

#: Relationships that must *not* be carried over: the layout is wired by
#: ``add_slide`` already, and a notes part belongs to exactly one slide —
#: pointing a second slide at the same one writes a file PowerPoint offers to
#: repair on open.
_SKIP_RELS = frozenset({RT.SLIDE_LAYOUT, RT.NOTES_SLIDE})

#: ``ppt/slides/slide12.xml`` -> 12. Example pages are numbered by *part*, so
#: this is how a catalog page number finds its slide (see
#: :func:`example_slides`).
_SLIDE_PART = re.compile(r"slide(\d+)\.xml\Z")


[docs] @dataclass class Content: """What a Markdown slide wants placed, independent of how it gets placed. The assembler has made every content decision by the time Seam E is asked — which branch the slide took, how many bullets survived the eyeball loop's cap, where a Mermaid diagram was rendered to — so this carries the outcome and both seams consume the same resolved payload. """ title: str bullets: list[str] = field(default_factory=list) image: Path | None = None alt: str = "" credit: str = ""
[docs] @dataclass class Roles: """Every shape of a slide, sorted into what Seam E may do with it.""" #: Where the slide's heading goes: the designer's own title placeholder #: when the page has one, else its largest, highest type. title: object | None = None #: Text frames that can carry bullets, in reading order #: (:func:`reading_order`: columns left to right, rows top to bottom). bodies: list = field(default_factory=list) #: Pictures big enough to be content slots, biggest first. pictures: list = field(default_factory=list) #: Design, not content: decorative shapes, small pictures, and the latent #: date / footer / slide-number placeholders. Never touched. furniture: list = field(default_factory=list) #: Content Seam E cannot restate — a chart, a table, SmartArt, a group with #: words in it. One of these is enough to disqualify the whole page, since #: cloning it would publish the designer's own numbers (see #: :func:`can_carry`). foreign: list = field(default_factory=list)
# ── reading the template's example slides ────────────────────────────────────
[docs] def example_slides(prs) -> dict[int, object]: """Map each example *page number* to the slide object that is that page. Keyed by the number in the slide's part name rather than by position in the deck, because that is how :func:`md2star.pptx_layout.layout_pages` numbers the pages it records in ``LayoutInfo.example_pages``. The two agree even in a template whose slides have been reordered in PowerPoint since they were created, where part order and presentation order no longer match. Snapshot it *before* cloning anything: the clones are slides of the same presentation, and a caller that re-read this map mid-build would start finding them in it. """ pages: dict[int, object] = {} for slide in prs.slides: match = _SLIDE_PART.search(str(slide.part.partname)) if match: pages[int(match.group(1))] = slide return pages
[docs] def template_slide_ids(prs) -> list: """The ``<p:sldId>`` entries the template shipped with. Seam E has to *read* the designer's example slides while it builds, so unlike Seam D it cannot clear them up front (:func:`md2star.pptx_assemble.clear_slides`). It records them here, appends its own slides after them, and drops these at the end — see :func:`drop_template_slides`. Authored order survives either way, since both seams append. """ return list(prs.slides._sldIdLst) # noqa: SLF001 — no public remove-slide API
[docs] def drop_template_slides(prs, ids: list) -> None: """Remove the example slides *ids*, leaving only what the build appended. The images on those pages are not dropped with them when a clone uses one: :func:`_copy_rels` gives every clone its *own* relationship to the same image part, so the part stays referenced and gets written. """ lst = prs.slides._sldIdLst # noqa: SLF001 — same workaround as clear_slides for sld in ids: prs.part.drop_rel(sld.rId) lst.remove(sld)
# ── classifying a page's shapes ────────────────────────────────────────────── def _area(shape) -> int: """Shape area in EMU². ``0`` when the geometry is not resolvable.""" return (shape.width or 0) * (shape.height or 0) def _is_picture(shape) -> bool: # The element tag, not ``shape_type``: a picture *placeholder* the designer # filled is also a <p:pic>, and that is exactly the case we must catch. return shape.element.tag == qn("p:pic") def _is_textbox(shape) -> bool: """True for a bare text box, false for an autoshape or a placeholder. OOXML records the difference itself (``<p:cNvSpPr txBox="1">``), which is better than guessing from the fill: a text box exists only for its text, so an unused one is filler and can go, while an unused *autoshape* is a drawn object — a chevron, a card, a band — that the composition needs. """ properties = shape.element.find(f".//{qn('p:cNvSpPr')}") return properties is not None and properties.get("txBox") == "1" def _deep_text(shape) -> str: """All text anywhere under *shape*, including inside a group.""" return "".join(node.text or "" for node in shape.element.iter(qn("a:t"))).strip() def _max_pt(shape) -> float: """Largest *declared* run size in points (``0.0`` when all are inherited).""" if not shape.has_text_frame: return 0.0 sizes = [ run.font.size.pt for paragraph in shape.text_frame.paragraphs for run in paragraph.runs if run.font.size is not None ] return max(sizes, default=0.0) def _overlaps_horizontally(a, b) -> bool: """Whether two frames occupy the same vertical band of the slide.""" left_a, left_b = (a.left or 0), (b.left or 0) right_a, right_b = left_a + (a.width or 0), left_b + (b.width or 0) shared = min(right_a, right_b) - max(left_a, left_b) narrower = min((a.width or 0), (b.width or 0)) return narrower > 0 and shared > 0.5 * narrower
[docs] def reading_order(shapes: list) -> list: """Order frames the way a reader walks them: columns first, then rows. Sorting by ``(top, left)`` is right for a row of columns and wrong for a split design, where it interleaves the two halves — the left panel's one line landing between the right panel's two. Found on a real template, not reasoned about: a cloned Jellysmack split page took bullet 2 into its lime panel and bullets 1 and 3 into the black half. So frames are grouped into **columns** (overlapping vertical bands), the columns run left to right, and each column runs top to bottom. A single row of boxes degenerates to left-to-right, a single stack to top-to-bottom, and the split design comes out in the order a reader actually reads it. """ columns: list[list] = [] for shape in sorted(shapes, key=lambda s: s.left or 0): for column in columns: if _overlaps_horizontally(column[0], shape): column.append(shape) break else: columns.append([shape]) return [shape for column in columns for shape in sorted(column, key=lambda s: s.top or 0)]
[docs] def classify(slide, canvas: tuple[int, int]) -> Roles: """Sort every shape of *slide* into a :class:`Roles`. Parameters ---------- slide: The page being classified — a designer example, or the clone of one. canvas: ``(width, height)`` of the deck in EMU; a picture's share of it is what separates a content slot from a logo. """ canvas_area = max(1, canvas[0] * canvas[1]) roles = Roles() texts: list = [] for shape in slide.shapes: if placeholder_kind(shape) in LATENT_KINDS: # Date / footer / slide number: empty is their correct state, # PowerPoint fills them itself. roles.furniture.append(shape) elif shape.element.tag == qn("p:graphicFrame"): # A chart, a table or SmartArt carries the designer's own data and # there is no "replace the text" that restates it. roles.foreign.append(shape) elif shape.element.tag == qn("p:grpSp"): # A group is design when it is shapes (an icon, a bracket, a badge) # and content when it has words in it — and its words are out of # reach of the retire-or-fill rule, since ``slide.shapes`` does not # descend into groups. design = is_ornament(_deep_text(shape)) (roles.furniture if design else roles.foreign).append(shape) elif _is_picture(shape): slot = _area(shape) >= CONTENT_PICTURE_AREA * canvas_area (roles.pictures if slot else roles.furniture).append(shape) elif shape.has_text_frame and shape.text_frame.text.strip(): texts.append(shape) else: roles.furniture.append(shape) roles.pictures.sort(key=lambda shape: -_area(shape)) named_titles = [shape for shape in texts if placeholder_kind(shape) in TITLE_KINDS] if named_titles: # The designer's own answer, and the one that feeds the outline pane # and a screen reader's reading order. roles.title = named_titles[0] elif texts: # No title placeholder on the page: the biggest declared type wins, # ties broken by whichever sits higher — a heading is big and near the # top, and on a page where nothing declares a size, the top one is # still the better guess than the first in XML order. roles.title = max(texts, key=lambda s: (_max_pt(s), -(s.top or 0), _area(s))) roles.bodies = reading_order([shape for shape in texts if shape is not roles.title]) return roles
[docs] def fits(shape, lines: list[str]) -> bool: """Whether *lines* can sit in *shape* without dropping under the floor. The designer sized that frame for the designer's own line. Ours may be three times as long, and the renderer answers overflow by shrinking the type — quietly, which is the point: a title that falls from 38 pt to 27 pt is still a title, while three bullets squeezed into a one-line kicker come out at 9 pt and nobody in the room can read them. So the question is not "does it overflow" but "how small would it have to get". Same character model as the rest of the suite (0.5 em mean advance, 1.2 line spacing) and the same square-root implied-size arithmetic as :func:`md2star.pptx_audit._check_overrun` — capacity scales with the square of the point size, since line height *and* characters per line both do. A frame that declares no size at all is left alone: its typography comes from the designer's master, and no signal is not a bad signal. """ declared = _max_pt(shape) available = (shape.height or 0) / EMU_PER_PT width_pt = (shape.width or 0) / EMU_PER_PT if declared <= 0 or available <= 0 or width_pt <= 0: return True chars_per_line = max(1.0, width_pt // (declared * AVG_CHAR_WIDTH_EM)) needed = sum(math.ceil(len(line) / chars_per_line) for line in lines) * declared * LINE_SPACING if needed <= available * FIT_SLACK: return True return declared * math.sqrt(available / needed) >= LEGIBLE_MIN_PT
[docs] def peers(bodies: list) -> list: """The body frames that are columns of one design, not captions of it.""" if not bodies: return [] biggest = max(_area(shape) for shape in bodies) if biggest <= 0: return list(bodies) # no resolvable geometry: treat them all as equals return [shape for shape in bodies if _area(shape) >= PEER_AREA_RATIO * biggest]
[docs] def can_carry(roles: Roles, content: Content) -> bool: """Whether a page classified as *roles* can actually hold *content*. The refusals are the interesting part, and each one is a composition this seam would otherwise break: * **no title frame** — nowhere to put the heading, and a cloned page with the designer's heading still on it is the leak this module exists to avoid; * **content that cannot be restated** (``roles.foreign``) — a chart or a table would ship with the designer's numbers in it; * **an image with no slot to put it in** — the picture would land on top of a composition that was not drawn around it; * **a slot with no image to put in it** — the mirror case, and the one a vision model catches first: a page the designer drew around a photograph keeps the shape of that photograph, so taking it out (which we must, it is their content) leaves the void the rest of the page was balanced against. A text slide belongs on a page the designer built for text; * **fewer bullets than columns** — a three-column design with one filled column reads as broken; * **text that would have to shrink past the legibility floor** to fit the frames the designer sized for their own line (:func:`fits`) — the page that taught this one put three bullets in a kicker meant for four words, and the renderer dutifully set them at 9 pt. Every refusal is the same move: the page is declined and Seam D, which can place anything, takes the slide instead. """ if roles.title is None or roles.foreign: return False if (content.image is not None) != bool(roles.pictures): return False if content.bullets: columns = peers(roles.bodies) if not columns or len(content.bullets) < len(columns): return False shares = distribute(content.bullets, len(columns)) return all(fits(frame, share) for frame, share in zip(columns, shares, strict=True)) return True
[docs] def distribute(bullets: list[str], frames: int) -> list[list[str]]: """Split *bullets* across *frames* columns in reading order. Near-equal contiguous chunks, so a three-column design stays balanced instead of overflowing its first column and leaving the other two thin. :func:`can_carry` guarantees there are at least as many bullets as frames, so no column comes out empty. """ if frames <= 1: return [list(bullets)] shares: list[list[str]] = [] start = 0 for position in range(frames): take = len(bullets) // frames + (1 if position < len(bullets) % frames else 0) shares.append(list(bullets[start : start + take])) start += take return shares
# ── cloning a page ─────────────────────────────────────────────────────────── def _copy_rels(src_part, dst_part) -> dict[str, str]: """Relate *dst_part* to everything *src_part* points at; old rId -> new.""" mapping: dict[str, str] = {} for rel in src_part.rels.values(): if rel.reltype in _SKIP_RELS: continue if rel.is_external: mapping[rel.rId] = dst_part.relate_to(rel.target_ref, rel.reltype, is_external=True) else: # The same part object, not a copy of it: two slides sharing one # image part is how PowerPoint itself stores a reused photo. mapping[rel.rId] = dst_part.relate_to(rel.target_part, rel.reltype) return mapping def _retarget(element, mapping: dict[str, str]) -> None: """Rewrite every relationship reference under *element* through *mapping*.""" for node in element.iter(): for attribute in _REL_ATTRS: old = node.get(attribute) if old is None: continue if old in mapping: node.set(attribute, mapping[old]) else: # Only the layout and notes relationships are deliberately not # copied, and neither is referenced from a shape — so this is # worth a line in a verbose log rather than silence. logger.debug("clone: %s=%r has no copied relationship", attribute, old)
[docs] def clone_slide(prs, src): """Duplicate *src* — a slide of *prs* — and return the new slide. python-pptx has no slide-copy API; this is the shape-tree deepcopy plus relationship re-pointing that stands in for one. It stays *inside* one presentation on purpose: the deck Seam E builds is opened from the designer template itself, so their example pages are already in the same package and no cross-file part surgery is needed. """ dst = prs.slides.add_slide(src.slide_layout) # ``add_slide`` clones the LAYOUT's placeholders onto the new slide. The # example page already carries its own filled copies of them, so leaving # ours in place would ship two shapes per region — the overlay/placeholder # defect §10.1 closed for Seam D, walked back in from the other side. for shape in list(dst.shapes): shape.element.getparent().remove(shape.element) rels = _copy_rels(src.part, dst.part) tree = dst.shapes._spTree # noqa: SLF001 — python-pptx exposes no shape-insert API for shape in src.shapes: element = copy.deepcopy(shape.element) _retarget(element, rels) tree.append(element) # A slide-level background override (a photo, a gradient the layout itself # does not carry) is part of the composition and lives outside the shape # tree, so a shape-only copy would silently lose it. src_csld = src.element.find(qn("p:cSld")) dst_csld = dst.element.find(qn("p:cSld")) background = None if src_csld is None else src_csld.find(qn("p:bg")) if background is not None and dst_csld is not None: copied = copy.deepcopy(background) _retarget(copied, rels) dst_csld.insert(0, copied) # <p:bg> must precede <p:spTree> return dst
# ── replacing a page's content ─────────────────────────────────────────────── def _retext(paragraph, text: str) -> None: """Make *paragraph* say exactly *text*, in its first run's formatting. Run-level ``rPr`` is what carries the designer's face, size, weight and theme colour, and nothing in python-pptx's public API copies it — so the run is *kept* and only its text replaced. Everything after it goes: a second run, a line break, a field (their slide number, their date) are all the designer's own, and a cloned paragraph must say one thing, ours. """ runs = paragraph.findall(qn("a:r")) for extra in runs[1:]: paragraph.remove(extra) for tag in ("a:br", "a:fld"): for node in paragraph.findall(qn(tag)): paragraph.remove(node) run = runs[0] if runs else paragraph.add_r() # A hyperlink lives in the run's own ``rPr``, so it would survive the text # swap and leave our words carrying a link the author never wrote — usually # to a page of the template that is not even in this deck. It is also the # only shape-level reference a slide can hold to *another slide*, so # dropping it is what keeps a cloned page from pinning an example page the # build is about to remove. properties = run.find(qn("a:rPr")) if properties is not None: for tag in ("a:hlinkClick", "a:hlinkMouseOver"): for link in properties.findall(qn(tag)): properties.remove(link) run.text = text def _enforce_floor(shape, font_delta: int = 0, min_pt: float | None = None) -> None: """Apply *font_delta*, never going under the legibility floor (or *min_pt*). A run with no declared size is left untouched: its size comes from the designer's own master, which is the typography this seam exists to inherit, and :mod:`md2star.pptx_audit` reads declared sizes for the same reason. """ for paragraph in shape.text_frame.paragraphs: for run in paragraph.runs: if run.font.size is None: continue floor = max(LEGIBLE_MIN_PT, min_pt or 0.0) run.font.size = Pt(max(floor, run.font.size.pt + font_delta)) def _fill_frame( shape, lines: list[str], *, font_delta: int = 0, min_pt: float | None = None ) -> None: """Replace a designer frame's paragraphs with one per line of *lines*. Every line is a copy of the designer's own first paragraph, so indent level, bullet glyph and run typography come along with it — which is why Seam E does *not* prefix a "• " the way Seam D must: the master already draws the glyph, and adding ours would double it. """ # A text frame with no paragraph at all is invalid OOXML, so a frame asked # for nothing still gets one — empty. Unreachable through `compose`, whose # feasibility check guarantees a line per column, and cheaper to hold than # to prove impossible for every future caller. lines = lines or [""] frame = shape.text_frame body = frame._txBody # noqa: SLF001 — paragraph copying needs the element paragraphs = body.findall(qn("a:p")) # Prefer a paragraph that actually has a run to inherit from; a designer's # first line is occasionally an empty spacer. with_runs = [p for p in paragraphs if p.findall(qn("a:r"))] prototype = copy.deepcopy((with_runs or paragraphs)[0]) for paragraph in paragraphs: body.remove(paragraph) for line in lines: clone = copy.deepcopy(prototype) _retext(clone, line) body.append(clone) frame.word_wrap = True # Belt and braces, exactly as Seam D: our line is not the designer's filler # and may be longer, so the renderer shrinks it rather than clipping it — # unless a floor is set (--min-pt), which shrinking would break. frame.auto_size = MSO_AUTO_SIZE.TEXT_TO_FIT_SHAPE if min_pt is None else MSO_AUTO_SIZE.NONE _enforce_floor(shape, font_delta, min_pt) def _retire(shape) -> None: """Guarantee a frame the deck did not fill ships nothing the designer wrote. Three outcomes, in order: ornament stays (the "01" of a numbered step is the design, not filler), a bare text box is removed (it existed only for the words we are taking out of it), and an autoshape or placeholder is emptied but kept (it is a drawn object; the assembler's own sweep then removes any placeholder left empty). """ if is_ornament(shape.text_frame.text.strip()): return if _is_textbox(shape): shape.element.getparent().remove(shape.element) return shape.text_frame.clear() def _replace_picture(slide, picture, image: Path, alt: str, credit: str = "") -> None: """Swap the designer's stand-in photo for *image*, inside its own slot. Their frame is kept as the *box*, not as the frame: contain-fitting into it (:func:`md2star.pptx_assemble.add_image`) preserves the composition's slot while refusing to stretch a picture whose aspect does not match the stand-in's. A cropped stand-in's crop goes with it, which is the price of never distorting the user's own image. """ box = ( (picture.left or 0) / EMU_PER_INCH, (picture.top or 0) / EMU_PER_INCH, (picture.width or 0) / EMU_PER_INCH, (picture.height or 0) / EMU_PER_INCH, ) element = picture.element parent = element.getparent() position = list(parent).index(element) parent.remove(element) pic = add_image(slide, image, box=box, alt=alt) # Stacking order is composition: the designer may have text over this # photo, and ``add_picture`` appends, which would bury it. Put the new # picture back exactly where the old one sat in the shape tree. placed = list(parent)[-1] parent.remove(placed) parent.insert(position, placed) if credit: # Appended, so it paints over the photo and whatever the designer laid on it. add_credit(slide, pic, credit) # ── the seam ───────────────────────────────────────────────────────────────── def _drop_unused_rels(slide) -> int: """Drop the clone's relationships nothing on it points at any more. :func:`_copy_rels` has to relate the clone to *everything* the example page pointed at, because the copied XML is re-targeted afterwards. Whatever the content replacement then removed — the designer's stand-in photo, most of all — leaves its relationship behind, and a relationship is what keeps a part in the saved file. Without this sweep their stock photo ships inside the deck, invisible but present, which is the same mistake as their filler text, only harder to notice. """ referenced = set() for node in slide.element.iter(): for attribute in _REL_ATTRS: value = node.get(attribute) if value is not None: referenced.add(value) dropped = 0 for rId, rel in list(slide.part.rels.items()): # The layout relationship is never referenced from the slide's own XML # and is mandatory; a notes relationship does not exist yet (the # assembler adds the notes after this). Both are in _SKIP_RELS. if rel.reltype in _SKIP_RELS or rId in referenced: continue slide.part.drop_rel(rId) dropped += 1 return dropped
[docs] def pick_example( examples: dict[int, object], pages: list[int], content: Content, *, used: set[int], canvas: tuple[int, int], avoid: list[int] | tuple[int, ...] = (), ) -> tuple[int, object] | None: """Choose which example page to clone for *content*, or ``None``. Candidates are the chosen layout's own ``example_pages``, in the designer's order, narrowed to those that :func:`can_carry` the content. Among those, a page this deck has not cloned yet wins — so a layout demonstrated by three pages gives three different-looking slides instead of the same one three times — and otherwise the first feasible page is reused. A slide with an image then takes the page offering the **biggest** picture slot, since contain-fitting means that slot is the size the reader actually sees. *avoid* is the hard exclusion the Ralph Eyeball Loop writes: a page a vision model has already judged not to be carrying this slide. *used* is only a preference (variety across the deck); *avoid* is a refusal. When every feasible page has been refused, the answer is ``None`` and the slide falls back to Seam D, which is the safe generic the loop wants anyway. """ feasible: list[tuple[int, object, int]] = [] for page in pages: if page in avoid: continue slide = examples.get(page) if slide is None: continue roles = classify(slide, canvas) if can_carry(roles, content): # How much room this page gives a picture, which is the one thing # worth ranking the designer's own order by: a chart dropped into a # thumbnail-sized slot is the most visible way this seam can fail, # and contain-fitting means the slot is exactly what the reader # ends up seeing. Zero for a text slide, so their order stands. room = _area(roles.pictures[0]) if (content.image and roles.pictures) else 0 feasible.append((page, slide, room)) if not feasible: return None fresh = [c for c in feasible if c[0] not in used] or feasible page, slide, _ = max(fresh, key=lambda c: c[2]) if content.image else fresh[0] return page, slide
[docs] def compose(prs, example, content: Content, *, font_delta: int = 0, min_pt: float | None = None): """Clone *example* and put *content* in it; ``None`` if it cannot carry it. Classification runs twice on purpose: once on *example* to decide feasibility before anything is written, and once on the clone, whose shapes are different objects. Deciding first is what makes a refusal free of side effects — the caller gets ``None`` and a presentation it can still build a Seam-D slide on. Returns ------- object | None The new slide, or ``None`` when the page was refused. """ canvas = (prs.slide_width, prs.slide_height) if not can_carry(classify(example, canvas), content): return None slide = clone_slide(prs, example) roles = classify(slide, canvas) if roles.title is not None: _fill_frame(roles.title, [content.title], font_delta=font_delta, min_pt=min_pt) columns: list = [] if content.bullets: columns = peers(roles.bodies) for frame, share in zip(columns, distribute(content.bullets, len(columns)), strict=True): _fill_frame(frame, share, font_delta=font_delta, min_pt=min_pt) for frame in roles.bodies: if frame not in columns: _retire(frame) spare = roles.pictures if content.image is not None and roles.pictures: _replace_picture(slide, roles.pictures[0], content.image, content.alt, content.credit) spare = roles.pictures[1:] for picture in spare: # A content slot with nothing of ours to put in it: the designer's # stand-in photo is their content, and shipping it inside someone # else's deck is the same mistake as shipping their filler text. picture.element.getparent().remove(picture.element) orphans = _drop_unused_rels(slide) if orphans: logger.debug("clone: dropped %d relationship(s) nothing points at", orphans) return slide