Purrx

Consistent decks: matching heading sizes

Make AI-generated decks look designed: after laying out every slide, lock headings and titles to the smallest size any of them needed, and lay the deck out again.

11 min+35 XPHands-on

Shrink-to-fit makes each text fit its own box. Across a deck, that has a side effect: if slide 2 has a long heading, it shrinks to 22pt while every other heading stays 28pt. Each slide is fine alone. Flipping through the deck, the jump looks like a mistake.

Before: 28pt · 22pt · 28pt

After: 22pt · 22pt · 22pt

Top: each slide fitted on its own, so the long heading shrank to 22pt while the others stayed 28pt. Bottom: after the second pass every heading is 22pt, and the deck reads as one design.

Roles

Components already mark what their text is: the library's headings have role="heading" and title slides have role="title". The layout copies the role onto each text element in the plan. Text with the same role should look the same.

Two passes

  1. Lay out every slide as usual. Some headings shrink.
  2. smallestSizeByRole(slides): for each role, the smallest font size it ended up at.
  3. lockRoleSizes(tree, sizes): a copy of each slide tree with every text of that role set to that size.
  4. Lay those trees out again. Every heading now starts at the size the longest one needed, so they all match.

That's . The second pass is pure arithmetic, a few milliseconds, no model call.

  • Headings also got a fixed two-line area (height="72") in the library, so content starts at the same height on every slide, whether the heading has one line or two.
Going deeper — consistency as a system, not a checklist

The production engine harmonizes more than headings: body text in cards on the same slide, stat values, and chosen groups across the whole deck. It also runs lint checks, like text color contrast against its background. The principle is the same: let the model write content, and let deterministic code enforce the design rules a human designer would.

Your project so far

24 files · 2 new or changed in this lesson

src/compile.js

// compile.js ties the stages together: text in, finished slides out.
//
//   SlideML text → parse → expand components → apply theme → layout → render plan → .pptx

import { elementChildren, parseXml } from "./xml.js";
import { layoutSlide } from "./layout.js";
import { planToPptx } from "./pptx.js";
import { expandAll } from "./components.js";
import { LIBRARY } from "./library.js";
import { applyTheme, THEMES } from "./theme.js";
import { lockRoleSizes, smallestSizeByRole } from "./harmonize.js";

/** SlideML text → a render plan for one slide. */
export function compileSlide(xml) {
  const tree = parseXml(xml);
  if (tree.tag !== "slide") throw new Error(`Expected a <slide>, found <${tree.tag}>`);
  return layoutSlide(tree);
}

/** SlideML text → the plan (to preview) and the .pptx file (base64, to download). */
export async function slideToPptx(xml) {
  const plan = { slides: [compileSlide(xml)] };
  const file = await planToPptx(plan);
  return { plan, file };
}

/**
 * A whole <deck>. One broken slide must not sink the others, so every slide is
 * compiled on its own and its problems are collected instead of thrown.
 *
 * Then a second pass makes the deck consistent: titles and headings are locked
 * to the smallest size any of them needed, so they match from slide to slide.
 *
 * Returns { slides, problems }, where each problem is { slide: number, message }.
 * Slide numbers start at 1; slide 0 means the deck itself.
 */
export function compileDeck(xml, themeName) {
  let deck;
  try {
    deck = parseXml(xml);
  } catch (error) {
    return { slides: [], problems: [{ slide: 0, message: `The XML couldn't be read: ${error.message}` }] };
  }
  if (deck.tag !== "deck") {
    return { slides: [], problems: [{ slide: 0, message: `The root element must be <deck>, not <${deck.tag}>` }] };
  }

  const theme = THEMES[themeName ?? deck.attrs.theme] ?? THEMES.light;
  const trees = [];
  const problems = [];

  // Pass 1: build and lay out every slide on its own.
  const firstPass = elementChildren(deck).map((node, index) => {
    const number = index + 1;
    try {
      const tree = applyTheme(expandAll(node, LIBRARY), theme);
      if (tree.tag !== "slide") throw new Error(`<${node.tag}> isn't a slide or a slide component`);
      trees[index] = tree;
      return layoutSlide(tree);
    } catch (error) {
      problems.push({ slide: number, message: error.message });
      return placeholderSlide(number, error.message, theme);
    }
  });

  // Pass 2: lay the good slides out again with matching title and heading sizes.
  const sizes = smallestSizeByRole(firstPass);
  const slides = firstPass.map((slide, index) => {
    if (!trees[index]) return slide;
    const final = layoutSlide(lockRoleSizes(trees[index], sizes));
    for (const message of final.problems) problems.push({ slide: index + 1, message });
    return final;
  });

  return { slides, problems: problems.sort((a, b) => a.slide - b.slide) };
}

/** A visible stand-in for a slide that couldn't be built, so the deck still opens. */
function placeholderSlide(number, message, theme) {
  return {
    background: theme.colors.background,
    problems: [message],
    elements: [
      { type: "text", x: 48, y: 150, w: 624, h: 40, text: `Slide ${number} couldn't be built`, lines: [`Slide ${number} couldn't be built`], fontSize: 28, bold: true, color: theme.colors.text, font: theme.fonts.body },
      { type: "text", x: 48, y: 200, w: 624, h: 60, text: message, lines: [message], fontSize: 14, color: theme.colors.muted, font: theme.fonts.body },
    ],
  };
}

/** A whole deck to a file, plus the plan and any problems. */
export async function deckToPptx(xml, themeName) {
  const { slides, problems } = compileDeck(xml, themeName);
  const plan = { slides };
  return { plan, problems, file: await planToPptx(plan) };
}

Key takeaways

  • Fitting text slide by slide can leave one heading smaller than the rest, and readers notice.
  • Mark similar text with a role, find the smallest size each role used, and lock every one to it.
  • A second layout pass is cheap: it's only arithmetic, no model call.

Sign in to run the exercise

Reading is free. Writing code here needs an account so we have somewhere to keep your Gemini key and the +35 XP you are about to earn.