Size boxes to fit their content
Upgrade the layout engine so column children are as tall as their text, space left over goes to growing children, and every text box shrinks to fit.
Lesson 16 of 4414 min+35 XPHands-on
Right now every child in a column gets an equal share () of the height, so a one-line heading gets as much room as a paragraph. Real slides don't work like that. Now that the engine can measure text, children can ask for exactly the height they need.
heading · measured: 34pt
body with grow="1" · the rest
note · measured: 17pt
The new rules for a child
- A fixed
widthorheightwins. - Otherwise an explicit
growtakes a share of the leftover space. - Otherwise, in a row, children share the width equally.
- Otherwise, in a column, the child is as tall as its content:
measureHeight(child, width).
measureHeight: how tall does this want to be?
- Text: wrap it at its font size to the given width, then
textHeight(lines, fontSize). - Row: work out each child's width, and take the tallest child (written for you).
- Column, box or slide: add up the children's heights, the gaps between them, and padding top and bottom. Children that grow don't want a fixed height, so they count as zero.
It's recursive, like arrange: a box inside a column inside a slide measures its own children to answer.
Your project so far
12 files · 1 new or changed in this lesson
src/layout.js
// The layout engine turns a tree of rows, columns and text into exact boxes.
// Every number here is in points, and every box is { x, y, w, h }.
import { SLIDE_HEIGHT, SLIDE_WIDTH } from "./units.js";
import { elementChildren, textOf } from "./xml.js";
import { fitText, textHeight, wrapText } from "./text.js";
const CONTAINERS = new Set(["slide", "column", "row", "box"]);
// Used when a <text> doesn't say otherwise. Themes replace these later.
const DEFAULT_FONT_SIZE = 18;
const DEFAULT_COLOR = "#1F2937";
const DEFAULT_FONT = "Arial";
// Text never shrinks below this; if it still doesn't fit, that's reported as a problem.
const MIN_FONT_SIZE = 10;
/** Attribute values arrive as strings: "24" → 24, missing → fallback. */
export function num(value, fallback = 0) {
if (value === undefined || value === "") return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
/** The space left inside a box after taking `padding` off every side. */
export function innerBox(box, padding = 0) {
return {
x: box.x + padding,
y: box.y + padding,
w: Math.max(0, box.w - padding * 2),
h: Math.max(0, box.h - padding * 2),
};
}
/**
* Share `total` points between items, leaving `gap` points between neighbors.
* Each item is either { size } (a fixed number of points) or { grow } (a share of what's left).
*
* splitSpace(300, [{ size: 100 }, { grow: 1 }, { grow: 1 }], 20) → [100, 80, 80]
*
* If the fixed sizes don't fit, they are all squeezed by the same factor, so the
* result never spills outside `total`.
*/
export function splitSpace(total, items, gap = 0) {
const available = Math.max(0, total - gap * Math.max(0, items.length - 1));
const fixedTotal = items.reduce((sum, item) => sum + (item.size ?? 0), 0);
const squeeze = fixedTotal > available ? available / fixedTotal : 1;
const leftover = Math.max(0, available - fixedTotal * squeeze);
const growTotal = items.reduce((sum, item) => sum + (item.size === undefined ? (item.grow ?? 0) : 0), 0);
return items.map((item) => {
if (item.size !== undefined) return item.size * squeeze;
if (growTotal === 0) return 0;
return (leftover * (item.grow ?? 0)) / growTotal;
});
}
/** Lay out one <slide> tree on a 720 × 405 point slide. */
export function layoutSlide(slide) {
const context = { elements: [], problems: [] };
arrange(slide, { x: 0, y: 0, w: SLIDE_WIDTH, h: SLIDE_HEIGHT }, context);
return { background: slide.attrs.fill, notes: slide.attrs.notes, elements: context.elements, problems: context.problems };
}
/** Place `node` inside `box`, adding whatever it draws to context.elements. */
export function arrange(node, box, context) {
if (node.tag === "text") {
arrangeText(node, box, context);
return;
}
if (!CONTAINERS.has(node.tag)) {
context.problems.push(`Unknown element <${node.tag}>`);
return;
}
// A filled container draws its background first, so its children sit on top.
if (node.attrs.fill && node.tag !== "slide") {
context.elements.push({ type: "rect", ...box, fill: node.attrs.fill });
}
const inner = innerBox(box, num(node.attrs.padding));
const children = elementChildren(node);
const horizontal = node.tag === "row";
const gap = num(node.attrs.gap);
const items = children.map((child) => sizeOf(child, horizontal, inner.w));
const sizes = splitSpace(horizontal ? inner.w : inner.h, items, gap);
let cursor = horizontal ? inner.x : inner.y;
children.forEach((child, index) => {
const childBox = horizontal
? { x: cursor, y: inner.y, w: sizes[index], h: inner.h }
: { x: inner.x, y: cursor, w: inner.w, h: sizes[index] };
arrange(child, childBox, context);
cursor += sizes[index] + gap;
});
}
/**
* How a child asks for space along its parent's direction.
* - a fixed width/height wins
* - then an explicit grow
* - in a row, everything else shares the width equally
* - in a column, everything else is as tall as its content
*/
function sizeOf(child, horizontal, width) {
const fixed = horizontal ? child.attrs.width : child.attrs.height;
if (fixed !== undefined) return { size: num(fixed) };
if (child.attrs.grow !== undefined) return { grow: num(child.attrs.grow) };
if (horizontal) return { grow: 1 };
return { size: measureHeight(child, width) };
}
/** How tall `node` wants to be when it is given `width` points of width. */
export function measureHeight(node, width) {
if (node.attrs.height !== undefined) return num(node.attrs.height);
if (node.tag === "text") {
const fontSize = num(node.attrs.fontSize, DEFAULT_FONT_SIZE);
const lines = wrapText(textOf(node), fontSize, width, node.attrs.bold === "true");
return textHeight(lines.length, fontSize);
}
const padding = num(node.attrs.padding);
const gap = num(node.attrs.gap);
const inner = Math.max(0, width - padding * 2);
const children = elementChildren(node);
if (children.length === 0) return padding * 2;
if (node.tag === "row") {
// Side by side: as tall as the tallest child, at the width each child will get.
const widths = splitSpace(
inner,
children.map((child) => (child.attrs.width !== undefined ? { size: num(child.attrs.width) } : { grow: num(child.attrs.grow, 1) })),
gap,
);
return padding * 2 + Math.max(...children.map((child, index) => measureHeight(child, widths[index])));
}
// Stacked: every child's height plus the gaps between them.
const heights = children.map((child) => (child.attrs.grow !== undefined ? 0 : measureHeight(child, inner)));
return padding * 2 + heights.reduce((sum, height) => sum + height, 0) + gap * (children.length - 1);
}
function arrangeText(node, box, context) {
const text = textOf(node);
const bold = node.attrs.bold === "true";
const fontSize = num(node.attrs.fontSize, DEFAULT_FONT_SIZE);
const fit = fitText(text, box, { fontSize, bold, minSize: Math.min(fontSize, MIN_FONT_SIZE) });
if (fit.overflow) {
context.problems.push(`Text is too long for its box even at ${fit.fontSize}pt: "${text.slice(0, 40)}"`);
}
context.elements.push({
type: "text",
...box,
text,
lines: fit.lines,
fontSize: fit.fontSize,
bold,
color: node.attrs.color ?? DEFAULT_COLOR,
font: node.attrs.font ?? DEFAULT_FONT,
align: node.attrs.align ?? "left",
valign: node.attrs.valign ?? "top",
role: node.attrs.role, // e.g. "title": lets later steps treat similar text the same way
});
}Key takeaways
- In a column, children without a height or grow are exactly as tall as their content.
- Measuring is recursive: a row is as tall as its tallest child, a column adds up its children.
- If content doesn't fit, splitSpace squeezes it and fitText shrinks the text to match.
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.