Cards takes a block of writing and turns it into a set of carousel slides. The hard question is where the breaks go. Put a fixed number of characters on each slide and you cut sentences in half, strand a single line on the last slide, and end up with slides that look unevenly full next to each other.
We wanted the split to do three things: fill slides to about the same level, break on sentence or paragraph boundaries, and avoid an orphan slide holding one stray line. Those goals pull against each other, so the split is an optimization rather than a loop that fills each slide to the brim.
The pipeline
A block of text passes through three stages before it becomes slides:
function autoSplitBlock(block: string, design: DesignConfig): string[] {
const plan = splitBodyPlannerMetrics(design);
const flow = plainFlowTokens(block); // words + line breaks
const lines = wrapPlainFlowToLines(flow, plan.usableWidthPx, plan.fontSpec);
const atoms = atomsFromWrappedLines(lines); // one atom per visual line
return balancedPackWeightedAtoms({
atoms,
maxSlotsPerCard: plan.maxSlotsPerCard,
joinInsideCard: "\n",
});
}
The first stage turns the text into a stream of words and line breaks. The second wraps those into visual lines at the slide’s real width, the same width the renderer uses. Each wrapped line becomes an atom that carries a few facts about itself:
type PackAtom = {
slots: number; // vertical room this line needs
tailEndsSentence: boolean;
tailEndsParagraph: boolean;
headBeginsContinuation: boolean;
chunk: string;
};
slots is the vertical room the line takes. The flags record whether the line ends a sentence, ends a paragraph, or starts as the continuation of one. Those facts are what let the packer break in sensible places.
A cost for every layout
The packer scores a whole arrangement of slides and keeps the cheapest. The scores come from one small table:
const DEFAULT_PACK_COSTS = {
wVar: 100, // distance from an even fill
bonusParagraphBoundary: -30,
bonusSentenceBoundary: -10,
ripMidSentence: 12,
orphanPenalty: 50,
widowPenalty: 15,
};
Read it as a set of preferences. wVar punishes slides whose fill sits far from the average, which is what keeps them even. A break that lands on a paragraph or sentence boundary earns a bonus, a negative cost. A break in the middle of a sentence pays ripMidSentence. A slide holding one short atom pays orphanPenalty. A continuation line pushed to the top of the next slide after a sentence already closed pays widowPenalty.
Choosing the breaks with dynamic programming
Once any arrangement has a cost, finding the best set of breaks for a fixed slide count turns into a shortest-path problem, the kind dynamic programming solves well. The packer fills a table where dp[ck][i] holds the lowest cost to place the first i atoms into ck slides, building the larger answers out of smaller ones:
dp[0][0] = 0;
const targetMean = totalSlots / cardsK;
for (let ck = 1; ck <= cardsK; ck++) {
for (let i = ck; i <= N; i++) {
for (let j = ck - 1; j < i; j++) {
const sliceW = sliceBetween(j, i); // slots used by atoms j..i
if (sliceW > slideSlotBudget) continue; // would overflow one slide
const variance = wVar * (sliceW - targetMean) ** 2;
const rip = outgoingRipCost(atoms[i - 1]); // boundary bonus or mid-sentence cost
const cost = dp[ck - 1][j] + variance + rip + widow + orphan;
if (cost < dp[ck][i]) {
dp[ck][i] = cost;
pick[ck][i] = j;
}
}
}
}
Each entry tries every earlier cut point j: put atoms j..i on the current slide, add that slide’s variance and break costs to the best way of packing the first j atoms into one fewer slide, and keep the minimum. Following the back-pointers from dp[K][N] gives the actual cuts.
How many slides
The slide count K is not known up front. Too few and the text overflows; too many and every slide is half-empty. The packer starts from a greedy lower bound, the fewest slides that could hold the text if you filled each to its limit, then raises K by one until the table finds a layout that fits:
let k = greedyMinCards(atoms, slideBudget); // fewest slides that could fit
for (; k <= atoms.length; k++) {
const packed = balancedPackForExactK({ /* ... */ cardsK: k });
if (packed?.length) return packed;
}
return atoms.map(oneAtomPerSlide); // fallback: never hang
If nothing fits, which happens only when a single atom is taller than a slide, it falls back to one atom per slide so the editor never hangs.
What this buys
The split is a pure function of the text and the slide size, so it reruns on every keystroke. Add a sentence in the middle and the slides after it reflow on their own, the count adjusts, and the breaks still sit on sentence boundaries. Nothing to drag, no slides to manage by hand. The same code runs again when you switch canvas format, so a square post and a vertical story re-split to fit their own shapes.
Cards is free and runs in the browser at cards.tinygods.dev.