"""plank_deck.py — the Plank deck house style. Save this file into the workspace as `plank_deck.py`, next to your deck script, and `from plank_deck import *`. Build slides from the primitives below. Every primitive measures its text against the box it has to fit in before it draws anything, so a slide that cannot hold its content is refused rather than quietly built. A DeckError means the slide is carrying too much, not that the font should be smaller. That measurement is the check that runs BEFORE the file exists. It is not the only one any more. When the deck is written, run `plank_deck_qa.py` over it: that converts the deck with headless LibreOffice and reports what only a renderer can know - a table whose rows grew, two shapes that landed on each other, a chart whose labels collide - and writes the PDF you hand over alongside the .pptx. Build, then look: curl -o plank_deck_qa.py https://plank.md/help/assets/plank_deck_qa.py python3 plank_deck_qa.py your-deck.pptx Requires python-pptx, which is preinstalled in every Plank workspace. """ import os import warnings from pptx import Presentation from pptx.util import Inches, Pt from pptx.dml.color import RGBColor from pptx.enum.shapes import MSO_SHAPE from pptx.enum.text import PP_ALIGN, MSO_ANCHOR, MSO_AUTO_SIZE from pptx.chart.data import CategoryChartData from pptx.enum.chart import XL_CHART_TYPE, XL_LEGEND_POSITION from pptx.oxml import parse_xml from pptx.oxml.ns import nsdecls, qn # --- themes --------------------------------------------------------------- # # Every colour and typeface a slide can draw with lives in a Theme, and every # primitive reads it off the deck it is building rather than off a module # constant. That indirection is the feature: a workspace can build a pitch in # one look and a board pack in another, and no primitive has to know which. # # There are two, and the DEFAULT is the dark one: # # "studio" - art-directed. Near-black surfaces, full-bleed photography, # italic display type, the Plank blue accent used sparingly, and # persistent brand chrome. This is what a pitch, a brand deck or a launch # wants to look like, and it is what the reference deck that prompted this # style does. It is the default because most decks Plank is asked for are # persuasion, not accounting. # # "report" - the light typographic style this file shipped with. Warm ivory, # upright type, no imagery. Still right, and still fully supported: a # board pack, a financial review, anything that will be printed or read on # paper is better in it. `new_deck(theme="report")`. # # Both themes accent in Plank blue #4F6DF5. They differ in surface, type # style and imagery - not in brand colour - because a deck is recognisably # Plank's in either look. What the dark theme adds is a lighter step of that # same blue for small type; see accent_text in Theme. # # What a theme is NOT allowed to change is geometry, type SIZES or budgets. # Those are shared, so the same text fits in either look and there is exactly # one budget table to keep true. A theme that moved the type sizes would need # a budget table of its own, and two budget tables is how numbers drift apart. FONT = "Inter" MONO = "JetBrains Mono" # Categorical series colours, one list per surface. # # BOTH lists are validated colourblind-safe IN THIS ORDER, and the validation # does NOT transfer between surfaces: it is a claim about contrast against a # specific background and about the separation of adjacent pairs, and the # ivory numbers were measured on ivory. Re-run the check, do not reason by # analogy. Assign in order. Never cycle, never substitute, never reorder. # Ivory surface #FAFAF7: worst adjacent CVD dE 15.1, normal-vision 15.6, all # six >=3:1 contrast. SERIES_REPORT = [ RGBColor(0x4F, 0x6D, 0xF5), # 1 blue RGBColor(0xC2, 0x70, 0x1F), # 2 ochre RGBColor(0x0E, 0x9F, 0x9F), # 3 teal RGBColor(0x4C, 0x7A, 0x2E), # 4 green RGBColor(0x9B, 0x5D, 0xE5), # 5 violet RGBColor(0xC2, 0x45, 0x6B), # 6 rose ] # Obsidian surface #0A0A0F: worst adjacent CVD dE 16.1 (protan), normal-vision # 18.2, all six >=3:1 contrast, all six inside the dark-mode OKLCH lightness # band 0.48-0.67. Re-derived from scratch rather than lightened by eye - the # ivory list fails the dark band outright, and its ochre and green fall under # 3:1 on near-black. # # Slot 1 and the studio accent are now both blue, which they were not when the # accent was acid green. They do not collide in use, because they never label # the same thing: slot 1 is a data mark and carries a legend entry, the accent # is chrome and never appears in one. The separation is real but not large - # accent_text #8FA6FF against slot 1 is dE2000 10.3 normal and 7.3 under the # worst CVD, which clears this list's own worst adjacent pair (6.1, teal/green) # but not by much. If you ever put the accent ON a chart - a callout, a target # line, a highlighted bar - use ink or a tone colour, not the accent. SERIES_STUDIO = [ RGBColor(0x4B, 0x8E, 0xFF), # 1 blue RGBColor(0xC0, 0x8A, 0x1F), # 2 ochre RGBColor(0x00, 0xA4, 0xBF), # 3 teal RGBColor(0x30, 0xAE, 0x40), # 4 green RGBColor(0x9B, 0x5C, 0xD7), # 5 violet RGBColor(0xDE, 0x50, 0x77), # 6 rose ] class Theme(object): """One deck look. Colour, typeface and type style; never geometry.""" def __init__(self, name, bg, surface, hairline, ink, ink_soft, accent, good, bad, warn, series, display_italic=False, font=FONT, mono=MONO, scrim=RGBColor(0x0A, 0x0A, 0x0F), over_image=RGBColor(0xFA, 0xFA, 0xF7), over_image_soft=RGBColor(0xCB, 0xCB, 0xD4), accent_over_image=None, accent_text=None): self.name = name self.bg = bg self.surface = surface self.hairline = hairline self.ink = ink self.ink_soft = ink_soft self.accent = accent self.good = good self.bad = bad self.warn = warn self.series = list(series) # Display type set italic. It is the single cheapest thing that makes # a deck read as art-directed rather than as a template, and it is a # style, not a size, so it costs the budgets nothing measurable - # Inter Italic's advance widths are within 1% of the upright, well # inside the 9% headroom every proportional estimate already carries. self.display_italic = display_italic self.font = font self.mono = mono # Text over a photograph is NEVER the theme's own ink: a photograph is # not a surface whose colour anything knows. It is always light type # on a dark scrim, in both themes, because that is the only pairing a # scrim can guarantee. self.scrim = scrim self.over_image = over_image self.over_image_soft = over_image_soft # An accent that works as a 3pt rule does not automatically work as a # 9pt tracked label, and an accent that works on the theme's own # surface does not automatically work on a scrim. So the accent is # three values, not one, and which one you get is decided by ROLE: # # accent - rules, bars and any other graphic mark. WCAG # floor 3:1, because these are large shapes. # accent_text - small tracked labels drawn on the theme's own # surfaces: the 9pt slide number, the 11pt step # numbers, the 11pt card label. Floor 4.5:1, and # it has to clear it on `surface` as well as on # `bg` - the card is the darker of the two in the # studio theme and it is where an accent label # fails first. # accent_over_image - the same small labels when they land on a # scrim over a photograph. # # Both extra values default to `accent`, so a theme whose accent is # bright enough for every role declares one colour and stops. The # split exists because Plank blue is NOT that colour on near-black: # #4F6DF5 is 4.55:1 on obsidian and 3.98:1 on the slate card, so as a # label it ships at the edge of the warn band and under it on the # card, while as a rule it is comfortably clear. See THEMES below. self.accent_over_image = accent_over_image or accent self.accent_text = accent_text or accent def tone(self, value, what): """Resolve a tile's `tone` to a colour. None means neutral.""" if value is None: return self.ink_soft key = str(value).strip().lower() table = {"good": self.good, "bad": self.bad, "neutral": self.ink_soft} if key not in table: raise DeckError( "%s tone is %r - use one of bad, good, neutral. The sign is " "not read: a delta with no tone is neutral, because '-40%%' is " "a win on cost and a loss on revenue and nothing here can tell " "which." % (what, value) ) return table[key] THEMES = { "studio": Theme( name="studio", bg=RGBColor(0x0A, 0x0A, 0x0F), # obsidian surface=RGBColor(0x1A, 0x1A, 0x22), # slate card hairline=RGBColor(0x2E, 0x2E, 0x3A), ink=RGBColor(0xFA, 0xFA, 0xF7), # warm ivory ink_soft=RGBColor(0xA2, 0xA2, 0xAE), # Plank blue. The brand accent, and the same hex the report theme # uses - one accent across both looks, which is the point of a brand # colour. It is 4.55:1 on obsidian: fine as a rule (floor 3:1), and # too dark to set 9pt type in, which is what accent_text is for. accent=RGBColor(0x4F, 0x6D, 0xF5), # Plank blue lightened for near-black - hue is unchanged to within # 2.3 degrees in OKLCH, so it still reads as the brand blue, not as a # second colour. 8.53:1 on obsidian and 7.46:1 on the slate card, # against 4.55:1 and 3.98:1 for #4F6DF5 itself. The card is the case # that forces this: an accent label there is the one that falls under # 4.5:1, and it is the same hex the report theme already uses over # imagery, so the palette gains a lightness step and no new hue. accent_text=RGBColor(0x8F, 0xA6, 0xFF), accent_over_image=RGBColor(0x8F, 0xA6, 0xFF), good=RGBColor(0x34, 0xD3, 0x99), bad=RGBColor(0xFF, 0x6B, 0x6B), # #EF4444 is only 4.0:1 on obsidian warn=RGBColor(0xF5, 0xB0, 0x2B), series=SERIES_STUDIO, display_italic=True, ), "report": Theme( name="report", bg=RGBColor(0xFA, 0xFA, 0xF7), surface=RGBColor(0xF0, 0xED, 0xE8), hairline=RGBColor(0xDD, 0xD8, 0xD0), ink=RGBColor(0x3A, 0x3A, 0x42), ink_soft=RGBColor(0x6B, 0x6B, 0x75), accent=RGBColor(0x4F, 0x6D, 0xF5), # 4.15:1 on ivory # The light theme has the same problem as the dark one and it is # slightly worse: #4F6DF5 is 4.15:1 on ivory and 3.72:1 on the stone # card, so its 9pt slide number and 11pt step numbers were already # under 4.5:1 before the dark theme was ever added. Same fix, opposite # direction - a darker step of the identical hue (shift 0.0 degrees in # OKLCH), 5.74:1 on ivory and 5.14:1 on stone. accent_text=RGBColor(0x3F, 0x58, 0xCE), good=RGBColor(0x34, 0xD3, 0x99), bad=RGBColor(0xEF, 0x44, 0x44), warn=RGBColor(0xF5, 0x9E, 0x0B), series=SERIES_REPORT, display_italic=False, accent_over_image=RGBColor(0x8F, 0xA6, 0xFF), # 7.3:1 on the scrim ), } DEFAULT_THEME = "studio" # --- geometry ------------------------------------------------------------- W, H = Inches(13.333), Inches(7.5) M_L = M_R = Inches(0.75) M_T, M_B = Inches(0.6), Inches(0.7) GUTTER = Inches(0.2) CONTENT_W = W - M_L - M_R COL_W = int((CONTENT_W - GUTTER * 11) / 12) # --- the chrome bands ----------------------------------------------------- # # Two strips are reserved on every slide, whether or not a deck sets a brand: # a metadata row across the top and a footer across the bottom. They are what # carries a logo lockup, the "brand ... / agency ... / production ..." row, the # confidentiality line, the city, the year and the slide number. # # Reserved unconditionally, and that is the decision worth naming. The bands # could have been added only when set_brand() is called - but then a deck built # without a brand would lay out differently from the same deck with one, every # budget in the table would have two values, and adding a logo late would # silently re-flow every slide. One geometry, sometimes empty, is cheaper than # two geometries that have to agree. # # They sit INSIDE the safe margins, not in them. Putting the metadata row up # against the top edge is what the reference deck does and it looks better, but # plank_deck_qa.py calls text outside the safe area an error - correctly, since # projectors crop it - so the row starts at the top margin and the title band # moves down to make room. CHROME_H = Inches(0.24) HEADER_Y = M_T # 0.60 -> 0.84 FOOTER_H = Inches(0.25) FOOTER_Y = H - M_B - FOOTER_H # 6.55 -> 6.80 # The title block is bottom-anchored in a box tall enough for two lines, so a # long title grows upward into empty space instead of down onto the accent # rule. The rule and the body therefore never move, whatever the title length. # It starts below the header band: 0.94in rather than the 0.55in it used # before the chrome existed. TITLE_TOP = Inches(0.94) # The band the title type actually occupies: two rendered line boxes - 37.8pt # each, measured off the render at 30pt with line spacing 1.05 - plus 3.6pt. # Unchanged, so the title sits exactly where it always did. TITLE_BAND = Inches(1.10) # Space under the last line, INSIDE the box. It used to be zero, and that was # the bug: a rendered glyph extends a little below its own line box, so with # the box ending exactly where the type ended, every titled slide in every # deck rendered its title's descender outside the shape that owns it. Measured # at 1.19pt - the largest escape anywhere in an otherwise clean deck, and four # times the next one (a source note's 0.27pt). # # It stayed invisible because plank_deck_qa.py tolerates 2pt of overhang, and # that tolerance was calibrated against a deck carrying this very fault: the # number meant to catch it was widened until it didn't. Only 0.8pt of that # margin was left, and renderers disagree by more than that, which is how a # workspace render came back reporting 4.5pt and warning on nine slides at # once. # # The fix is an explicit bottom inset, NOT a taller box. Raising TITLE_H on # its own changes nothing at all: the frame is bottom-anchored, so the type # moves down with the box bottom and the descender stays exactly as far # outside it - measured 1.19pt at TITLE_H 1.10in and 1.22pt at 1.25in, where # the box has also swallowed the accent rule. The inset is the only thing that # puts space under the last line. TITLE_H grows by exactly the inset so that # the title itself does not move. TITLE_INSET_B = Pt(6) TITLE_H = TITLE_BAND + TITLE_INSET_B # hero()'s title is the same shape of box - bottom-anchored, same inset - and # it had a DIFFERENT fault, which is worth stating because the two look alike # and the fixes are opposite. # # _slide_title's box was tall enough for the two lines it budgets; what it # lacked was clearance UNDER the last one, so the cure was an inset and a # taller box would have changed nothing. hero()'s box was simply too short for # the three lines it budgets: 40pt type at line spacing 1.0 renders 48pt line # boxes (single is ~1.2em - see cover()'s comment), so three lines are 144pt, # and the box was 1.86in = 133.9pt with 6pt of that already reserved as the # inset. 127.9pt of usable height against 144pt of type: the box held 2.7 of # the 3 lines it promised, and a rendered Russian title 54 characters long - # well inside the 60-char cap - escaped 14.8pt out of the TOP of it, because a # bottom-anchored frame overflows upward, straight into the eyebrow. Latin # prose of the same length usually wraps to two lines and never saw it. # # So here the height IS the fix. The bottom edge is what is pinned (to the lead # line, or to the footer), the box grows upward into empty picture, and the # type does not move: a one- or two-line hero title renders exactly where it # did. Three line boxes at 48pt, plus slack, plus the inset. # # The slack is 0.12em, which is TITLE_BAND's own 3.6pt read as a fraction of # its 30pt type rather than as a number - a capital Й or a Cyrillic ascender # paints a little above the line box it sits in, and that overshoot scales with # the type. Measured on the render at 40pt it is 1.4pt in Chromium and 0.1pt in # LibreOffice, so 4.8pt of slack clears the worse of the two by 3.4pt and the # QA tool's 2pt tolerance never has to absorb any of it. That tolerance # absorbing a real fault is exactly how the descender bug above stayed # invisible for as long as it did. LINE_BOX_EM = 1.2 # a pptx "single" line, measured off the render GLYPH_OVERSHOOT_EM = 0.12 HERO_TITLE_PT = 40 HERO_TITLE_LINES = 3 HERO_TITLE_BAND = Pt(HERO_TITLE_PT * (HERO_TITLE_LINES * LINE_BOX_EM + GLYPH_OVERSHOOT_EM)) HERO_TITLE_H = HERO_TITLE_BAND + TITLE_INSET_B # 2.07in of type + 6pt # All three moved down by 0.39in when the header band was reserved. The # distances BETWEEN them are unchanged, so the title still sits the same # distance above its rule and the rule the same distance above the body. RULE_Y = Inches(2.17) BODY_TOP = Inches(2.44) # The floor for anything in the body band is now the top of the footer strip, # not the bottom margin: the footer is drawn there on every slide, and a table # that grew into it would collide with the slide number. BODY_BOTTOM = FOOTER_Y # Source notes used to sit at the very bottom of the slide, which is where the # footer now is. They move up into their own strip immediately above it. SOURCE_H = Inches(0.25) SOURCE_Y = FOOTER_Y - Inches(0.06) - SOURCE_H # 6.24 -> 6.49 # --- vertical rhythm ------------------------------------------------------ # # The body band is everything between the accent rule and the bottom margin. # A block that ignores it - a fixed-height KPI tile, a fixed-height process # card, a three-row table - ends around the middle of a 16:9 slide and leaves # the bottom 45-55% empty. That reads as unfinished rather than as deliberate # whitespace, which is the same failure a crowded slide has from the other # side: the whitespace wasn't decided, it was left over. # # The blocks still START at BODY_TOP. Floating the process row 1.25in below # the accent rule once made it read as unrelated to its own title, so the fix # is downward growth, never a downward shift, and never vertical centring of # the band - both reopen that gap. # # The primitives are two kinds of thing and take the band two ways: # # Layout-sized blocks - a KPI tile, a process card - have a height their # content does not set, so the layout sets it: CARD_H, with the card's own # content centred inside. The card grows and the type does not. # # Content-sized blocks - bullets, compare_two's points, table rows - are a # row count times a pitch. They keep their type sizes and spread the PITCH # to fill the band, capped: a two-bullet slide gets more air between the # bullets, not two enormous bullets. The cap is what keeps short content # composed rather than stretched. BODY_H = BODY_BOTTOM - BODY_TOP # How far down the band a block full of content reaches. Not a taste number: # it is set so that a full block stops just above the source-note strip, which # is what BAND_FILL always meant - the old 0.82 left 0.85in under the block for # exactly that note. The band is 4.11in now (the header band took 0.39in off # the top and the footer 0.25in off the bottom), and 0.90 of it is 3.70in, # which ends 0.10in clear of SOURCE_Y. The chart, the one body primitive that # was never top-heavy, is therefore 3.70in tall rather than 3.90in. BAND_FILL = 0.90 BAND_TARGET = int(BODY_H * BAND_FILL) # A card whose height the layout sets rather than the content - a KPI tile, # a process step - is deliberately shorter than BAND_TARGET. At the full band # its three short lines float in the middle of a mostly empty surface, which # does not fix the hole in the slide, it just moves the hole inside the card. # 0.80 of the band is 3.29in, read off the render as the tallest a card gets # while still looking full. Both card primitives share it, so a KPI row and a # process row on consecutive slides sit on the same line. The share went up # from 0.70 when the band shrank, deliberately: the card height that reads as # full is an absolute measurement off a render (3.3in), not a proportion, and # expressing it as a proportion of a band that has since changed would have # quietly shortened every card by a quarter of an inch. CARD_FILL = 0.80 CARD_H = int(BODY_H * CARD_FILL) def _pitch(rows, base, cap, extra=0.0): """Row pitch in inches for a `rows`-row block spread over the band. `base` is the tight pitch the type needs and is the floor, so a block already at or over the band is left exactly as it was. `cap` is the loosest pitch that still reads as spacing rather than as a hole, and is what stops two rows being spread across a 16:9 slide. `extra` is the fixed height the block carries besides its rows - a card's padding. """ if rows <= 0: return base return min(max((_inches(BAND_TARGET) - extra) / rows, base), cap) # How much of the leftover band sits ABOVE a block that does not fill it. # # A block shorter than the band used to be pinned to BODY_TOP, which put the # entire remainder underneath it in one lump: a two-step process left 40% of # the slide dead and a two-bullet slide 55%. That reads as an unfinished # slide, which is the same failure a crowded one has from the other side - # the whitespace was not decided, it was left over. # # Centring is the other extreme and it is also wrong: at 0.5 the block floats # free of its own title, which is the exact mistake the process() comment # records ("floating the process row 1.25in below the accent rule once made it # read as unrelated to its own title"). So the block takes a MINORITY share of # the slack above it - still tethered to the title, no longer sitting on a # hole. 0.38 read best on the renders; see /tmp/claude-1000/fix/png/sparse-*. OPTICAL_SHARE = 0.38 def _optical_top(block_h): """Top edge (EMU) of a block `block_h` EMU tall inside the body band.""" slack = max(0, int(BAND_TARGET) - int(block_h)) return int(BODY_TOP + slack * OPTICAL_SHARE) def col(n): """Left edge of grid column n (0-based).""" return int(M_L + n * (COL_W + GUTTER)) def span(n): """Width of n grid columns including the gutters between them.""" return int(n * COL_W + (n - 1) * GUTTER) def _inches(emu): return emu / 914400.0 # --- text measurement ----------------------------------------------------- # # Nothing in a workspace can measure text at build time - no fonts, no # renderer - so the fit check is a weighted advance sum: each character costs # an estimated fraction of an em, and the sum has to fit the box. # # The estimate used to be one number (0.518em) calibrated on English # sentence-case prose. That is wrong for every other script, and the Russian # help page shipped it to Russian readers: Cyrillic averages ~0.58em per # letter with longer words (so fewer of the cheap 0.26em spaces), which eats # the whole safety margin and then some. Uppercase runs ~0.68em. CJK and # emoji are a full em - twice what the old number charged them. _EM_SPACE = 0.26 # word space and the narrow space forms _EM_LATIN = 0.52 # Latin lowercase, digits, punctuation _EM_CYRILLIC = 0.58 # Cyrillic and Greek lowercase _EM_UPPER = 0.68 # uppercase, any script _EM_WIDE = 1.00 # CJK, kana, Hangul, fullwidth forms, emoji # JetBrains Mono is monospace: every glyph (regular or bold, digit or letter) # has an identical advance of 600/1000 em - confirmed against the installed # font with fontTools, not estimated. Monospace runs are billed at this exact # figure and get no headroom term, because there is no wider glyph to miss. MONO_EM = 0.60 # Proportional estimates get ~9% headroom on top, the same margin the old # single number carried: bold weights, wide glyph pairs and ragged word # breaks all cost a little more than the per-script average. HEADROOM = 1.09 # Space, no-break space, thin space, narrow no-break space, ideographic # space. Wider space forms (en, em) are deliberately absent: they fall # through to the proportional estimate, which is closer to their real width. _SPACES = "\u0020\u00a0\u2009\u202f\u3000" def _char_em(ch): """Estimated advance of one character, in em.""" if ch in _SPACES: return _EM_SPACE o = ord(ch) if (0x1100 <= o <= 0x115F or 0x2E80 <= o <= 0xA4CF or 0xA960 <= o <= 0xA97F or 0xAC00 <= o <= 0xD7A3 or 0xF900 <= o <= 0xFAFF or 0xFE30 <= o <= 0xFE6F or 0xFF00 <= o <= 0xFF60 or 0xFFE0 <= o <= 0xFFE6 or o >= 0x1F000): return _EM_WIDE # Uppercase first, so uppercase Cyrillic is billed as uppercase. if ch.isupper(): return _EM_UPPER if (0x0370 <= o <= 0x03FF or 0x0400 <= o <= 0x052F or 0x2DE0 <= o <= 0x2DFF or 0xA640 <= o <= 0xA69F): return _EM_CYRILLIC return _EM_LATIN def _advance(text, size_pt, mono=False, tracking=0.0): """Estimated rendered width of `text`, in inches, at `size_pt`.""" if mono: em = MONO_EM * len(text) else: em = sum(_char_em(c) for c in text) * HEADROOM # Letter-spacing is an exact, known quantity - it is added, not estimated. em += tracking * len(text) return em * size_pt / 72.0 # A hyphen is a break opportunity, exactly like a space. Every renderer that # lays one of these decks out - LibreOffice, PowerPoint, Keynote - starts a # new line after one, and the budget has to agree or it refuses text the # renderer handles cleanly. # # It used to disagree. The wrap simulation split on spaces alone, so a # Russian compound - моушн-дизайнер, тех-разведка, пресс-релиз - was billed # as one indivisible token as wide as both halves. In a narrow card that is # an over-count of one to two lines per compound, and Russian business copy # is full of them: the reference deck's four process steps measured 6, # 7, 7 and 9 lines against boxes that rendered them in 5, 6, 6 and 7. Three # of the four were refused at build time and all four render mechanically # clean. # # The old error message already told the writer to "break it with a space or # a hyphen". This is what makes the second half of that sentence true. # The solidus is in this set for the same reason the hyphens are: every # renderer that draws this deck will break a line after one. Leaving it out # made «4K/HDR» - a KPI VALUE, the single most likely place in a business # deck for a slash - an "unbreakable run" 2.28in wide in a 2.16in tile, and it # renders at 2.05in on one line with room to spare. That was the fourth false # refusal in the probe KP and the only one left after the three the # English-calibration fix was aimed at. _BREAK_AFTER = "-‐‑‒–—/" def _segments(token): """`token` split at its internal break opportunities, in order. The break character stays with the chunk it ends, because that is where the renderer leaves it: "моушн-" then "дизайнер". A token with no hyphen in it comes back unchanged, as a single segment, so this costs nothing for Latin prose. """ out = [] start = 0 for i, ch in enumerate(token): # A trailing hyphen is not a break opportunity - there is nothing # after it to move down - so the last character never splits. if ch in _BREAK_AFTER and i + 1 < len(token): out.append(token[start:i + 1]) start = i + 1 out.append(token[start:]) return [s for s in out if s] def _line_count(text, width_in, size_pt, mono=False, tracking=0.0): """How many lines `text` wraps to in a `width_in` box, greedily. Comparing one total advance against width * lines is not enough for a multi-line box: wrapping is ragged, and the leftover at the end of each line is real space the text cannot use. A 140-character Russian statement passed that check and rendered five lines deep through the bottom of the slide. This packs segment by segment, the way a renderer does - where a segment is a word, or the part of a hyphenated word between two break opportunities. See `_segments`. """ space = _advance(" ", size_pt, mono=mono, tracking=tracking) lines = 1 used = 0.0 first = True for token in text.split(" "): for i, seg in enumerate(_segments(token)): width = _advance(seg, size_pt, mono=mono, tracking=tracking) # Only the first segment of a token is preceded by a space; the # rest follow a hyphen, which is already part of the chunk before # them and costs nothing extra. lead = 0.0 if i else space if first: used = width first = False elif used + lead + width <= width_in: used += lead + width else: lines += 1 used = width return lines class DeckError(ValueError): """A slide is over budget or malformed. Split it; don't shrink the font.""" _CONTROL = {"\n": "\\n", "\r": "\\r", "\v": "\\v", "\t": "\\t", "\f": "\\f"} def _fit(text, width_in, size_pt, what, lines=1, mono=False, tracking=0.0, cap=None): """Check that `text` fits `lines` lines of a `width_in` box at `size_pt`. Raises DeckError if it does not. `cap` is an optional editorial character limit applied on top of the geometry, for the display-tier slides where a long line is a writing problem before it is a layout one. """ if not isinstance(text, str): raise DeckError( "%s must be a string, got %s - convert it with str() at the call " "site so an over-long value is a budget error, not a TypeError" % (what, type(text).__name__) ) for ch, name in _CONTROL.items(): if ch in text: raise DeckError( "%s contains %s. Every budgeted string is a single line by " "construction - a line break inside one silently doubles the " "height of a fixed box. Use a separate bullet, row or slide." % (what, name) ) if cap is not None and len(text) > cap: raise DeckError( "%s is %d chars, budget %d. The budget is checked before anything " "is drawn, so this slide does not exist yet. Shorten the text or " "split the slide - do not shrink the font." % (what, len(text), cap) ) # word_wrap cannot break *inside* a token, so a single whitespace-free run # wider than one line overflows sideways however short the whole string # is: a URL, a product code, a long compound word. Checked before the line # count, because a token that cannot fit a line makes the count a fiction. for token in text.split(): # Measured per segment, not per token: a hyphen IS the break the # message tells the writer to add, so "Перерасчёт-отчётности" is two # chunks the renderer can stack, not one run that overflows. Measuring # the whole token here would refuse the very fix it recommends. for seg in _segments(token): if _advance(seg, size_pt, mono=mono, tracking=tracking) > width_in: raise DeckError( "%s contains the unbreakable run %r, which is wider than " "one %.2fin line on its own - wrapping cannot split it, " "so it runs off the side of the box. Shorten it, or break " "it with a space or a hyphen." % (what, seg, width_in) ) needed = _line_count(text, width_in, size_pt, mono=mono, tracking=tracking) if needed > lines: raise DeckError( "%s wraps to %d lines at %gpt in a %.2fin box that budgets %d - " "room for roughly %d characters of text like this, not %d. " "The measurement runs before anything is drawn, which is why it " "is strict. Shorten the text or split the slide - do not " "shrink the font." % (what, needed, size_pt, width_in, lines, int(len(text) * lines / needed), len(text)) ) return text def _need_notes(text): if not text: raise DeckError( "every slide needs speaker notes - they are what makes a deck " "presentable rather than just readable" ) return text def _need_list(value, what, low, high): """A sized sequence, checked before anything is drawn.""" if not isinstance(value, (list, tuple)): raise DeckError( "%s must be a list, got %s" % (what, type(value).__name__) ) if not low <= len(value) <= high: raise DeckError( "%s takes %d-%d entries, got %d" % (what, low, high, len(value)) ) return value def _need_keys(value, keys, what): if not isinstance(value, dict): raise DeckError( "%s must be a dict with keys %s, got %s" % (what, ", ".join(keys), type(value).__name__) ) missing = [k for k in keys if k not in value] if missing: raise DeckError( "%s is missing %s" % (what, ", ".join(missing)) ) return value def _coerce_keys(value, keys, what): """`_need_keys`, but a positional pair is accepted as well as a dict. The primitives disagreed about this and the disagreement was invisible until it raised: `chart` takes its series as `(name, values)` pairs while `kpi_row` and `compare_two` demanded dicts, so a perfectly reasonable `[("4.2M", "Revenue"), ...]` was a DeckError. Every one of those costs the agent a whole round trip - write the script, read the traceback, rewrite the script - which is the single most expensive thing that can go wrong here. The check that actually matters is that the entry has the right number of fields, and that survives: a 3-tuple where a pair belongs still raises. """ if isinstance(value, dict): return _need_keys(value, keys, what) if isinstance(value, (list, tuple)): if len(value) != len(keys): raise DeckError( "%s takes %d values (%s), got %d: %r" % (what, len(keys), ", ".join(keys), len(value), (value,)) ) return dict(zip(keys, value)) raise DeckError( "%s must be a dict with keys %s, or a %d-item sequence in that order - " "got %s" % (what, ", ".join(keys), len(keys), type(value).__name__) ) # --- the deck: theme and brand -------------------------------------------- # # Both are per-deck, not module globals. A module global would mean two decks # built in one script share whichever look was set last, and the second one # would come out wrong with nothing to show for it - the kind of bug that only # appears when somebody builds a Russian and an English deck in one run. # # They are stored on the Presentation in a private dict rather than as # attributes, because python-pptx hands back a fresh proxy object for a lot of # what it wraps and an attribute set on a proxy does not survive. _DECKS = {} # --- a customer's own colours --------------------------------------------- # # There was no supported way to set one. `set_brand()` does chrome - the # metadata row, the logo, the footer - and nothing else; `Theme` was a public # class that `new_deck` would accept, but the help page never said so, and its # "Brand" section said in terms that the block was "guidance the assistant # reads and applies by hand. Nothing parses it." # # So an agent following our own documentation had no way to use a customer's # accent, and we shipped a production studio's client decks in Plank blue. # # What follows is that way, and the reason it is a function rather than four # more arguments on `Theme` is the contrast: a palette is a set of PAIRS, and # every pair has a floor that comes from the same WCAG rules plank_deck_qa.py # measures the render against. Deriving the rest of the palette from three # colours and CHECKING it here is what stops an unreadable deck being built # and then discovered by the QA tool forty slides later. CONTRAST_TEXT = 4.5 # body and small labels. plank_deck_qa.CONTRAST_WARN CONTRAST_GRAPHIC = 3.0 # rules, bars and other large marks def _need_rgb(value, what): """Accept '#RRGGBB', 'RRGGBB' or an RGBColor, and return an RGBColor.""" if isinstance(value, RGBColor): return value if not isinstance(value, str): raise DeckError( "%s must be a colour like '#DAFF00', got %r" % (what, value) ) text = value.strip().lstrip("#") if len(text) != 6 or any(c not in "0123456789abcdefABCDEF" for c in text): raise DeckError( "%s is %r - a colour is six hex digits, like '#DAFF00' or " "'#000000'" % (what, value) ) return RGBColor(*[int(text[i:i + 2], 16) for i in (0, 2, 4)]) def _linear_channel(value): value /= 255.0 return (value / 12.92 if value <= 0.04045 else ((value + 0.055) / 1.055) ** 2.4) def _luminance(color): text = str(color) r, g, b = [_linear_channel(int(text[i:i + 2], 16)) for i in (0, 2, 4)] return 0.2126 * r + 0.7152 * g + 0.0722 * b def _contrast(a, b): """WCAG 2.1 contrast ratio between two colours, 1.0 to 21.0. The same formula plank_deck_qa.py measures the RENDER with. It is written out twice on purpose: this one has to run in a workspace with no PIL and no LibreOffice, before a single slide exists. """ hi, lo = sorted((_luminance(a), _luminance(b)), reverse=True) return (hi + 0.05) / (lo + 0.05) def _mix(a, b, amount): """`a` moved `amount` of the way towards `b`.""" ta, tb = str(a), str(b) return RGBColor(*[ int(round(int(ta[i:i + 2], 16) * (1 - amount) + int(tb[i:i + 2], 16) * amount)) for i in (0, 2, 4) ]) def brand_theme(base=DEFAULT_THEME, accent=None, ink=None, ground=None, accent_text=None, name=None): """A theme in a customer's colours, derived from `base` and checked. prs = new_deck(theme=brand_theme(accent="#DAFF00", ground="#000000", ink="#FFFFFF")) `ground` is the page, `ink` is the type on it and `accent` is the one colour that is theirs. Everything else - the card surface, the hairline, the softer ink - is derived from those three, so the palette stays a palette rather than three colours dropped into someone else's. Every derived pair is measured before the theme exists. A DeckError here names the pair and the ratio, which is a fixable brief; the alternative is a deck that is built, handed over and only then found to be unreadable. `accent_text` is the escape hatch for the one pair that most often fails: a brand accent chosen for a logo is frequently too dark to set 9pt type in. Pass a lighter or darker step of the same hue and the graphic accent is left alone - which is exactly what both built-in themes already do. """ theme = _need_theme(base) ground = theme.bg if ground is None else _need_rgb(ground, "ground") ink = theme.ink if ink is None else _need_rgb(ink, "ink") accent = theme.accent if accent is None else _need_rgb(accent, "accent") accent_text = (accent if accent_text is None else _need_rgb(accent_text, "accent_text")) # Derived, not asked for. A customer names three colours; a deck needs a # card that reads as raised off the page, a hairline that reads as a rule # rather than as a border, and a second ink for the text that is not the # point of the sentence. The three amounts are the ones the built-in # themes already sit at, measured off them rather than invented. surface = _mix(ground, ink, 0.09) hairline = _mix(ground, ink, 0.20) ink_soft = _mix(ink, ground, 0.35) checks = ( ("ink", ink, ground, "the ground", CONTRAST_TEXT), ("ink", ink, surface, "the card surface", CONTRAST_TEXT), ("ink_soft (derived from ink)", ink_soft, ground, "the ground", CONTRAST_TEXT), ("ink_soft (derived from ink)", ink_soft, surface, "the card surface", CONTRAST_TEXT), ("accent", accent, ground, "the ground", CONTRAST_GRAPHIC), ("accent_text", accent_text, ground, "the ground", CONTRAST_TEXT), ("accent_text", accent_text, surface, "the card surface", CONTRAST_TEXT), ) for label, fg, bg, where, floor in checks: ratio = _contrast(fg, bg) if ratio < floor: raise DeckError( "%s #%s is %.2f:1 on %s (#%s) and the floor is %.1f:1. %s" % (label, str(fg), ratio, where, str(bg), floor, "Pass `accent_text=` with a lighter or darker step of the " "same hue - the graphic accent is left alone, which is " "what both built-in themes do." if label == "accent_text" else "Pick a ground and an ink that can carry each other; a " "deck nobody can read is not a brand.") ) return Theme( name=name or ("%s-branded" % theme.name), bg=ground, surface=surface, hairline=hairline, ink=ink, ink_soft=ink_soft, accent=accent, good=theme.good, bad=theme.bad, warn=theme.warn, # The chart series stay the base theme's. They are a set of colours # tuned to be distinguishable from EACH OTHER on a surface of that # lightness, which is a different problem from a brand accent and not # one three inputs can solve - see SERIES_STUDIO. Pass `series=` on # `Theme` directly if a customer has a real chart palette. series=theme.series, display_italic=theme.display_italic, font=theme.font, mono=theme.mono, scrim=theme.scrim, over_image=theme.over_image, over_image_soft=theme.over_image_soft, accent_over_image=theme.accent_over_image, accent_text=accent_text, ) def new_deck(theme=DEFAULT_THEME, accent=None, ink=None, ground=None, accent_text=None): """A 16:9 deck in `theme` - "studio" (the default) or "report". Pass `accent`, `ink` and `ground` to build it in a customer's colours: prs = new_deck(accent="#DAFF00", ground="#000000", ink="#FFFFFF") That is `brand_theme()` applied to `theme`, contrast checks and all, and it is the supported way to set a brand colour. `theme` may also be a `Theme` you built yourself, in which case leave these alone. """ if any(v is not None for v in (accent, ink, ground, accent_text)): theme = brand_theme(base=theme, accent=accent, ink=ink, ground=ground, accent_text=accent_text) prs = Presentation() prs.slide_width, prs.slide_height = W, H _DECKS[id(prs)] = {"theme": _need_theme(theme), "brand": None} return prs def _need_theme(theme): if isinstance(theme, Theme): return theme if theme not in THEMES: raise DeckError( "unknown theme %r - use one of %s. 'studio' is the art-directed " "dark default; 'report' is the light typographic style, which is " "the better one for a board pack or anything that gets printed." % (theme, ", ".join(sorted(THEMES))) ) return THEMES[theme] def _theme(prs): entry = _DECKS.get(id(prs)) if entry is None: raise DeckError( "this deck was not made by new_deck(), so it carries no theme. " "Build it with `prs = new_deck()` (or `new_deck(theme=\"report\")`)" ) return entry["theme"] def _brand(prs): entry = _DECKS.get(id(prs)) return entry["brand"] if entry else None class Brand(object): """The persistent chrome: metadata row, logo lockup, footer.""" def __init__(self, meta=(), logo=None, confidential=None, city=None, year=None, slide_numbers=True): self.meta = tuple(meta) self.logo = logo self.confidential = confidential self.city = city self.year = year self.slide_numbers = slide_numbers @property def header(self): """The metadata row, as one line: 'brand ACME / agency Plank'.""" return " / ".join("%s %s" % (k, v) for k, v in self.meta) @property def footer(self): """The footer line: 'Confidential · Almaty · 2026'.""" parts = [p for p in (self.confidential, self.city, self.year) if p] return " · ".join(str(p) for p in parts) def set_brand(prs, meta=(), logo=None, confidential=None, city=None, year=None, slide_numbers=True): """Give a deck its persistent chrome. Wire this to the `## Brand` block. `meta` is a list of (label, value) pairs rendered across the top of every slide - (("brand", "ACME"), ("agency", "Plank")). `logo` is a path to an image; a path that does not resolve is refused HERE, at build time, rather than shipped as an empty frame on twenty slides. Everything is measured before anything is drawn, same as every other budgeted string. """ entry = _DECKS.get(id(prs)) if entry is None: raise DeckError("set_brand() takes a deck made by new_deck()") if not isinstance(meta, (list, tuple)): raise DeckError("brand meta must be a list of (label, value) pairs") pairs = [] for item in meta: if not isinstance(item, (list, tuple)) or len(item) != 2: raise DeckError( "each brand meta entry must be a (label, value) pair, got %r" % (item,) ) pairs.append((str(item[0]), str(item[1]))) brand = Brand(meta=pairs, logo=logo, confidential=confidential, city=city, year=year, slide_numbers=slide_numbers) # The header shares the top band with the logo lockup, so it gets nine # columns and the logo three; the footer shares the bottom band with the # slide number, so it gets eight. # # Nine, not the six it was. The logo is sized by HEIGHT - CHROME_H is # 0.24in - so even a wide lockup is under an inch across, and six columns # left a quarter of the band permanently empty while refusing the row at # about 42 characters. The reference deck this chrome was modelled on runs # "brand ... / project ... / production ..." to 85, across roughly 70% of # its slide. Nine columns is 8.82in, which holds it with room over. if brand.header: _fit(brand.header, _inches(span(9)), CHROME_PT, "brand metadata row", tracking=0.06) if brand.footer: _fit(brand.footer, _inches(span(8)), CHROME_PT, "brand footer", tracking=0.06) if logo is not None: # The help page tells the assistant to confirm a logo exists before # referencing it. This is that rule with teeth: a missing file is a # DeckError at set_brand() time, which is one message, rather than a # broken image frame on every slide, which is a finding per slide. if not isinstance(logo, str) or not os.path.isfile(logo): raise DeckError( "brand logo %r is not a file that exists. Confirm the path " "before referencing it, or leave `logo` out - a deck with no " "logo is correct; a deck with a broken image frame is not." % (logo,) ) entry["brand"] = brand return brand def _blank(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) fill = slide.background.fill fill.solid() fill.fore_color.rgb = _theme(prs).bg return slide def _notes(slide, text): _need_notes(text) slide.notes_slide.notes_text_frame.text = text def _tf(slide, x, y, w, h, anchor=MSO_ANCHOR.TOP, inset_b=0): box = slide.shapes.add_textbox(x, y, w, h) tf = box.text_frame tf.word_wrap = True tf.margin_left = tf.margin_right = 0 # `inset_b` is real space reserved under the last line, which a # bottom-anchored frame needs and a top-anchored one does not: it is the # only lever that moves the type up relative to the box it must stay # inside. Left at 0 everywhere else, where the anchor is TOP and the # overhang lands in the box's own spare height. tf.margin_top = 0 tf.margin_bottom = inset_b tf.vertical_anchor = anchor # add_textbox defaults to , which lets the renderer resize # the shape around the text and quietly defeats bottom/middle anchoring. # Geometry here is explicit, so turn it off. tf.auto_size = MSO_AUTO_SIZE.NONE return tf def _para(tf, text, size, color, bold=False, font=FONT, tracking=0.0, align=PP_ALIGN.LEFT, line=1.35, space_after=0, first=False, level=0, italic=False): para = tf.paragraphs[0] if first else tf.add_paragraph() para.alignment = align para.line_spacing = line para.space_after = Pt(space_after) para.level = level run = para.add_run() run.text = text run.font.size = Pt(size) run.font.bold = bold run.font.italic = italic run.font.name = font run.font.color.rgb = color if tracking: # python-pptx has no letter-spacing API; a:rPr@spc is in 1/100 pt. run.font._rPr.set("spc", str(int(round(tracking * size * 100)))) return para def _rect(slide, x, y, w, h, color, line=None): shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h) shape.fill.solid() shape.fill.fore_color.rgb = color if line is None: shape.line.fill.background() else: shape.line.color.rgb = line shape.line.width = Pt(1) shape.shadow.inherit = False # add_shape also emits with , a theme # reference to a drop shadow. An empty is supposed to # override it, but renderers disagree and LibreOffice draws the shadow # anyway. Every surface in this deck is flat, so drop the whole style # reference rather than fighting it - fill and line are set explicitly. style = shape._element.find(qn("p:style")) if style is not None: shape._element.remove(style) return shape def _check_title(title, width=12): # Two lines' worth at 30pt across the full content width. The original 70 # was struck through by its own accent rule on the render: at 30pt a title # of that length wraps, and the second line landed on the rule and then on # the body. The box is bottom-anchored so one-line titles still sit tight # above the rule. 80 stays as an editorial cap on top of the geometry. # # The line budget is measured against TITLE_BAND, not TITLE_H: TITLE_H now # carries a bottom inset that is deliberately not usable by type. Budgeting # against the whole box would hand the inset straight back to the text and # reopen the overhang it exists to prevent. return _fit(title, _inches(span(width)), 30, "slide title", lines=2, cap=80) def _slide_title(prs, slide, title, width=12, rule=True): theme = _theme(prs) _check_title(title, width) tf = _tf(slide, col(0), TITLE_TOP, span(width), TITLE_H, anchor=MSO_ANCHOR.BOTTOM, inset_b=TITLE_INSET_B) _para(tf, title, 30, theme.ink, bold=True, tracking=-0.03, line=1.05, first=True, italic=theme.display_italic) # `rule=False` is for a slide that puts something else in the rule's slot # - labelled_blocks' eyebrow. Two accent marks on one line is one too # many, and the eyebrow is doing the rule's job with words. if rule: _rect(slide, col(0), RULE_Y, Inches(0.6), Pt(3), theme.accent) def _hairline_bottom(cell, color, width_pt=1): """Give a table cell a bottom rule. python-pptx has no border API.""" tcPr = cell._tc.get_or_add_tcPr() ln = parse_xml( '' '' '' % (nsdecls("a"), int(Pt(width_pt)), str(color)) ) # lnL/lnR/lnT/lnB precede the fill elements in CT_TableCellProperties; # cell.fill has already written a solidFill, so insert at the front. tcPr.insert(0, ln) def _cell(cell, text, color, fill, size=12, bold=False, rule=None, font=FONT): cell.fill.solid() cell.fill.fore_color.rgb = fill cell.margin_left = cell.margin_right = Inches(0.14) cell.margin_top = cell.margin_bottom = Inches(0.05) cell.vertical_anchor = MSO_ANCHOR.MIDDLE cell.text = str(text) run = cell.text_frame.paragraphs[0].runs[0] run.font.size = Pt(size) run.font.bold = bold run.font.name = font run.font.color.rgb = color if rule is not None: _hairline_bottom(cell, rule) SOURCE_PT = 11 # The chrome runs quiet: smaller than a source note and tracked out, so it # reads as furniture rather than as content competing with the slide. CHROME_PT = 9 def _check_source(text, what="source note"): return _fit(text, _inches(span(12)), SOURCE_PT, what) def _source(prs, slide, text): tf = _tf(slide, col(0), SOURCE_Y, span(12), SOURCE_H) _para(tf, text, SOURCE_PT, _theme(prs).ink_soft, first=True) # --- images --------------------------------------------------------------- def _need_image(path, what="image"): """An image path that resolves to a real file, checked before drawing. Deliberately the same rule as the logo: an image is the one thing in a deck that fails silently. A missing path produces an empty frame that looks like a design choice, and plank_deck_qa.py can only tell you about it after the deck exists. """ if not isinstance(path, str) or not path: raise DeckError( "%s must be a path to an image file, got %r" % (what, path) ) if not os.path.isfile(path): raise DeckError( "%s %r does not exist. Confirm the file is there before building " "the slide - a deck must never ship a broken image frame. Paths " "are relative to where the build script runs." % (what, path) ) return path def _picture_cover(slide, path, x, y, w, h): """Place an image filling (x, y, w, h) exactly, cropping to fit. Scaling to the box distorts the picture; scaling to cover and letting the overflow hang off the slide leaves a shape whose rect disagrees with what is visible, which is what every geometry check in plank_deck_qa.py reads. So the shape IS the box, and the *source* is cropped to the box's aspect ratio - the crop is centred, so a portrait photo in a landscape slot loses equal amounts of sky and floor. """ picture = slide.shapes.add_picture(path, x, y, w, h) try: px_w, px_h = picture.image.size except Exception: # noqa: BLE001 - undecodable, leave as-is return picture if not px_w or not px_h or not w or not h: return picture want = float(w) / float(h) have = float(px_w) / float(px_h) if have > want: keep = want / have picture.crop_left = picture.crop_right = (1.0 - keep) / 2.0 elif have < want: keep = have / want picture.crop_top = picture.crop_bottom = (1.0 - keep) / 2.0 return picture # A scrim is not decoration and it is not optional. Type on a photograph is # legible only against something that is not the photograph, and "pick a dark # image" is not a guarantee - the image is chosen by whoever writes the deck # and the failure is silent. So every image primitive lays a gradient down # between the picture and the text, and plank_deck_qa.py measures the result # against the pixels that actually ended up behind each word. # # The stops: dark at the top so the metadata row and the logo hold, nearly # clear across the middle so the picture is still a picture, and heaviest # across the bottom third where the title, the lead and the footer sit. # # Four stops, not three, and the fourth is load-bearing. With a single ramp # from the clear middle to the dark bottom edge, the TOP of a three-line title # lands where the wash is only about 45% and a near-white photograph came back # at 4.9:1 - legible, but with no margin at all, and the contrast check is # there precisely because the next photograph is a coin toss. # # The third stop was 74% at 66% of the slide height, which put the TITLE in # the heavy part. That turned out to be the wrong definition of "the text # block": hero()'s eyebrow sits ABOVE the title, at roughly 47% of the height, # where that ramp had only reached about 40% - and on a light photograph the # pixels behind it came back mid-grey. Measured on one, every accent failed # there and so did plain ivory: 2.5:1 for the acid green this deck used to # accent in, 1.4:1 for Plank blue, 3.1:1 for #FAFAF7. That is a scrim bug, not # a colour bug - no ink is legible on mid-grey - and it is why picking a # lighter accent was not the fix. # # So the heavy part starts above the eyebrow: 86% by 42% of the height. # Re-measured on the same light photograph, the eyebrow clears 4.5:1 for every # accent tried, including the two this file has shipped. The clear window # moves up rather than closing - the picture is still a picture across its top # third, which is where a hero's subject usually sits. # # The top stop went 70% -> 90% in the same pass, and for the same reason one # step higher up the slide: the brand metadata row sits at about 9% and was # already the marginal case before any of this (3.2:1 on that photograph, a # warning). Moving the clear point later without also holding the top darker # turned that warning into an error at 2.7:1 - the fix for one band had made # the band above it worse, which is the failure mode a four-stop ramp exists # to make visible. At 90%/12%/86%/96% the whole hero comes back clean. # # 46% -> 42% when the eyebrow stopped being anchored to the top of the title # BOX and started tracking the title's first LINE (see _hero_eyebrow_rise). The # "roughly 47%" above was measured on a layout where the eyebrow overlapped a # three-line title; with the overlap gone the label sits where it should, which # is higher, and on a light photograph it came back at 4.2:1 through the old # ramp. The clear window keeps its top third and the ramp is 0.6in rather than # 0.9in - which is the trade, and it is not visible on a photograph. # FIVE stops now, and the new one is a HOLD rather than a ramp. The top stop # was 90% at position 0 and the next 12% at 34% of the height, so the wash # under the brand metadata row - which sits at 8-11% - had already fallen to # about 72%. Measured on four photographs, that row came back at 3.2, 4.1, 4.5 # and 3.2:1: a `text-over-image` warning on every one of them, including the # 4.5 that reads as passing and is the floor rather than a margin. # # The top chrome band is where a scrim CANNOT be tuned by a single ramp, # because it is the one text band that is nowhere near the text block the ramp # was shaped around. So the wash is held at 0.90-0.94 down to 13% of the # height, which covers the band and its descenders, and only then falls to the # clear window at the same 34% it always did. Re-measured on the same four: # all four clean. The cost is the picture's top eighth, which is chrome # anyway; the clear middle third is untouched - compare # /tmp/claude-1000/fix/png/scrim_before--3.png with scrim_after--3.png. SCRIM_STOPS = ((0, 0.94), (13000, 0.90), (34000, 0.12), (42000, 0.86), (100000, 0.96)) def _scrim(slide, x, y, w, h, color, stops=SCRIM_STOPS): """A vertical gradient wash, top to bottom, in `color` at varying alpha.""" shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h) shape.line.fill.background() shape.shadow.inherit = False style = shape._element.find(qn("p:style")) if style is not None: shape._element.remove(style) spPr = shape._element.spPr for tag in ("a:solidFill", "a:noFill", "a:gradFill"): existing = spPr.find(qn(tag)) if existing is not None: spPr.remove(existing) gs = "".join( '' '' % (pos, str(color), int(round(alpha * 100000))) for pos, alpha in stops ) # ang is in 60000ths of a degree; 5400000 is straight down. grad = parse_xml( '%s' '' % (nsdecls("a"), gs) ) # gradFill belongs after prstGeom and before ln in CT_ShapeProperties. ln = spPr.find(qn("a:ln")) if ln is not None: ln.addprevious(grad) else: spPr.append(grad) return shape # --- brand chrome --------------------------------------------------------- def _chrome(prs, slide, over_image=False): """The persistent header row, logo lockup and footer. Drawn on every slide, in that order, after the slide's own content - so the slide number is the number of slides that exist, which is what a reader counts. A deck with no brand still gets the slide number, because the bands are reserved either way and an empty band with a number in it is the least surprising thing to put there. """ theme = _theme(prs) brand = _brand(prs) ink = theme.over_image_soft if over_image else theme.ink_soft accent = theme.accent_over_image if over_image else theme.accent_text if brand is not None and brand.header: tf = _tf(slide, col(0), HEADER_Y, span(9), CHROME_H) _para(tf, brand.header, CHROME_PT, ink, tracking=0.06, first=True) if brand is not None and brand.logo: # Right-aligned in the header band, sized by height so any aspect # ratio lands on the same baseline as the metadata row. picture = slide.shapes.add_picture(brand.logo, 0, HEADER_Y, height=CHROME_H) picture.left = int(W - M_R - picture.width) footer = brand.footer if brand is not None else "" if footer: tf = _tf(slide, col(0), FOOTER_Y, span(8), FOOTER_H) _para(tf, footer, CHROME_PT, ink, tracking=0.06, first=True) if brand is None or brand.slide_numbers: number = len(prs.slides._sldIdLst) tf = _tf(slide, col(10), FOOTER_Y, span(2), FOOTER_H) _para(tf, "%02d" % number, CHROME_PT, accent, bold=True, tracking=0.06, align=PP_ALIGN.RIGHT, first=True) # --- the cover block ------------------------------------------------------ # # The cover's title and subtitle share ONE frame, so they share ONE budget. # Everything here exists because they did not: each field was checked against # a line count of its own and the frame was a flat 2.6in that nothing compared # the sum against. COVER_TOP = Inches(2.55) COVER_TITLE_PT = 44 COVER_SUB_PT = 18 COVER_TITLE_GAP_PT = 10 # space_after under the title, in points # The subtitle is a subtitle. Two lines is a line of geometry and a line of # editing at once, and unlike the title's it was never the false one - the # measured failures were all the SUM. COVER_SUB_LINES = 2 # Under the last line, for the same reason TITLE_INSET_B and process()'s # reserve exist: a rendered glyph sits below its own line box. COVER_DESCENDER_IN = 10 / 72.0 # How much block the cover may carry. This is a MEASURED allowance, not the # geometric distance from COVER_TOP to the presenter strip - and the # difference is the whole point, so do not "simplify" it back into # `_inches(SOURCE_Y - COVER_TOP)`. # # _cover_block_h is fed by _line_count, which runs conservative by design and # runs MOST conservative at display sizes: an 82-character title that renders # as four lines at 44pt is counted as six. The geometric distance is 3.49in, # and enforcing it refused a 4-line title with a one-line subtitle that # renders with the subtitle sitting cleanly above the presenter strip. So the # allowance is in the units the estimate produces, and the render says where # it runs out: /tmp/claude-1000/fix/cover_cliff2.pptx sweeps five title # lengths x three subtitle lengths with every budget off, and # plank_deck_qa.py reads the result. A block the model scores at 4.98in is the # largest that comes back clean; 5.28in is the first `text-collision` with the # presenter strip. See /tmp/claude-1000/fix/png/cov2--08.png and cov2--09.png. # # Re-measure this the same way if LINE_BOX_EM, _line_count, _advance or the # footer geometry changes. An arithmetic adjustment here is how the original # bug shipped. COVER_BLOCK_ALLOWANCE = 4.98 def _cover_room(): """Inches of block the cover may carry, in _cover_block_h's own units.""" return COVER_BLOCK_ALLOWANCE def _cover_block_h(title_lines, sub_lines): """Height in inches of a cover block of that many lines of each field.""" h = title_lines * COVER_TITLE_PT * LINE_BOX_EM / 72.0 if sub_lines: h += COVER_TITLE_GAP_PT / 72.0 h += sub_lines * COVER_SUB_PT * LINE_BOX_EM / 72.0 return h + COVER_DESCENDER_IN def _cover_max_title_lines(): """The most lines a title alone may take. A subtitle costs some of them.""" n = 1 while _cover_block_h(n + 1, 0) <= _cover_room(): n += 1 return n # --- primitives ----------------------------------------------------------- # # Every primitive validates *before* it calls _blank(). python-pptx has no # supported remove-slide API, so a primitive that appended the slide first and # raised afterwards would leave a half-built orphan behind on every caught # DeckError - and the documented response to a DeckError is to shorten and # retry, which would accumulate them invisibly. def cover(prs, title, subtitle=None, presenter=None, date=None, notes=""): _need_notes(notes) # Both fields are measured, and then the BLOCK is measured. They used to # be budgeted independently - title <= 3 lines cap 60, subtitle <= 2 lines # cap 90 - into one 2.6in frame that nothing measured the sum against, so # the cover was wrong in both directions at once. A 62-character title # with no subtitle was refused over two characters and renders perfectly; # the same title with a two-line subtitle was accepted and rendered 40.1pt # past the bottom of its frame. Measured across nine covers in # /tmp/claude-1000/probe/cover_sweep.pptx. # # The consequence of the first half is the one that reached the customer: # the refusal was caught, the cover was skipped, and the deck SHIPPED WITH # NO COVER SLIDE, opening on a hero. So the character caps are loose # editorial ones now - a long display line is still a writing problem - # and the real constraint is the block against the space, which is # measured once, for both fields together. _fit(title, _inches(span(9)), COVER_TITLE_PT, "cover title", lines=_cover_max_title_lines(), cap=90) if subtitle: _fit(str(subtitle), _inches(span(9)), COVER_SUB_PT, "cover subtitle", lines=COVER_SUB_LINES, cap=140) footer = " · ".join([p for p in (presenter, date) if p]) if footer: _fit(footer, _inches(span(12)), 11, "cover footer", tracking=0.08) title_lines = _line_count(title, _inches(span(9)), COVER_TITLE_PT) sub_lines = (_line_count(str(subtitle), _inches(span(9)), COVER_SUB_PT) if subtitle else 0) block = _cover_block_h(title_lines, sub_lines) if subtitle and block > _cover_room(): # DEGRADE, do not refuse. A cover is the one slide whose absence is # worse than any compromise in it: the deck that started this has no # cover at all because a DeckError here was caught and the slide # skipped. The title is the cover; the subtitle is the thing that can # go, and it goes loudly rather than silently. warnings.warn( "cover subtitle dropped: the title needs %d lines at %gpt and the " "block would be %.2fin in the %.2fin above the footer strip. The " "cover is drawn without it - shorten the title or the subtitle " "and pass it again if you want it back." % (title_lines, COVER_TITLE_PT, block, _cover_room()), stacklevel=2, ) subtitle = None sub_lines = 0 block = _cover_block_h(title_lines, 0) theme = _theme(prs) slide = _blank(prs) _rect(slide, col(0), Inches(2.15), Inches(0.9), Pt(4), theme.accent) # The frame is the block's height, not a flat 2.6in. A frame that promises # more room than the text uses is harmless; one that promises less is the # 40.1pt overflow above, and a flat number does both depending on the copy. # Clamped to the real space on the slide. The box is top-anchored, so the # clamp never moves a line of type - it just stops the frame itself # hanging off the bottom edge when the estimate has run long. tf = _tf(slide, col(0), COVER_TOP, span(9), min(Inches(block), SOURCE_Y - COVER_TOP)) # line spacing here is a *percentage of single*, so 1.0 renders as about # 1.2em. 1.15 rendered as 1.38em and read as loose at display sizes. _para(tf, title, COVER_TITLE_PT, theme.ink, bold=True, tracking=-0.03, line=1.0, space_after=COVER_TITLE_GAP_PT, first=True, italic=theme.display_italic) if subtitle: _para(tf, str(subtitle), COVER_SUB_PT, theme.ink_soft, space_after=0) if footer: # Above the chrome footer strip, not on top of it. ftf = _tf(slide, col(0), SOURCE_Y, span(12), SOURCE_H) _para(ftf, footer, 11, theme.ink_soft, tracking=0.08, first=True) _notes(slide, notes) _chrome(prs, slide) return slide def section_divider(prs, number, label, notes=""): _need_notes(notes) try: number = int(number) except (TypeError, ValueError): raise DeckError( "section_divider number must be a whole number, got %r" % (number,) ) # One line at 40pt across ten columns. A divider is a beat, not a sentence. _fit(label, _inches(span(10)), 40, "section label") theme = _theme(prs) slide = _blank(prs) _rect(slide, 0, 0, W, H, theme.surface) tf = _tf(slide, col(0), Inches(2.95), span(10), Inches(1.8)) _para(tf, "%02d" % number, 11, theme.accent_text, bold=True, tracking=0.08, space_after=6, first=True) # 40pt, not 20pt. A divider is a beat in the talk; at 20pt on a full-bleed # surface it read as a stray caption, smaller than the slide titles it was # introducing. This matches the closing tier. _para(tf, label, 40, theme.ink, bold=True, tracking=-0.03, line=1.0, space_after=0, italic=theme.display_italic) _notes(slide, notes) _chrome(prs, slide) return slide def statement(prs, text, notes=""): _need_notes(notes) _fit(text, _inches(span(10)), 32, "statement", lines=4, cap=140) theme = _theme(prs) slide = _blank(prs) tf = _tf(slide, col(1), Inches(2.4), span(10), Inches(2.6), anchor=MSO_ANCHOR.MIDDLE) _para(tf, text, 32, theme.ink, bold=True, tracking=-0.03, line=1.05, first=True, italic=theme.display_italic) _notes(slide, notes) _chrome(prs, slide) return slide # A bullet and its sub-bullets are ONE UNIT, and the spacing has to say so. # # It said the opposite. The bullet got `space_after=gap` and each sub got # `gap * 0.6`, so the biggest space on the slide landed between a label and # its own body. Measured off the PDF text positions of a rendered sub-bullet # slide, page 3: «Сроки» to its body «Штраф» is 55.7pt, and «Штраф» to the # next label «Переснимем» is 42.5pt - the space INSIDE a unit 31% larger than # the space separating two units. A reader # groups by proximity before reading a word, so the slide said the body # belonged to the label below it. # # It was invisible at full density, where `_pitch` drives the gap to almost # nothing and both numbers collapse, and ruinous exactly where the customer # saw it: on a thin slide, where there is slack to distribute. That is why # spreading rows UNIFORMLY is the wrong model - the spreader must distribute # space BETWEEN units and never between a label and its body. BULLET_PT = 16 BULLET_SUB_PT = 14 BULLET_LINE = 1.45 # Inside a unit: a fixed hairline gap that does not grow with the slack. It is # small on purpose - the sub is already smaller type at a lower level, so the # indent and the size carry the hierarchy and the space only has to stop the # lines touching. BULLET_TIGHT_PT = 4 # Between units: the floor is the gap a full eight-line slide always had # (`_pitch` at its own floor), so a dense slide is spaced exactly as before. # The cap is what stops two bullets being spread over a 16:9 slide. BULLET_GAP_FLOOR_PT = 10 BULLET_GAP_CAP_PT = 30 def _bullet_units(items, width): """[(bullet_lines, [sub_lines, ...]), ...] for validated `items`.""" units = [] for item in items: text, subs = (item, []) if isinstance(item, str) else item units.append(( _line_count("— " + text, width, BULLET_PT), [_line_count(s, width - 0.45, BULLET_SUB_PT) for s in subs], )) return units def _bullet_metrics(units): """(unit gap in points, block height in inches) for a list of units.""" line_b = BULLET_PT * BULLET_LINE / 72.0 line_s = BULLET_SUB_PT * BULLET_LINE / 72.0 type_h = sum(b * line_b + sum(s * line_s for s in subs) for b, subs in units) inner_h = sum(len(subs) for _, subs in units) * BULLET_TIGHT_PT / 72.0 # Divided by the number of UNITS, not by the gaps between them, which is # `_pitch`'s own arithmetic and is why it never overran: a trailing gap is # reserved under the last unit and never drawn. Spreading over the gaps # instead spends that reserve, and six short bullets came out 0.26in past # the band and under the footer - looser than the model the primitive had # before, which is not a fix. At eight lines this is 10.1pt and at six # bullets 21.2pt, both identical to the pitch model they replace. free = _inches(BAND_TARGET) - type_h - inner_h gap = int(round(min(max(free / len(units) * 72.0, BULLET_GAP_FLOOR_PT), BULLET_GAP_CAP_PT))) # The reserve is part of the block for placement, so a full slide has no # slack and starts exactly at BODY_TOP, where it always did. return gap, type_h + inner_h + len(units) * gap / 72.0 def bullets(prs, title, items, notes=""): _need_notes(notes) _check_title(title) _need_list(items, "bullets", 1, 6) width = _inches(span(9)) rows = 0 for item in items: # A bare string where a (text, subs) pair belongs used to fall through # the isinstance check and render one sub-bullet per *character*. if isinstance(item, str): text, subs = item, [] elif isinstance(item, (list, tuple)) and len(item) == 2 \ and isinstance(item[0], str) \ and isinstance(item[1], (list, tuple)) \ and all(isinstance(s, str) for s in item[1]): text, subs = item[0], list(item[1]) else: raise DeckError( "a bullet must be a string, or a (text, [sub, sub]) pair - " "got %r. A bare string where the pair belongs would render " "one sub-bullet per character." % (item,) ) # 2 chars of the em-dash marker come out of the same line, so measure # the marker along with the text. # # A bullet may wrap. It used to be one line each, which is the check # this budget was named for - but the count was of ITEMS, not of # lines, so the two disagreed the moment a bullet was longer than its # box. A sub-bullet in particular is 8.37in wide and was refused at # about 68 Cyrillic characters, while the reference deck writes 77 to # 160. What is real is the eight lines between the accent rule and the # bottom margin, so lines are what is counted. _fit("— " + text, width, 16, "bullet", lines=8) rows += _line_count("— " + text, width, 16) for sub in subs: _fit(sub, width - 0.45, 14, "sub-bullet", lines=8) rows += _line_count(sub, width - 0.45, 14) # Only eight lines fit between the accent rule and the bottom margin. Six # bullets each carrying a sub ran clean off the bottom of the slide on the # render, and so would four bullets that each wrap to two. if rows > 8: raise DeckError( "%d lines of bullet (bullets plus sub-bullets, counting the lines " "each one wraps to), budget 8 - the rest falls off the bottom of " "the slide. Shorten one, or split the slide." % rows ) theme = _theme(prs) slide = _blank(prs) _slide_title(prs, slide, title) units = _bullet_units(items, width) gap, block_h = _bullet_metrics(units) # The block sits optically rather than sitting on a hole - see # _optical_top. At full density block_h reaches BAND_TARGET, the slack is # zero and the top is BODY_TOP, which is where every existing deck has it. top = _optical_top(Inches(block_h)) tf = _tf(slide, col(0), top, span(9), BODY_BOTTOM - top) first = True for (_, sub_lines), item in zip(units, items): text, subs = (item, []) if isinstance(item, str) else item # The gap under a bullet is the TIGHT one when the bullet has a body # of its own to hold, and the unit gap only when it is the whole unit. _para(tf, "— " + text, BULLET_PT, theme.ink, line=BULLET_LINE, space_after=(BULLET_TIGHT_PT if subs else gap), first=first) first = False for i, sub in enumerate(subs): last = i == len(subs) - 1 _para(tf, sub, BULLET_SUB_PT, theme.ink_soft, line=BULLET_LINE, space_after=(gap if last else BULLET_TIGHT_PT), level=1) _notes(slide, notes) _chrome(prs, slide) return slide def compare_two(prs, title, left, right, notes=""): _need_notes(notes) _check_title(title) # Coerced once, up front: both sides are read again further down, so # normalising inside the validation loop would leave the drawing code # holding the caller's original tuple. left = _coerce_keys(left, ("label", "points"), "compare_two left side") right = _coerce_keys(right, ("label", "points"), "compare_two right side") inner = _inches(span(6) - GUTTER) - 0.7 chrome = 0.35 + 0.30 + 0.25 line_h = 15 * 1.45 / 72.0 # 15pt at line 1.45 is 0.302in of type descender_in = 10 / 72.0 # see process(): the last line's overhang # A point may wrap. It could not before: the budget was one line each, # which is about 27 Cyrillic characters in this card, and the reference # deck writes 61 to 82. Three of the four slides that map to this # primitive were refused outright on copy that fits the band easily. # # What is real is the BAND, not the line count, so that is what is # budgeted: the two cards together may use as many lines as the body band # holds, however they are distributed across the points. band_lines = int((_inches(BAND_TARGET) - chrome - descender_in) / line_h) used = {} for name, side in (("left", left), ("right", right)): _need_list(side["points"], "compare_two %s points" % name, 1, 5) _fit(str(side["label"]), inner, 11, "compare label", tracking=0.08) total = 0 for point in side["points"]: _fit(point, inner, 15, "compare point", lines=band_lines) total += _line_count(point, inner, 15) if total > band_lines: raise DeckError( "compare_two %s side needs %d lines of point text, budget %d " "- the card would run past the bottom of the body band. " "Shorten a point or drop one; do not shrink the type." % (name, total, band_lines) ) used[name] = total # Both cards take the height the fuller side needs, so they stay a matched # pair. A flat 4.3in left a two-point card two thirds empty - so the card # is still content-sized, and it is the point PITCH that spreads to fill # the band. Five points now reach the bottom of it; two get a looser but # capped rhythm instead of being stretched across the slide. The paragraph # gap is derived from the pitch, not tuned separately, or the points would # bunch at the top of a card sized for a wider rhythm. points = max(len(left["points"]), len(right["points"])) lines = max(used["left"], used["right"]) pitch = _pitch(points, 0.485, 0.70, extra=chrome) # 15pt at line 1.45 is 0.302in of type; the rest of the pitch is the gap. gap = max(0, round((pitch - line_h) * 72)) if lines == points: # Every point is a single line: the geometry every existing deck was # laid out against, left exactly as it was. card_h = Inches(chrome + points * pitch) else: # Something wrapped. The gap between points is what it would have # been, and the extra lines add their own height on top - so a card # with one two-line point grows by one line, not by a whole pitch. card_h = Inches( chrome + descender_in + lines * line_h + points * (gap / 72.0) ) card_h = min(card_h, int(BAND_TARGET)) theme = _theme(prs) slide = _blank(prs) _slide_title(prs, slide, title) for i, side in enumerate((left, right)): x = col(0) if i == 0 else col(6) _rect(slide, x, BODY_TOP, span(6) - GUTTER, card_h, theme.surface) tf = _tf(slide, x + Inches(0.35), BODY_TOP + Inches(0.35), span(6) - GUTTER - Inches(0.7), card_h - Inches(0.6)) _para(tf, str(side["label"]), 11, # BOTH labels in the accent. They used to be accent on the left # and ink_soft on the right, which is the one thing this # primitive must not do: "two options" drawn with one of them # highlighted is a recommendation, made by the layout, that the # writer never made and cannot see. A colour difference between # two peers is an argument. If the deck wants to recommend one, # it says so in the words. theme.accent_text, bold=True, tracking=0.08, first=True) for point in side["points"]: _para(tf, point, 15, theme.ink, line=1.45, space_after=gap) _notes(slide, notes) _chrome(prs, slide) return slide # --- labelled blocks ------------------------------------------------------ # # The workhorse this builder did not have. The reference deck - a production # studio's commercial proposal, the deck the house style was art-directed # from - uses this shape on 10 of its 16 slides, and its absence was 10 of the # 13 refusals a rebuild of that deck measured. The substitution an agent is # pushed into is `bullets` with the label as the bullet text, and rendering # that substitution shows what it costs: every label wearing an em-dash like a # bullet, the eyebrow and the lead dropped because bullets has nowhere to put # them, and the right 40% of the slide empty because a flat list is one # column. # # It is a CARD GRID, not a flat list, for the reason the grid exists: four # peers laid out 2x2 are read as four peers. Four rows of text are read as an # ordered list, and they were not ordered. LB_LABEL_PT = 15 LB_TEXT_PT = 14 LB_LEAD_PT = 16 LB_LINE = 1.25 LB_LEAD_LINE = 1.4 LB_PAD_IN = 0.24 LB_DESCENDER_IN = 10 / 72.0 LB_LEAD_LINES = 2 LB_LEAD_GAP = Inches(0.22) # under the lead, before the grid LB_ROW_GUTTER = Inches(0.18) # between the two rows of a 2x2 # This primitive never draws a source note, so the strip BAND_FILL reserves # for one is not reserved against anything here: the first thing under it is # the chrome footer, which starts at BODY_BOTTOM. So the grid is budgeted # against the whole body band. That is the difference between a 2x2 with a # lead fitting and not fitting, and it is a fact about this primitive rather # than a relaxation of BAND_FILL - pass a source note here and this has to # change with it. LB_BAND = BODY_BOTTOM - BODY_TOP # Every line height here is size x line-spacing x LINE_BOX_EM. The third # factor is the one that is easy to drop - `line=1.25` is a PERCENTAGE OF # SINGLE and single is 1.2em, so a 14pt line at 1.25 is 21pt and not 17.5pt. # Dropping it understates every card by 4.8pt per line, which the render # reported as a text-overflow warning on all three cards of a three-card row # the first time this primitive was built. # Columns per card, by block count. Two and four cards are half the grid each; # three are a third. Five would be a row of three and a row of two, which is # not a grid - it is a grid with a hole in it, so five is refused. LB_COLUMNS = {2: (6, 6), 3: (4, 4, 4), 4: (6, 6, 6, 6)} def labelled_blocks(prs, title, blocks, eyebrow=None, lead=None, notes=""): """A titled slide carrying two to four labelled cards. `blocks` is a list of {"label": ..., "text": ...} - the label names the thing and the text says what about it. Four blocks are drawn 2x2, three in a row of three, two side by side. `eyebrow` is the small tracked label that classifies the slide; `lead` is the sentence under the title that says what the blocks add up to. Both are optional and both are the reason this primitive exists rather than `bullets` - a labelled list has no room for either. """ _need_notes(notes) _check_title(title) _need_list(blocks, "labelled_blocks", 2, 4) blocks = [_coerce_keys(b, ("label", "text"), "labelled_blocks block") for b in blocks] if eyebrow: _fit(str(eyebrow), _inches(span(9)), CHROME_PT + 2, "block eyebrow", tracking=0.08, cap=48) lead_h = 0.0 if lead: _fit(str(lead), _inches(span(9)), LB_LEAD_PT, "block lead", lines=LB_LEAD_LINES) lead_lines = _line_count(str(lead), _inches(span(9)), LB_LEAD_PT) lead_h = (lead_lines * LB_LEAD_PT * LB_LEAD_LINE * LINE_BOX_EM / 72.0 + _inches(LB_LEAD_GAP)) cols = LB_COLUMNS[len(blocks)] rows = 2 if len(blocks) == 4 else 1 card_w = span(cols[0]) - (GUTTER if len(blocks) != 3 else GUTTER) inner = _inches(card_w) - LB_PAD_IN * 2 # The grid has to fit what is left of the band under the lead, and with # two rows of cards each row gets half of it. That is the whole budget: # everything else about this primitive is content-sized. label_line = LB_LABEL_PT * LB_LINE * LINE_BOX_EM / 72.0 text_line = LB_TEXT_PT * LB_LINE * LINE_BOX_EM / 72.0 chrome = LB_PAD_IN * 2 + label_line + 4 / 72.0 + LB_DESCENDER_IN # The most a card could ever hold: the whole band, no lead, one row of # cards. This is only the sanity cap _fit needs - what actually decides # the slide is the total, measured below against the same band, so that a # refusal can name the lead and the text together rather than blaming # whichever one happened to be checked first. ceiling = max(1, int((_inches(LB_BAND) - chrome) / text_line)) tallest = 0.0 for block in blocks: # The label is one line by construction - it is a name, and a name # that wraps is one the writer should shorten. Its 40-character cap is # the same editorial rule process step labels carry. _fit(str(block["label"]), inner, LB_LABEL_PT, "block label", cap=40) _fit(str(block["text"]), inner, LB_TEXT_PT, "block text", lines=ceiling) tallest = max(tallest, _line_count(str(block["text"]), inner, LB_TEXT_PT) * text_line) card_h = int(Inches(chrome + tallest)) total = Inches(lead_h) + rows * card_h if rows == 2: total += LB_ROW_GUTTER if total > LB_BAND: raise DeckError( "the lead and %d cards need %.2fin and the body band holds " "%.2fin. On a 2x2 the lead costs BOTH rows, so a one-line lead " "buys every card a line of its own. Shorten the lead, shorten the " "longest block, or split the slide." % (len(blocks), _inches(total), _inches(LB_BAND)) ) theme = _theme(prs) slide = _blank(prs) # The eyebrow takes the accent rule\'s slot rather than sitting above the # title. There is nowhere above: the brand metadata row ends at 0.84in and # the title box starts at 0.94in, so an eyebrow up there lands on the # chrome. Its ROLE is the rule\'s anyway - a small accent mark tying the # title to what follows - so it replaces it and says something while it is # there. With no eyebrow the rule is drawn exactly as on every other body # slide. _slide_title(prs, slide, title, rule=not eyebrow) if eyebrow: etf = _tf(slide, col(0), RULE_Y - Inches(0.03), span(9), Inches(0.24)) _para(etf, str(eyebrow), CHROME_PT + 2, theme.accent_text, bold=True, tracking=0.08, first=True) # Optical placement is against this primitive's own band, so a short # grid still sits where a short process row does. top = int(BODY_TOP + max(0, int(LB_BAND) - int(total)) * OPTICAL_SHARE) if lead: ltf = _tf(slide, col(0), top, span(9), Inches(lead_h)) _para(ltf, str(lead), LB_LEAD_PT, theme.ink_soft, line=LB_LEAD_LINE, first=True) grid_top = top + Inches(lead_h) pad = Inches(LB_PAD_IN) for i, block in enumerate(blocks): column = i % (2 if rows == 2 else len(blocks)) row = i // (2 if rows == 2 else len(blocks)) x = col(sum(cols[:i]) % 12) if rows == 1 else col(column * 6) y = grid_top + row * (card_h + LB_ROW_GUTTER) _rect(slide, x, y, card_w, card_h, theme.surface) tf = _tf(slide, x + pad, y + pad, card_w - pad * 2, card_h - pad * 2) _para(tf, str(block["label"]), LB_LABEL_PT, theme.ink, bold=True, line=LB_LINE, space_after=4, first=True) _para(tf, str(block["text"]), LB_TEXT_PT, theme.ink_soft, line=LB_LINE, space_after=0) _notes(slide, notes) _chrome(prs, slide) return slide # A KPI delta's colour is a claim about whether the number is good news, and # the sign alone cannot support it. "-2.0 handoffs per ticket", "-31% churn", # "-40% cost" and "-15% latency" are all wins, and a sign-reading rule painted # every one of them in the same error red as a failure. For a large class of # business metrics, down IS the win - so the direction is in the string and # the sentiment is the caller's to state. # # `tone` says it: "good", "bad", or "neutral". With no `tone` the delta is # neutral ink, whatever its sign. That is deliberate: guessing from the sign # is what produced the bug, and a wrong colour is worse than no colour - a # neutral delta still reports the number, a red one reports a failure that # did not happen. The tile has never needed the colour to be readable. # # The three colours are a theme's, not a module constant's - #EF4444 is only # 4.0:1 on obsidian, so the dark theme carries a lighter red. The resolution # rule is identical in both; see Theme.tone. TONE_NAMES = ("bad", "good", "neutral") # A KPI label was budgeted at ONE line, which is about twelve Cyrillic # characters in a four-tile row. «на рынке видеопроизводства Казахстана» - the # only phrase that says what "8 лет" actually means - was refused as "3 lines, # budget 1, room for ~12 chars". A label that cannot say what its number means # is not a label, and the tile was never the constraint: it is CARD_H (3.29in) # tall and its content is middle-anchored, so the label has the whole card # under the 40pt value to grow into. # # The three fields share ONE stack, so they share ONE budget - the same # mistake the cover was making with its title and subtitle, and the reason # this is an inch of height rather than a line count per field: a 40pt value # line is three label lines tall, so no line count can describe the trade. # # MEASURED, in two sweeps, with every budget off and plank_deck_qa.py reading # the overflow off the LibreOffice render: # # /tmp/claude-1000/fix/sweep_kpi2.pptx 1-line value, labels 1..9, # with and without a delta # /tmp/claude-1000/fix/sweep_kpi_v2.pptx 2-line value, labels 1..7, same # # Every clean case in both sweeps scores at or under 2.42in on the model # below and every failing one at or over 2.60in - one number that predicts # all 32 renders, which a per-field line budget could not. The allowance is # 2.45in: nothing beyond what a render has shown clean, and 0.15in short of # the first thing one has shown broken. KPI_VALUE_PT = 40 KPI_LABEL_PT = 13 KPI_DELTA_PT = 12 KPI_TILE_TEXT_IN = 2.45 def kpi_row(prs, title, kpis, notes=""): _need_notes(notes) _check_title(title) _need_list(kpis, "kpi_row", 2, 4) # Coerced once, up front — the tiles are walked a second time to draw them, # so normalising inside the validation loop would leave the drawing code # holding the caller's original tuple. kpis = [_coerce_keys(k, ("value", "label"), "kpi_row tile") for k in kpis] theme = _theme(prs) width = span(12) // len(kpis) pad = Inches(0.3) inner = _inches(width - GUTTER - pad * 2) for kpi in kpis: # Validated here with everything else, before _blank(), so a typo in # `tone` costs a DeckError rather than an orphan slide. theme.tone(kpi.get("tone"), "kpi_row tile") # str(): a caller passing an int (a perfectly reasonable KPI value, or # a year as a label) must get a DeckError over budget, not a # TypeError. table_slide does this for cell values; match it here. # # A KPI value is the single most important thing on the slide, not a # column of figures - and that's exactly where house style's "mono # for data" rule stops applying. Monospace exists so that digits line # up underneath each other; a hero number has nothing below it to # align with, and mono's fixed advance actively hurts it at this # size: "1,0" rendered as "1 , 0" and "-40%" opened with a visible # gap before the minus, because a comma or a hyphen gets the same # full glyph cell as a digit. It got worse with a Cyrillic unit after # the number - "40 мин" left a canyon between the digits and "мин" # that a fixed advance can't close. So the value (and its delta, # same failure) is set in Inter and measured with Inter's estimate, # same as every other proportional string in the deck. # `lines` is generous here and the stack is what is budgeted, below. # These calls still have to run: they are what catches a control # character and an unbreakable run, neither of which a height model # can see. _fit(str(kpi["value"]), inner, KPI_VALUE_PT, "KPI value", lines=3) delta = kpi.get("delta") _fit(str(kpi["label"]), inner, KPI_LABEL_PT, "KPI label", lines=9) stack = ( _line_count(str(kpi["value"]), inner, KPI_VALUE_PT) * KPI_VALUE_PT * LINE_BOX_EM / 72.0 + _line_count(str(kpi["label"]), inner, KPI_LABEL_PT) * KPI_LABEL_PT * LINE_BOX_EM / 72.0 ) if delta is not None: stack += KPI_DELTA_PT * LINE_BOX_EM / 72.0 if stack > KPI_TILE_TEXT_IN: raise DeckError( "this KPI tile's value, label%s stack to %.2fin and a tile " "holds %.2fin. The three share one stack, so a long value " "costs the label its lines: shorten whichever of them is " "saying least." % (" and delta" if delta is not None else "", stack, KPI_TILE_TEXT_IN) ) if delta is not None: # Delta is data too, but reads as an extension of the value # sitting right under it - same font, same reasoning, at its own # smaller size. _fit(str(delta), inner, KPI_DELTA_PT, "KPI delta") slide = _blank(prs) _slide_title(prs, slide, title) # A tile is a layout-sized block: three short lines whose height the # content does not set. At a fixed 2.1in it ended 57% down a 16:9 slide # with nothing under it. It takes CARD_H now and centres its own content, # so the tile carries the whitespace instead of the slide. tile_h = CARD_H for i, kpi in enumerate(kpis): value = str(kpi["value"]) x = col(0) + i * width _rect(slide, x, BODY_TOP, width - GUTTER, tile_h, theme.surface) tf = _tf(slide, x + pad, BODY_TOP + pad, width - GUTTER - pad * 2, tile_h - pad * 2, anchor=MSO_ANCHOR.MIDDLE) _para(tf, value, 40, theme.ink, bold=True, tracking=-0.03, first=True) _para(tf, str(kpi["label"]), 13, theme.ink_soft, space_after=0) delta = kpi.get("delta") # `is not None`, so a delta of 0 still renders. Colour comes from # `tone` and from nothing else - see Theme.tone above. if delta is not None: _para(tf, str(delta), 12, theme.tone(kpi.get("tone"), "kpi_row tile"), bold=True) _notes(slide, notes) _chrome(prs, slide) return slide # A table cell was budgeted at ONE line - about 19 Cyrillic characters in a # four-column table - so «Сценарий, раскадровка, кастинг, локации» was refused # and a price table's «состав работ» column could not carry a commercial # proposal. The one-line rule was a real constraint wearing the wrong shape: # the row height was FLAT (`_pitch` over the row count), so a wrapped cell # overflowed a row sized for one line. Let the ROW grow and the constraint # moves where it belongs - onto the table's total height. # # The row model, measured off _cell(): 0.05in of margin top and bottom, and a # 12pt line box at pptx "single" is 12 * 1.2 / 72 = 0.20in. TABLE_ROW_CHROME = 0.10 # cell margin_top + margin_bottom TABLE_ROW_LINE = 0.20 # one 12pt line box # The loosest a row reads as a row rather than as a gap, unchanged from the # flat model: 0.75in was a one-line row's cap, so the air a row may take on # top of what its lines need is 0.75 - (0.10 + 0.20). TABLE_ROW_AIR = 0.45 TABLE_ROW_FLOOR = 0.42 # the pitch a full eight-row table always had # How many lines one cell may wrap to. Not a taste number either: a cell that # needs more than this makes the whole table taller than the band before the # second row exists. TABLE_CELL_LINES = 4 def _table_row_need(lines): """The height a table row of `lines` wrapped lines cannot go under.""" return TABLE_ROW_CHROME + lines * TABLE_ROW_LINE def table_slide(prs, title, headers, rows, note=None, notes=""): _need_notes(notes) _check_title(title) _need_list(headers, "table_slide headers", 1, 6) _need_list(rows, "table_slide rows", 1, 8) # A cell may wrap and its ROW grows to hold it; what is budgeted is the # table's total height against the body band. Eight two-line rows still # push the table through the source note - they are 4.30in of row in a # 3.70in band - and are still refused, but now by the measurement that is # actually true rather than by a per-cell rule that also refused a # four-row table with a two-line column. # Headers are labels and stay Inter; the body is tabular data and is set # in JetBrains Mono (house style: Inter throughout, mono for figures that # line up in a column - a table body, not a KPI hero value, which has # nothing to align with and stays Inter; see kpi_row), which is wider per # character, so it is measured with mono's exact advance. col_w = _inches(span(12)) / len(headers) - 0.28 # One entry per table row, header first, holding the lines its tallest # cell wraps to. row_lines = [1] for head in headers: _fit(str(head), col_w, 12, "table header", lines=TABLE_CELL_LINES) row_lines[0] = max(row_lines[0], _line_count(str(head), col_w, 12)) for r, row in enumerate(rows, start=1): if not isinstance(row, (list, tuple)): raise DeckError( "row %d must be a list of cells, got %s" % (r, type(row).__name__) ) if len(row) != len(headers): raise DeckError( "row %d has %d cells but there are %d headers" % (r, len(row), len(headers)) ) tallest = 1 for value in row: _fit(str(value), col_w, 12, "table cell", mono=True, lines=TABLE_CELL_LINES) tallest = max(tallest, _line_count(str(value), col_w, 12, mono=True)) row_lines.append(tallest) needs = [_table_row_need(n) for n in row_lines] if sum(needs) > _inches(BAND_TARGET): raise DeckError( "the table needs %.2fin of rows (%d rows plus the header, at the " "lines their cells wrap to) and the body band holds %.2fin - the " "bottom rows would run through the source note and the slide " "number. Drop a row, shorten the column that wraps, or split the " "table across two slides." % (sum(needs), len(rows), _inches(BAND_TARGET)) ) if note: _check_source(note, "table note") theme = _theme(prs) slide = _blank(prs) _slide_title(prs, slide, title) # Content-sized, per row. Each row keeps the height its own lines need and # the leftover band is shared out as air, capped at TABLE_ROW_AIR so a # three-row table breathes instead of being blown up, and floored at the # pitch a full eight-row table always had. For an all-one-line table this # is arithmetically the old `_pitch(len(rows) + 1, 0.42, 0.75)` - the # share is BAND_TARGET/n - 0.30 on a 0.30in need - so every deck already # laid out against the flat model is unchanged. slack = max(0.0, _inches(BAND_TARGET) - sum(needs)) / len(needs) row_hs = [ Inches(max(TABLE_ROW_FLOOR, need, min(need + slack, need + TABLE_ROW_AIR))) for need in needs ] shape = slide.shapes.add_table( len(rows) + 1, len(headers), col(0), BODY_TOP, span(12), sum(row_hs) ) table = shape.table # add_table stamps the default Office table style, which renders a blue # banded header and blue banded rows - the single most off-brand thing # the render exposed. Drop the style reference and paint every cell. tblPr = table._tbl.find(qn("a:tblPr")) tblPr.set("firstRow", "0") tblPr.set("bandRow", "0") style_id = tblPr.find(qn("a:tableStyleId")) if style_id is not None: tblPr.remove(style_id) for c, head in enumerate(headers): _cell(table.cell(0, c), head, theme.ink, theme.surface, bold=True, rule=theme.ink_soft) last = len(rows) for r, row in enumerate(rows, start=1): for c, value in enumerate(row): _cell(table.cell(r, c), value, theme.ink, theme.bg, font=theme.mono, rule=None if r == last else theme.hairline) for row_el, height in zip(table.rows, row_hs): row_el.height = height if note: _source(prs, slide, note) _notes(slide, notes) _chrome(prs, slide) return slide def _transparent_chart(frame): """Let the slide background show through the chart. python-pptx builds a chart with an opaque chart area and plot area, which on the ivory theme was close enough to the page to go unnoticed and on the dark theme is a visible black rectangle sitting on an obsidian slide. A theme layer that repaints everything except the largest shape on the slide is not a theme layer. There is no fill API on either element, so the is written directly. """ space = frame.chart._chartSpace inner = space.find(qn("c:chart")) plot_area = None if inner is None else inner.find(qn("c:plotArea")) for parent in (space, plot_area): if parent is None: continue spPr = parent.find(qn("c:spPr")) if spPr is None: spPr = parse_xml('' % nsdecls("c", "a")) # c:spPr comes after c:chart on chartSpace and after the plot's # own series on plotArea; appending is correct for both because # nothing that must follow it is ever written by python-pptx. parent.append(spPr) for existing in list(spPr): if existing.tag in (qn("a:solidFill"), qn("a:gradFill"), qn("a:noFill"), qn("a:pattFill")): spPr.remove(existing) spPr.insert(0, parse_xml('' % nsdecls("a"))) def _reverse_categories(plot): cat_ax = plot.category_axis._element scaling = cat_ax.find(qn("c:scaling")) orientation = scaling.find(qn("c:orientation")) if orientation is None: orientation = parse_xml( '' % nsdecls("c")) scaling.insert(0, orientation) else: orientation.set("val", "maxMin") # python-pptx always writes c:crosses on a value axis it creates, and # this only ever runs against a chart this module just built - so an # "insert it if absent" branch for that case can't be reached and can't # be tested honestly. Set the value on the element that's already there; # doing nothing here left the value axis crossing at the flipped end, # i.e. the scale along the top, which is the bug this function exists to # fix. val_ax = plot.value_axis._element crosses = val_ax.find(qn("c:crosses")) crosses.set("val", "max") GOAL_CHART = { "comparison": XL_CHART_TYPE.COLUMN_CLUSTERED, "trend": XL_CHART_TYPE.LINE, "composition": XL_CHART_TYPE.COLUMN_STACKED, "part": XL_CHART_TYPE.DOUGHNUT, "progress": XL_CHART_TYPE.BAR_CLUSTERED, } # A category axis crowds long before the palette does. Twelve is a year of # months; past that the labels overlap and the chart is really a table. MAX_CATEGORIES = 12 CHART_PT = 12 def chart(prs, title, goal, categories, series, source, notes=""): """A native, editable PPTX chart. `title` must state the takeaway ("Support costs fell 40% after triage"), not the dimension ("Support costs"). `goal` is what the reader should take away, and it picks the chart type - never pick a type by taste. """ _need_notes(notes) _check_title(title) if goal not in GOAL_CHART: raise DeckError( "unknown goal %r - use one of %s" % (goal, sorted(GOAL_CHART)) ) theme = _theme(prs) palette = theme.series _need_list(categories, "chart categories", 1, MAX_CATEGORIES) _need_list(series, "chart series", 1, len(palette)) if goal == "part": # A doughnut colours its *slices*, so the categories are what consume # palette slots, and a single series is all one ring can show. if len(series) != 1: raise DeckError( "goal 'part' draws one ring, got %d series" % len(series) ) if len(categories) > len(palette): raise DeckError( "%d slices, palette has %d validated slots - group the tail " "into 'Other'" % (len(categories), len(palette)) ) # Axis labels and legend entries are rendered text like anything else and # had no budget at all: the label strip is the plot width shared between # the categories, the legend the plot width shared between the series. cat_w = _inches(span(12)) / len(categories) for category in categories: _fit(str(category), cat_w, CHART_PT, "chart category label") name_w = _inches(span(12)) / len(series) for entry in series: if not isinstance(entry, (list, tuple)) or len(entry) != 2: raise DeckError( "each series must be a (name, values) pair, got %r" % (entry,) ) name, values = entry _fit(str(name), name_w, CHART_PT, "chart series name") if not isinstance(values, (list, tuple)): raise DeckError( "series %r values must be a list, got %s" % (name, type(values).__name__) ) # A short list plots partial data and a long one drops its tail, both # silently. table_slide already refuses a ragged table; so does this. if len(values) != len(categories): raise DeckError( "series %r has %d values but there are %d categories" % (name, len(values), len(categories)) ) for value in values: if value is None: continue if isinstance(value, bool) or not isinstance(value, (int, float)): raise DeckError( "series %r contains %r - chart values must be numbers, " "or None for a gap" % (name, value) ) _check_source(source) slide = _blank(prs) _slide_title(prs, slide, title) data = CategoryChartData() data.categories = categories for name, values in series: data.add_series(name, values) # BAND_TARGET is the shared body-band term, so the chart moves with every # other body primitive when the band changes - which it did when the # chrome bands were reserved, taking it from 3.90in to 3.70in. frame = slide.shapes.add_chart( GOAL_CHART[goal], col(0), BODY_TOP, span(12), BAND_TARGET, data ) _transparent_chart(frame) plot = frame.chart plot.has_title = False plot.font.name = theme.font plot.font.size = Pt(CHART_PT) plot.font.color.rgb = theme.ink_soft if len(series) > 1 or goal == "part": plot.has_legend = True plot.legend.position = XL_LEGEND_POSITION.BOTTOM plot.legend.include_in_layout = False else: plot.has_legend = False if goal == "part": # Slice colour is a *point* property. Setting it on the series left # the doughnut in default Office blue/red/green - the one primitive # whose whole job is the validated palette was the one ignoring it. for i, point in enumerate(plot.series[0].points): point.format.fill.solid() point.format.fill.fore_color.rgb = palette[i] else: for i, plot_series in enumerate(plot.series): color = palette[i] if goal == "trend": plot_series.format.line.color.rgb = color plot_series.format.line.width = Pt(2) else: plot_series.format.fill.solid() plot_series.format.fill.fore_color.rgb = color if goal in ("comparison", "composition", "progress"): # PowerPoint autoscales a narrow range to a non-zero baseline, which # is exactly the truncated axis the house style says it never draws. # Bars encode length, so their baseline has to be zero. `trend` is # left alone on purpose: a zero baseline on a time series usually # flattens the very movement the chart is about. plot.value_axis.minimum_scale = 0 if goal == "progress": # A bar chart plots the first category at the *bottom*, so a ranked # list reads upside down. Flip the category axis and move the value # axis crossing so the scale stays along the bottom. _reverse_categories(plot) _source(prs, slide, source) _notes(slide, notes) _chrome(prs, slide) return slide def quote(prs, text, attribution, notes=""): _need_notes(notes) _fit(text, _inches(span(9)), 24, "quote", lines=4, cap=180) _fit(attribution, _inches(span(9)), 13, "attribution", tracking=0.08, cap=60) theme = _theme(prs) slide = _blank(prs) _rect(slide, 0, 0, W, H, theme.surface) # The rule is fixed at the height a maximum-length quote needs, so the # text is centred against it - otherwise a one-line quote sat at the top # of a 2.2in rule with an inch of bare accent hanging below it. # 2.5in, not 2.2in: a maximum-length quote runs to four lines and its # attribution fell below the end of the rule. Centred on the slide. _rect(slide, col(1), Inches(2.5), Pt(3), Inches(2.5), theme.accent) tf = _tf(slide, col(1) + Inches(0.4), Inches(2.5), span(9), Inches(2.5), anchor=MSO_ANCHOR.MIDDLE) _para(tf, text, 24, theme.ink, line=1.3, space_after=12, first=True, italic=theme.display_italic) _para(tf, attribution, 13, theme.ink_soft, tracking=0.08, space_after=0) _notes(slide, notes) _chrome(prs, slide) return slide PROCESS_STEP_PT = 14 PROCESS_LABEL_PT = 15 PROCESS_NUM_PT = 11 PROCESS_STEP_LINE = 1.25 PROCESS_PAD_IN = 0.6 # 0.3in of padding, top and bottom # Reserved under the last line, for the same reason TITLE_INSET_B exists: a # rendered glyph sits slightly below its own line box. The render put # 'дизайнер.' 6.8pt past the bottom of a card that was, on paper, tall enough. PROCESS_DESCENDER_IN = 10 / 72.0 # The smallest a card gets. Not the old sliding `floor_h`, which stretched a # two-step card to 1.6in of surface around one line of type - see the comment # on the group below. PROCESS_MIN_H = Inches(1.15) def _process_steps(steps): """Normalise `steps` to [(label or None, text), ...]. A step used to have to be a string: passing a dict raised "process step must be a string", so the one thing a process card obviously wants - a name for the stage and a sentence saying what happens in it - had to be folded into one run of prose with a colon in it. """ out = [] for step in steps: if isinstance(step, str): out.append((None, step)) continue if isinstance(step, dict): unknown = set(step) - {"label", "text"} if unknown: raise DeckError( "process step has unknown key(s) %s - a step is a string, " "or {'label': ..., 'text': ...}" % ", ".join(repr(k) for k in sorted(unknown)) ) if "text" not in step: raise DeckError( "process step %r has no 'text' - the label names the " "stage, the text says what happens in it" % (step,) ) label = step.get("label") out.append((None if label is None else str(label), str(step["text"]))) continue raise DeckError( "a process step must be a string, or {'label': ..., 'text': ...} " "- got %r" % (step,) ) return out def process(prs, title, steps, notes=""): """A row of numbered cards, one per stage. Each step is a string, or {"label": "Препродакшн", "text": "..."} when the stage has a name worth setting apart from its description. """ _need_notes(notes) _check_title(title) _need_list(steps, "process steps", 1, 5) steps = _process_steps(steps) width = span(12) // len(steps) # Derived from the card, like every other body primitive: the card width # varies with the step count, so a flat 28 was two lines at two steps and # nearly two at five. inner = _inches(width - GUTTER - Inches(0.6)) # The step used to be budgeted at two lines with a 32-character editorial # cap on top. Both were far under what the card holds, and the cap was the # binding one: a four-step card is 2.87in tall and renders eight lines of # 14pt cleanly, so the budget was refusing roughly a third of the text the # card could carry. The reference deck - a production studio's commercial # proposal, the deck this house style was art-directed from - writes 88 to # 120 characters in every one of its four steps, and all four render # mechanically clean here. A builder that cannot accept its own reference # deck's copy is what forces an agent to write telegraphic stubs, which is # what "the deck looks thin" means. # # So the card is content-sized now, the way compare_two and bullets # already are, and the only budget left is the one that is real: the body # band. # x LINE_BOX_EM, because `line=1.25` is a percentage of SINGLE and single # is 1.2em. Leaving it out understates a 14pt line by 3.5pt, which the # render reported as "03 ... renders 3.3pt past the top" on a three-step # card - the card was sized in a unit that does not exist. step_line = PROCESS_STEP_PT * PROCESS_STEP_LINE * LINE_BOX_EM / 72.0 label_line = PROCESS_LABEL_PT * PROCESS_STEP_LINE * LINE_BOX_EM / 72.0 num_block = (PROCESS_NUM_PT * 1.45 * LINE_BOX_EM / 72.0 + 4 / 72.0) budget_in = (_inches(BAND_TARGET) - PROCESS_PAD_IN - num_block - PROCESS_DESCENDER_IN) max_lines = int(budget_in / step_line) tallest = 0.0 for label, text in steps: if label is not None: # The label is one line. It is a name, not a sentence - a stage # label that wraps is a stage label that should be shorter, and # it costs the step's own text a line either way. _fit(label, inner, PROCESS_LABEL_PT, "process step label", cap=40) _fit(text, inner, PROCESS_STEP_PT, "process step", lines=max_lines - (1 if label is not None else 0)) body = _line_count(text, inner, PROCESS_STEP_PT) * step_line if label is not None: body += label_line + 4 / 72.0 tallest = max(tallest, body) theme = _theme(prs) slide = _blank(prs) _slide_title(prs, slide, title) # Content-sized, and that is the fix. The card height used to be a sliding # LAYOUT height - `floor_h`, interpolated from 1.6in at one step up to # CARD_H at five - whose own comment said it existed because "the render # showed two big empty boxes". It still showed two big empty boxes: a # two-step card was 5.7in wide and 1.6in tall around a single line of # type, 95% empty, over 40% of dead slide (kp_nofit-6.png). Stretching a # card the content does not fill does not remove the hole, it moves the # hole inside the card. # # So the card takes the height its own content needs, and the ROW sits # optically in the band instead of being pinned to the top of it. A # primitive given less content than it expects composes; it does not # stretch. card_h = int(Inches(PROCESS_PAD_IN + num_block + PROCESS_DESCENDER_IN) + Inches(tallest)) card_h = min(max(card_h, PROCESS_MIN_H), int(BAND_TARGET)) top = _optical_top(card_h) pad = Inches(0.3) for i, (label, text) in enumerate(steps): x = col(0) + i * width _rect(slide, x, top, width - GUTTER, card_h, theme.surface) tf = _tf(slide, x + pad, top + pad, width - GUTTER - pad * 2, card_h - pad * 2, anchor=MSO_ANCHOR.MIDDLE) _para(tf, "%02d" % (i + 1), PROCESS_NUM_PT, theme.accent_text, bold=True, tracking=0.08, space_after=4, first=True) if label is not None: _para(tf, label, PROCESS_LABEL_PT, theme.ink, bold=True, line=PROCESS_STEP_LINE, space_after=4) _para(tf, text, PROCESS_STEP_PT, theme.ink_soft, line=PROCESS_STEP_LINE, space_after=0) else: _para(tf, text, PROCESS_STEP_PT, theme.ink, line=PROCESS_STEP_LINE, space_after=0) _notes(slide, notes) _chrome(prs, slide) return slide def closing(prs, title, cta=None, contact=None, notes=""): _need_notes(notes) _fit(title, _inches(span(9)), 40, "closing title", lines=3, cap=60) if cta: _fit(cta, _inches(span(9)), 17, "closing CTA", lines=2, cap=90) if contact: _fit(contact, _inches(span(12)), 11, "closing contact", tracking=0.08) theme = _theme(prs) slide = _blank(prs) _rect(slide, col(0), Inches(2.5), Inches(0.9), Pt(4), theme.accent) tf = _tf(slide, col(0), Inches(2.9), span(9), Inches(2.4)) _para(tf, title, 40, theme.ink, bold=True, tracking=-0.03, line=1.0, space_after=8, first=True, italic=theme.display_italic) if cta: _para(tf, cta, 17, theme.ink_soft) if contact: # Above the chrome footer strip, same as the cover's. ctf = _tf(slide, col(0), SOURCE_Y, span(12), SOURCE_H) _para(ctf, contact, 11, theme.ink_soft, tracking=0.08, first=True) _notes(slide, notes) _chrome(prs, slide) return slide # --- the image-led primitives --------------------------------------------- # # These are the two the art-directed style needs and the typographic one never # had. They consume an image that already exists in the workspace - the deck # builder does not generate one - and the path is checked before anything is # drawn, for the same reason every string is measured before anything is # drawn: an image that is not there fails as an empty frame, silently. # # Both put a scrim between the picture and the type. That is structural, not # advisory: there is no way to write a slide with these primitives that puts # text straight onto a photograph, and plank_deck_qa.py measures the contrast # of every word against the pixels that actually ended up behind it. HERO_EYEBROW_H = Inches(0.26) HERO_EYEBROW_GAP = Inches(0.04) # eyebrow box bottom to the title's first line def _hero_type_rise(title): """How far above its pinned bottom edge a hero title's first line starts. In EMU, as a distance rather than a coordinate, because the two builders hold the box in different units and only the distance is common to both. The box is bottom-anchored, so this is the inset plus however many line boxes the title wraps to. `_line_count` is the same estimate the budget check runs, and its 9% headroom biases it upward - so when it is wrong it puts the eyebrow slightly too high, never on top of the title. """ lines = _line_count(title, _inches(span(9)), HERO_TITLE_PT) return TITLE_INSET_B + Pt(lines * HERO_TITLE_PT * LINE_BOX_EM) def _hero_eyebrow_rise(title): """How far above the same bottom edge the eyebrow box starts. The eyebrow tracks the first line of type, so a one-line title does not leave it stranded near the top of a box the type never reaches. The one subtlety is the clamp. The title box's top carries slack no type occupies, so at full height the eyebrow's box lands a couple of points inside it - two boxes each sticking out of the other, which is a shape-overlap finding and a true one. Either state is fine on its own: the eyebrow sits entirely above the box (a full-height title) or entirely inside its empty top (a short one). Only straddling the edge is wrong, so that is the case that gets pushed out. """ rise = _hero_type_rise(title) + HERO_EYEBROW_GAP # the box's bottom if HERO_TITLE_H - HERO_EYEBROW_H < rise < HERO_TITLE_H: rise = HERO_TITLE_H return rise + HERO_EYEBROW_H def hero(prs, image, title, eyebrow=None, lead=None, notes=""): """A full-bleed image with the title over it. The opening slide of a pitch, the beat before a section, the one slide that has to land as an image rather than as an argument. `eyebrow` is the small tracked label above the title; `lead` is one line under it. """ _need_notes(notes) _need_image(image, "hero image") if eyebrow: _fit(str(eyebrow), _inches(span(9)), 11, "hero eyebrow", tracking=0.08, cap=40) # 40pt over three lines, like the closing tier. Over a photograph a title # is competing with the picture, so the cap is tighter than a slide # title's 80: a long sentence on top of an image is read by nobody. # # The three lines are budgeted here and held by HERO_TITLE_H below. They # were not always: see that constant for the 2.7-line box this used to # promise them to. _fit(title, _inches(span(9)), HERO_TITLE_PT, "hero title", lines=HERO_TITLE_LINES, cap=60) if lead: _fit(str(lead), _inches(span(8)), 16, "hero lead", lines=2, cap=110) theme = _theme(prs) slide = _blank(prs) _picture_cover(slide, image, 0, 0, W, H) _scrim(slide, 0, 0, W, H, theme.scrim) # Three boxes, not three paragraphs in one, and the TITLE goes down first. # plank_deck_qa.py's title ladder reads the first paragraph of the first # text shape on the slide; with the eyebrow sharing the frame the ladder # would print "OUR WORK" for every hero and the deck's argument would # vanish from the one place it is supposed to be readable. lead_h = Inches(0.62) lead_y = FOOTER_Y - Inches(0.18) - lead_h title_h = HERO_TITLE_H title_bottom = (lead_y - Inches(0.12)) if lead else (lead_y + lead_h) title_y = title_bottom - title_h tf = _tf(slide, col(0), title_y, span(9), title_h, anchor=MSO_ANCHOR.BOTTOM, inset_b=TITLE_INSET_B) _para(tf, title, HERO_TITLE_PT, theme.over_image, bold=True, tracking=-0.03, line=1.0, first=True, italic=theme.display_italic) if eyebrow: # Above the first LINE of the title, not above the box that holds it. # A bottom-anchored box only fills from the bottom, so the two are the # same place only for a title that uses every line it is given: on a # one-line title the box top is 1.4in of empty picture above the type, # and the eyebrow hung there, unattached to anything. Making the box # taller made that worse, which is how it was noticed. # # It also puts the eyebrow back where the scrim was tuned for it. The # heavy part of the wash starts at 46% of the slide height *because* # the eyebrow sits at about 47% - see SCRIM_STOPS - and an eyebrow # pinned to the box top climbs out of the wash as the box grows, onto # whatever the photograph happens to be. Measured on a light one, that # is the difference between 4.5:1 and 3.9:1. etf = _tf(slide, col(0), title_bottom - _hero_eyebrow_rise(title), span(9), HERO_EYEBROW_H) _para(etf, str(eyebrow), 11, theme.accent_over_image, bold=True, tracking=0.08, first=True) if lead: ltf = _tf(slide, col(0), lead_y, span(8), lead_h) _para(ltf, str(lead), 16, theme.over_image_soft, line=1.4, first=True) _notes(slide, notes) _chrome(prs, slide, over_image=True) return slide # A point beside the picture was budgeted at ONE line - about 38 Cyrillic # characters across seven columns - so a 77-character point was refused while # rendering clean in two lines and reading BETTER than the one-line version # somebody had to cut it down to. Same correction as bullets: what is real is # the band, so lines are what is counted, and the count is the same eight # lines bullets gets because it is the same band at the same 16pt. # # Measured at /tmp/claude-1000/fix/png/ic_sweep-*: 8 rows of point text is # 3.13in of a 4.11in band under the new pitch, and the old model's failures # (three points x three lines, four x two) both come back clean. IMAGE_POINT_LINES = 8 def image_content(prs, title, image, points, caption=None, notes=""): """A titled slide with the image on the right and the points on the left. The workhorse of an art-directed deck: an argument that needs a picture beside it rather than under it. The image sits in the body band, not full-bleed, so it never runs under the header row or the footer - the two places where a picture would put type on top of an unknown colour. """ _need_notes(notes) _check_title(title, width=7) _need_image(image, "slide image") _need_list(points, "image_content points", 1, 4) width = _inches(span(7)) rows = 0 for point in points: if not isinstance(point, str): raise DeckError( "image_content points must be strings, got %r" % (point,) ) _fit("— " + point, width, 16, "image_content point", lines=IMAGE_POINT_LINES) rows += _line_count("— " + point, width, 16) if rows > IMAGE_POINT_LINES: raise DeckError( "%d lines of point text beside the picture, budget %d - the rest " "falls off the bottom of the slide. Shorten one, drop one, or " "split the slide." % (rows, IMAGE_POINT_LINES) ) if caption: _fit(str(caption), _inches(span(5)), 11, "image caption", cap=70) theme = _theme(prs) slide = _blank(prs) _slide_title(prs, slide, title, width=7) # Five columns of picture, seven of argument. The picture fills the band # top to bottom so the two halves share a baseline. img_x, img_w = col(7), span(5) img_h = BODY_BOTTOM - BODY_TOP _picture_cover(slide, image, img_x, BODY_TOP, img_w, img_h) if caption: # A caption sits INSIDE the picture, so it gets its own scrim strip # rather than the whole-image wash a hero uses - the point of this # layout is that the picture stays a picture. # # Three stops, not two, and for the same reason SCRIM_STOPS has four: # a single 0 -> 92% ramp is only about 41% where the caption's own # baseline sits (the text box starts 0.28in down a 0.62in strip), and # on a light photograph that measured 3.0:1 - an error, and the same # bug as the hero eyebrow one layer down. Reaching 88% by 38% of the # strip puts the whole caption in the heavy part while the strip's top # edge still fades in rather than banding across the picture. strip = Inches(0.62) _scrim(slide, img_x, BODY_TOP + img_h - strip, img_w, strip, theme.scrim, stops=((0, 0.0), (38000, 0.88), (100000, 0.96))) tf = _tf(slide, img_x + Inches(0.22), BODY_TOP + img_h - strip + Inches(0.28), img_w - Inches(0.44), Inches(0.28)) _para(tf, str(caption), 11, theme.over_image_soft, first=True) # Spread on LINES, not on points - the same correction bullets and # compare_two already carry. Spreading on the point count sized the gaps # for a one-line point and then let every wrapped line add its height on # top: four two-line points measured 4.73in in a 4.11in band and the # render put the fourth point through the bottom of the slide. With the # pitch taken on rows, the block is rows x pitch <= BAND_TARGET whatever # the points do, because there is never more than one gap per row. pitch = _pitch(rows, 0.461, 0.86) gap = max(0, round((pitch - 16 * 1.45 / 72.0) * 72)) tf = _tf(slide, col(0), BODY_TOP, span(7), img_h) for i, point in enumerate(points): _para(tf, "— " + point, 16, theme.ink, line=1.45, space_after=gap, first=(i == 0)) _notes(slide, notes) _chrome(prs, slide) return slide def save(prs, path): prs.save(path) return path