-- lx-bundle:true

--- Box layout and drawing for the Markdown TeX subset
-- @module plugins.mdpreview.math.layout

local layout = {}
local colors = require("core.colors")
local resolve_css_color = colors.combine("core.colors.css")

-- Fixed TeX units are measured relative to the preview's ordinary ten-point em
local TEX_POINT_RATIOS = {
	pt = 1,
	pc = 12,
	["in"] = 72.27,
	bp = 803 / 800,
	cm = 7227 / 254,
	mm = 7227 / 2540,
	dd = 1238 / 1157,
	cc = 14856 / 1157,
	nd = 685 / 642,
	nc = 1370 / 107,
	sp = 1 / 65536,
}

--- Create an empty drawing box
-- @param width number box width
-- @param height number box height
-- @param baseline number baseline measured from the top
-- @return table box drawing box
local function new_box(width, height, baseline)
	return {
		width = math.max(0, width),
		height = math.max(1, height),
		baseline = math.max(0, baseline),
		operations = {},
	}
end

--- Add visible pixels to a box's ink bounds
-- @param box table destination box
-- @param x number visible left edge
-- @param y number visible top edge
-- @param width number visible width
-- @param height number visible height
local function include_ink(box, x, y, width, height)
	if width <= 0 or height <= 0 then
		return
	end

	if not box.ink then
		box.ink = { x = x, y = y, width = width, height = height }
		return
	end

	local left = math.min(box.ink.x, x)
	local top = math.min(box.ink.y, y)
	local right = math.max(box.ink.x + box.ink.width, x + width)
	local bottom = math.max(box.ink.y + box.ink.height, y + height)

	box.ink.x = left
	box.ink.y = top
	box.ink.width = right - left
	box.ink.height = bottom - top
end

--- Return visible bounds or the full box when nothing draws
-- @param box table measured box
-- @return table ink visible bounds
local function visible_ink(box)
	return box.ink or { x = 0, y = 0, width = box.width, height = box.height }
end

--- Add one visible rule and include it in the box's ink
-- @param box table destination box
-- @param x number rule left edge
-- @param y number rule top edge
-- @param width number rule width
-- @param height number rule height
-- @param color table|nil explicit rule color
local function append_line(box, x, y, width, height, color)
	box.operations[#box.operations + 1] = {
		kind = "line",
		x = x,
		y = y,
		width = width,
		height = height,
		color = color,
	}
	include_ink(box, x, y, width, height)
end

--- Convert one parsed TeX length into preview pixels
-- @param amount number parsed numeric amount
-- @param unit string parsed TeX unit
-- @param context table current layout context
-- @return number pixels resolved length
local function resolve_length(amount, unit, context)
	if unit == "em" then
		return amount * context.text_unit
	elseif unit == "ex" then
		return amount * context.text_unit * 0.431
	elseif unit == "mu" then
		return amount * context.unit / 18
	end

	-- Absolute units do not grow with font-size or math-style declarations
	return amount * TEX_POINT_RATIOS[unit] * context.base_unit / 10
end

--- Add one arbitrary-angle rule and include its conservative ink bounds
-- @param box table destination box
-- @param x1 number starting horizontal position
-- @param y1 number starting vertical position
-- @param x2 number ending horizontal position
-- @param y2 number ending vertical position
-- @param thickness number line width
local function append_diagonal(box, x1, y1, x2, y2, thickness)
	box.operations[#box.operations + 1] = {
		kind = "diagonal",
		x = x1,
		y = y1,
		x2 = x2,
		y2 = y2,
		width = thickness,
	}
	include_ink(
		box,
		math.min(x1, x2) - thickness,
		math.min(y1, y2) - thickness,
		math.abs(x2 - x1) + thickness * 2,
		math.abs(y2 - y1) + thickness * 2
	)
end

--- Add one dashed horizontal rule
-- @param box table destination box
-- @param x number rule left edge
-- @param y number rule top edge
-- @param width number complete rule width
-- @param thickness number rule thickness
local function append_dashed_line(box, x, y, width, thickness)
	local dash = math.max(thickness * 3, 3)
	local gap = dash
	local offset = 0

	while offset < width do
		append_line(box, x + offset, y, math.min(dash, width - offset), thickness)
		offset = offset + dash + gap
	end
end

--- Add one dashed vertical rule
-- @param box table destination box
-- @param x number rule left edge
-- @param y number rule top edge
-- @param height number complete rule height
-- @param thickness number rule thickness
local function append_dashed_vertical_line(box, x, y, height, thickness)
	local dash = math.max(thickness * 3, 3)
	local gap = dash
	local offset = 0

	while offset < height do
		append_line(box, x, y + offset, thickness, math.min(dash, height - offset))
		offset = offset + dash + gap
	end
end

--- Copy every drawing operation into another box at an offset
-- @param target table destination box
-- @param source table source box
-- @param x number horizontal offset
-- @param y number vertical offset
local function append_box(target, source, x, y)
	for operation_index = 1, #source.operations do
		local operation = source.operations[operation_index]
		target.operations[#target.operations + 1] = {
			kind = operation.kind,
			text = operation.text,
			font = operation.font,
			glyph_id = operation.glyph_id,
			mask = operation.mask,
			x = operation.x + x,
			y = operation.y + y,
			x2 = operation.x2 and operation.x2 + x,
			y2 = operation.y2 and operation.y2 + y,
			width = operation.width,
			height = operation.height,
			color = operation.color,
			link = operation.link,
		}
	end
	for link_index = 1, #(source.links or {}) do
		local link = source.links[link_index]
		target.links = target.links or {}
		target.links[#target.links + 1] = {
			target = link.target,
			x = link.x + x,
			y = link.y + y,
			width = link.width,
			height = link.height,
			underline_y = link.underline_y + y,
		}
	end

	if source.ink then
		include_ink(
			target,
			source.ink.x + x,
			source.ink.y + y,
			source.ink.width,
			source.ink.height
		)
	end

	-- Invisible vertical phantoms still affect enclosing radical measurements
	if source.layout_top then
		local top = source.layout_top + y
		local bottom = source.layout_bottom + y
		target.layout_top = math.min(target.layout_top or top, top)
		target.layout_bottom = math.max(target.layout_bottom or bottom, bottom)
	end
end

--- Lay out one plain glyph run
-- @param text string visible text
-- @param font userdata selected font
-- @return table box text box
local function text_box(text, font)
	local box = new_box(font:get_width(text), font:get_height(), font:get_baseline())
	box.operations[1] = { kind = "text", text = text, font = font, x = 0, y = 0 }
	local bounds = font:get_text_bounds(text)
	local italics_correction = font:get_math_glyph_italics_correction(text)
	local right_overhang = bounds
		and math.max(0, bounds.x + bounds.width - box.width) or 0

	if bounds then
		include_ink(box, bounds.x, bounds.y, bounds.width, bounds.height)
	end
	box.attachment = {
		text = text,
		font = font,
		italics_correction = italics_correction,
		top_accent = font:get_math_glyph_top_accent_attachment(text),
	}
	if italics_correction > 0 or right_overhang > 0 then
		-- Keep following content beyond both the italic correction and visible overhang
		box.trailing_italics_correction = math.max(
			italics_correction,
			right_overhang + font:get_height() * 0.02
		)
	end

	return box
end

local build_node
local CD_LABEL_GAP = 0.29	-- Match the established side-label distance from each shaft

--- Use the math font's real italic alphabet for ordinary Latin variables
-- @param text string one parsed atom
-- @param font userdata selected math font
-- @return string text mathematical italic glyph when available
local function mathematical_italic(text, font)
	local byte = #text == 1 and text:byte() or nil
	local codepoint

	if byte and byte >= string.byte("A") and byte <= string.byte("Z") then
		codepoint = 0x1D434 + byte - string.byte("A")
	elseif byte and byte >= string.byte("a") and byte <= string.byte("z") then
		if text == "h" then
			codepoint = 0x210E
		else
			codepoint = 0x1D44E + byte - string.byte("a")
		end
	end

	if codepoint and font:has_codepoint(codepoint) then
		return utf8.char(codepoint)
	end

	return text
end

--- Use Unicode's distinct script capital L when the math font provides it
-- @param text string one styled atom
-- @param font userdata selected math font
-- @return string text script glyph when available
local function mathematical_script(text, font)
	local script_l = 0x2112

	if text == "L" and font:has_codepoint(script_l) then
		return utf8.char(script_l)
	end

	return text
end

--- Use Unicode's double-struck alphabet for mathbb letters and digits
-- @param text string one parsed atom
-- @param font userdata selected math font
-- @return string text double-struck glyph when available
local function mathematical_double_struck(text, font)
	local byte = #text == 1 and text:byte() or nil
	local exceptions = {
		C = 0x2102,
		H = 0x210D,
		N = 0x2115,
		P = 0x2119,
		Q = 0x211A,
		R = 0x211D,
		Z = 0x2124,
	}
	local codepoint = exceptions[text]

	if not codepoint and byte and byte >= string.byte("A") and byte <= string.byte("Z") then
		codepoint = 0x1D538 + byte - string.byte("A")
	elseif not codepoint and byte
		and byte >= string.byte("a") and byte <= string.byte("z")
	then
		codepoint = 0x1D552 + byte - string.byte("a")
	elseif not codepoint and byte
		and byte >= string.byte("0") and byte <= string.byte("9")
	then
		codepoint = 0x1D7D8 + byte - string.byte("0")
	end

	if codepoint and font:has_codepoint(codepoint) then
		return utf8.char(codepoint)
	end

	return text
end

--- Resolve one native MATH measurement at the current nested scale
-- @param context table font and scale context
-- @param name string metric field
-- @param fallback number fallback pixels
-- @return number value scaled pixels
local function get_metric(context, name, fallback)
	local value = context.metrics and context.metrics[name]

	if type(value) == "number" and value > 0 then
		return value * context.scale
	end

	return fallback
end

--- Resolve one native MATH measurement that may be negative
-- @param context table font and scale context
-- @param name string metric field
-- @param fallback number fallback pixels
-- @return number value scaled pixels
local function get_signed_metric(context, name, fallback)
	local value = context.metrics and context.metrics[name]

	if type(value) == "number" then
		return value * context.scale
	end

	return fallback
end

--- Return the greatest safe overlap between two assembly parts
-- @param left table preceding assembly part
-- @param right table following assembly part
-- @param minimum_overlap number font-wide minimum connector overlap
-- @return number overlap maximum supported overlap
local function maximum_join_overlap(left, right, minimum_overlap)
	local left_connector = left.end_connector or minimum_overlap
	local right_connector = right.start_connector or minimum_overlap

	return math.max(minimum_overlap, math.min(left_connector, right_connector))
end

--- Return the visible size used to place one assembly part
-- @param part table font-provided assembly part
-- @return number advance visible width or height
local function assembly_part_advance(part)
	return part.assembly_advance or part.full_advance
end

--- Return the bitmap edge offset used to join one assembly part
-- @param part table font-provided assembly part
-- @return number offset visible edge from the glyph origin
local function assembly_part_offset(part)
	return part.assembly_offset or 0
end

--- Select visible bitmap measurements for one assembly direction
-- @param parts table font-provided assembly parts
-- @param vertical boolean true for vertical construction
local function prepare_assembly_axis(parts, vertical)
	for part_index = 1, #parts do
		local part = parts[part_index]
		part.assembly_advance = vertical and part.bitmap_height or part.bitmap_width
		part.assembly_offset = vertical and part.draw_offset_y or part.draw_offset_x

		if part.assembly_advance <= 0 then
			part.assembly_advance = part.full_advance
		end
	end
end

--- Measure the greatest size possible when every join uses its minimum overlap
-- @param parts table ordered assembly parts
-- @param minimum_overlap number font-wide minimum connector overlap
-- @return number advance greatest assembled width or height
local function maximum_assembly_size(parts, minimum_overlap)
	local advance = 0

	for part_index = 1, #parts do
		advance = advance + assembly_part_advance(parts[part_index])
	end

	return advance - math.max(0, #parts - 1) * minimum_overlap
end

--- Spread connector overlap reductions evenly until the assembly reaches its target
-- @param parts table ordered assembly parts
-- @param target_advance number requested assembled width or height
-- @param minimum_overlap number font-wide minimum connector overlap
-- @return table positions part positions
-- @return table overlaps solved overlap at every join
-- @return number advance final assembled width or height
local function solve_assembly(parts, target_advance, minimum_overlap)
	local total_advance = 0
	local overlaps = {}

	for part_index = 1, #parts do
		local part = parts[part_index]
		total_advance = total_advance + assembly_part_advance(part)

		if part_index < #parts then
			overlaps[part_index] = maximum_join_overlap(
				part,
				parts[part_index + 1],
				minimum_overlap
			)
		end
	end

	local advance = total_advance
	for overlap_index = 1, #overlaps do
		advance = advance - overlaps[overlap_index]
	end

	local remaining_growth = math.max(0, target_advance - advance)
	local epsilon = 0.000001

	while remaining_growth > epsilon do
		local active = {}

		for overlap_index = 1, #overlaps do
			if overlaps[overlap_index] > minimum_overlap + epsilon then
				active[#active + 1] = overlap_index
			end
		end
		if #active == 0 then
			break
		end

		local equal_reduction = remaining_growth / #active
		local actual_growth = 0

		for active_index = 1, #active do
			local overlap_index = active[active_index]
			local reduction = math.min(
				equal_reduction,
				overlaps[overlap_index] - minimum_overlap
			)
			overlaps[overlap_index] = overlaps[overlap_index] - reduction
			actual_growth = actual_growth + reduction
		end
		if actual_growth <= epsilon then
			break
		end

		remaining_growth = remaining_growth - actual_growth
	end

	local positions = { 0 }
	for part_index = 2, #parts do
		local previous = parts[part_index - 1]
		local current = parts[part_index]
		positions[part_index] = positions[part_index - 1]
			+ assembly_part_offset(previous)
			+ assembly_part_advance(previous)
			- assembly_part_offset(current)
			- overlaps[part_index - 1]
	end

	local final_advance = total_advance
	for overlap_index = 1, #overlaps do
		final_advance = final_advance - overlaps[overlap_index]
	end

	return positions, overlaps, final_advance
end

--- Expand every font-designated extender evenly until an assembly reaches its target
-- @param parts table fixed and extensible font parts
-- @param target_advance number requested assembled width or height
-- @param minimum_overlap number font-wide minimum connector overlap
-- @return table expanded ordered parts
local function expand_assembly(parts, target_advance, minimum_overlap)
	local extender_count = 0
	local repetitions = 0

	for part_index = 1, #parts do
		if parts[part_index].extender then
			extender_count = extender_count + 1
		end
	end

	--- Rebuild the recipe so repeated extenders remain in their font-defined positions
	-- @return table expanded ordered parts
	local function build_expanded()
		local expanded = {}

		for part_index = 1, #parts do
			local part = parts[part_index]
			local count = part.extender and repetitions or 1

			for _ = 1, count do
				expanded[#expanded + 1] = part
			end
		end

		return expanded
	end

	local expanded = build_expanded()
	while extender_count > 0
		and maximum_assembly_size(expanded, minimum_overlap) < target_advance
	do
		local previous_advance = maximum_assembly_size(expanded, minimum_overlap)
		repetitions = repetitions + 1
		expanded = build_expanded()

		-- Broken font connector data must not leave layout in an endless loop
		if maximum_assembly_size(expanded, minimum_overlap) <= previous_advance then
			break
		end
	end

	return expanded
end

--- Convert a font's bottom-to-top vertical recipe into drawing order
-- @param parts table font-provided vertical assembly parts
-- @return table parts same parts ordered from top to bottom
local function order_vertical_assembly(parts)
	for part_index = 1, #parts do
		local part = parts[part_index]
		part.start_connector, part.end_connector = part.end_connector, part.start_connector
	end
	for part_index = 1, math.floor(#parts / 2) do
		local opposite_index = #parts - part_index + 1
		parts[part_index], parts[opposite_index] = parts[opposite_index], parts[part_index]
	end

	return parts
end

--- Choose the smallest font-provided vertical variant that fits
-- @param text string source glyph
-- @param font userdata selected font
-- @param target_height number requested visual height
-- @param context table font and scale context
-- @return table box stretchable glyph box
local function stretchy_glyph_box(text, font, target_height, context)
	local shape = font:get_math_glyph_shape(text, true)
	local selected

	for variant_index = 1, #shape.variants do
		local variant = shape.variants[variant_index]

		if not selected or variant.advance > selected.advance then
			selected = variant
		end
		if variant.advance >= target_height then
			selected = variant
			break
		end
	end

	if not selected and #shape.parts == 0 then
		return text_box(text, font)
	end

	local axis_height = get_metric(context, "axis_height", context.unit * 0.25)
	if selected and (selected.advance >= target_height or #shape.parts == 0) then
		local height = math.max(font:get_height(), selected.advance)
		local baseline = height / 2 + axis_height
		local width = math.max(1, selected.bitmap_width, selected.width)
		local box = new_box(width, height, baseline)
		box.operations[1] = {
			kind = "glyph",
			glyph_id = selected.glyph_id,
			font = font,
			x = (width - selected.bitmap_width) / 2 - selected.draw_offset_x,
			y = (height - selected.bitmap_height) / 2 - selected.draw_offset_y,
		}
		include_ink(
			box,
			(width - selected.bitmap_width) / 2,
			(height - selected.bitmap_height) / 2,
			selected.bitmap_width,
			selected.bitmap_height
		)
		box.advance = selected.width
		box.script_clearance = context.unit * 0.06
		box.attachment = {
			text = text,
			font = font,
			italics_correction = font:get_math_glyph_italics_correction(text),
			top_accent = font:get_math_glyph_top_accent_attachment(text),
		}
		-- Outline geometry avoids anchoring rules to the rounded bitmap fringe
		box.outline_top = box.operations[1].y + selected.outline_top

		return box
	end

	-- Connector-aware overlaps keep adjoining raster pieces visually continuous
	local minimum_overlap = math.max(0, shape.minimum_connector_overlap)
	local parts = order_vertical_assembly(shape.parts)
	prepare_assembly_axis(parts, true)
	local expanded = expand_assembly(
		parts,
		target_height,
		minimum_overlap
	)
	local positions, _, height = solve_assembly(
		expanded,
		target_height,
		minimum_overlap
	)
	local ink_left = math.huge
	local ink_top = math.huge
	local ink_right = -math.huge
	local ink_bottom = -math.huge
	local advance = 1

	for part_index = 1, #expanded do
		local part = expanded[part_index]
		local position = positions[part_index]
		ink_left = math.min(ink_left, part.draw_offset_x)
		ink_top = math.min(ink_top, position + part.draw_offset_y)
		ink_right = math.max(
			ink_right,
			part.draw_offset_x + part.bitmap_width
		)
		ink_bottom = math.max(
			ink_bottom,
			position + part.draw_offset_y + part.bitmap_height
		)
		advance = math.max(advance, part.width)
	end

	-- Every part keeps the same glyph origin so its connector stays aligned
	local width = math.max(1, ink_right - ink_left)
	height = math.max(1, ink_bottom - ink_top)
	local baseline = height / 2 + axis_height
	local box = new_box(width, height, baseline)
	for part_index = 1, #expanded do
		local part = expanded[part_index]
		local position = positions[part_index]
		box.operations[#box.operations + 1] = {
			kind = "glyph",
			glyph_id = part.glyph_id,
			font = font,
			x = -ink_left,
			y = position - ink_top,
		}
		include_ink(
			box,
			part.draw_offset_x - ink_left,
			position + part.draw_offset_y - ink_top,
			part.bitmap_width,
			part.bitmap_height
		)

	end
	box.advance = advance
	box.script_clearance = context.unit * 0.06
	box.attachment = {
		text = text,
		font = font,
		italics_correction = font:get_math_glyph_italics_correction(text),
		top_accent = font:get_math_glyph_top_accent_attachment(text),
	}

	return box
end

--- Lay out one horizontal accent using the font's variants or assembly
-- @param text string accent glyph
-- @param font userdata selected font
-- @param target_width number requested visual width
-- @param stretchy boolean allow an extensible shape
-- @return table box accent glyph box
local function horizontal_glyph_box(text, font, target_width, stretchy)
	local shape = font:get_math_glyph_shape(text, false)
	local selected = shape.base

	if stretchy then
		for variant_index = 1, #shape.variants do
			local variant = shape.variants[variant_index]

			if not selected or variant.advance > selected.advance then
				selected = variant
			end
			if variant.advance >= target_width then
				selected = variant
				break
			end
		end
	end

	if not selected and #shape.parts == 0 then
		return text_box(text, font)
	end
	if selected
		and (not stretchy or selected.advance >= target_width or #shape.parts == 0)
	then
		local width = math.max(1, selected.advance, selected.bitmap_width)
		local height = math.max(1, selected.bitmap_height)
		local box = new_box(width, height, height)
		box.operations[1] = {
			kind = "glyph",
			glyph_id = selected.glyph_id,
			font = font,
			x = (width - selected.bitmap_width) / 2 - selected.draw_offset_x,
			y = -selected.draw_offset_y,
		}
		include_ink(
			box,
			(width - selected.bitmap_width) / 2,
			0,
			selected.bitmap_width,
			selected.bitmap_height
		)
		box.attachment = {
			text = text,
			font = font,
			italics_correction = 0,
			top_accent = font:get_math_glyph_top_accent_attachment(text),
		}

		return box
	end

	-- Connector-aware overlaps remove seams between brace and arrow pieces
	local minimum_overlap = math.max(0, shape.minimum_connector_overlap)
	prepare_assembly_axis(shape.parts, false)
	local expanded = expand_assembly(shape.parts, target_width, minimum_overlap)
	local positions, _, width = solve_assembly(
		expanded,
		target_width,
		minimum_overlap
	)
	local ink_left = math.huge
	local ink_top = math.huge
	local ink_right = -math.huge
	local ink_bottom = -math.huge

	for part_index = 1, #expanded do
		local part = expanded[part_index]
		local position = positions[part_index]
		ink_left = math.min(ink_left, position + part.draw_offset_x)
		ink_top = math.min(ink_top, part.draw_offset_y)
		ink_right = math.max(
			ink_right,
			position + part.draw_offset_x + part.bitmap_width
		)
		ink_bottom = math.max(
			ink_bottom,
			part.draw_offset_y + part.bitmap_height
		)
	end

	-- Every part keeps the same glyph origin so its connector stays aligned
	width = math.max(1, ink_right - ink_left)
	local height = math.max(1, ink_bottom - ink_top)
	local box = new_box(width, height, height)
	for part_index = 1, #expanded do
		local part = expanded[part_index]
		local position = positions[part_index]
		box.operations[#box.operations + 1] = {
			kind = "glyph",
			glyph_id = part.glyph_id,
			font = font,
			x = position - ink_left,
			y = -ink_top,
		}
		include_ink(
			box,
			position + part.draw_offset_x - ink_left,
			part.draw_offset_y - ink_top,
			part.bitmap_width,
			part.bitmap_height
		)

	end

	return box
end

--- Return the gap between two normalized TeX atom classes
-- @param previous_class string class on the left
-- @param current_class string class on the right
-- @param unit number current font height
-- @return number spacing pixels
local function class_spacing(previous_class, current_class, unit)
	if previous_class == "binary" or current_class == "binary" then
		return unit * 0.16
	elseif previous_class == "relation" or current_class == "relation" then
		return unit * 0.22
	elseif previous_class == "operator" or current_class == "operator" then
		return unit * 0.12
	elseif previous_class == "punctuation" then
		return unit * 0.1
	end

	return 0
end

--- Return one spacing class through groups that do not draw their own atom
-- @param node table parsed node
-- @param from_start boolean read the first rather than last visible atom
-- @return string class mathematical spacing class
local function boundary_math_class(node, from_start)
	if node.math_class then
		return node.math_class
	elseif node.kind == "sequence" then
		local child = from_start and node.children[1] or node.children[#node.children]

		return child and boundary_math_class(child, from_start) or "ordinary"
	elseif node.kind == "delimited" then
		local delimiter = from_start and node.left or node.right

		return delimiter and delimiter ~= "" and (from_start and "open" or "close") or "ordinary"
	elseif node.kind == "color" or node.kind == "math_style"
		or node.kind == "styled" or node.kind == "text_styled"
	then
		return boundary_math_class(node.body, from_start)
	end

	return "ordinary"
end

--- Lay out children on one shared baseline
-- @param children table parsed children
-- @param context table font and scale context
-- @return table box combined row
local function build_sequence(children, context)
	local boxes = {}
	local baseline = 0
	local descent = 0
	local width = 0

	for child_index = 1, #children do
		local child_box = build_node(children[child_index], context)
		local previous = children[child_index - 1]
		local current = children[child_index]
		local previous_box = boxes[#boxes]
		local spacing = previous_box
			and previous_box.trailing_italics_correction or 0
		local next_node = children[child_index + 1]
		if previous_box and current.kind == "space"
			and child_box.width < 0
			and next_node
			and boundary_math_class(next_node, true) == "open"
		then
			local previous_ink = visible_ink(previous_box)
			local right_overhang = math.max(
				0,
				previous_ink.x + previous_ink.width - previous_box.width
			)

			-- Tight spacing before a delimiter must not cross the preceding visible edge
			spacing = math.max(
				spacing,
				right_overhang - child_box.width
			)
			-- The following delimiter adds its own left bearing to this final gap
			child_box.opening_clearance = context.unit * 0.03
		end

		if previous and current then
			local previous_class = boundary_math_class(previous, false)
			local current_class = boundary_math_class(current, true)

			if previous.kind ~= "space" and current.kind ~= "space" then
				spacing = spacing
					+ class_spacing(previous_class, current_class, context.unit)
			end
			if current_class == "open" and previous_box.opening_clearance then
				local previous_ink = visible_ink(previous_box)
				local current_ink = visible_ink(child_box)
				local previous_overhang = previous.kind == "space" and 0
					or previous_ink.x + previous_ink.width - previous_box.width
				local visible_gap = previous_box.opening_clearance
					+ previous_overhang - current_ink.x

				-- Include both glyph overhangs when separating a script and delimiter
				spacing = math.max(spacing, visible_gap)
			end
			if previous_class == "close" and current.kind == "scripts" then
				local previous_ink = visible_ink(previous_box)
				local current_ink = visible_ink(child_box)
				local previous_overhang = previous_ink.x + previous_ink.width
					- previous_box.width
				local visible_gap = context.unit * 0.05
					+ previous_overhang - current_ink.x

				-- A scripted letter after a closing delimiter must clear both visible edges
				spacing = math.max(spacing, visible_gap)
			end
		end

		boxes[#boxes + 1] = child_box
		boxes[#boxes].leading_space = spacing
		baseline = math.max(baseline, child_box.baseline)
		descent = math.max(descent, child_box.height - child_box.baseline)
		width = width + spacing + child_box.width
	end

	local box = new_box(width, baseline + descent, baseline)
	local x = 0

	for child_index = 1, #boxes do
		local child_box = boxes[child_index]
		x = x + child_box.leading_space
		append_box(box, child_box, x, baseline - child_box.baseline)
		x = x + child_box.width
	end
	if #boxes == 1 then
		-- A single grouped atom keeps metadata needed by accents and table spans
		box.attachment = boxes[1].attachment
		box.column_span = boxes[1].column_span
		box.column_alignment = boxes[1].column_alignment
	end
	-- Nested groups pass the final italic letter's clearance to outside content
	box.trailing_italics_correction = boxes[#boxes]
		and boxes[#boxes].trailing_italics_correction or 0

	return box
end

--- Lay out already measured boxes on one shared baseline
-- @param boxes table measured boxes in display order
-- @return table box combined row
local function build_box_row(boxes)
	local baseline = 0
	local descent = 0
	local width = 0

	for box_index = 1, #boxes do
		local child = boxes[box_index]
		baseline = math.max(baseline, child.baseline)
		descent = math.max(descent, child.height - child.baseline)
		width = width + child.width
	end

	local box = new_box(width, baseline + descent, baseline)
	local x = 0

	for box_index = 1, #boxes do
		local child = boxes[box_index]
		append_box(box, child, x, baseline - child.baseline)
		x = x + child.width
	end

	return box
end

--- Lay out a row environment with shared column widths
-- @param node table parsed row environment
-- @param context table font and scale context
-- @return table box row environment box
local function build_rows(node, context)
	local rows = {}
	local column_widths = {}
	local width = 0

	for row_index = 1, #node.rows do
		local cells = {}
		local column_index = 1

		for cell_index = 1, #node.rows[row_index] do
			local cell = build_node(node.rows[row_index][cell_index], context)
			local column_span = math.max(1, cell.column_span or 1)
			cell.column_start = column_index
			cells[cell_index] = cell

			for spanned_column = column_index, column_index + column_span - 1 do
				column_widths[spanned_column] = column_widths[spanned_column] or 0
			end
			if column_span == 1 then
				column_widths[column_index] = math.max(column_widths[column_index], cell.width)
			end
			column_index = column_index + column_span
		end
		rows[row_index] = cells
	end

	local shared_columns = node.alignment == "aligned"
		or node.alignment == "matrix"
		or node.alignment == "array"
	-- CD keeps a compact half-em gap around its fixed-width connectors
	local column_gap = node.environment == "CD"
		and context.unit * 0.5
		or node.alignment == "aligned"
		and context.unit * 0.22
		or (node.alignment == "matrix" or node.alignment == "array")
			and context.unit
		or 0
	local outer_padding = not node.compact_rows
		and (node.alignment == "matrix" or node.alignment == "array")
		and context.unit * 0.4
		or 0

	-- Spanning cells widen their covered columns only when the current space is too small
	for row_index = 1, #rows do
		for cell_index = 1, #rows[row_index] do
			local cell = rows[row_index][cell_index]
			local column_span = math.max(1, cell.column_span or 1)
			local available = column_gap * (column_span - 1)

			for column_index = cell.column_start, cell.column_start + column_span - 1 do
				available = available + column_widths[column_index]
			end
			local extra = math.max(0, cell.width - available) / column_span

			for column_index = cell.column_start, cell.column_start + column_span - 1 do
				column_widths[column_index] = column_widths[column_index] + extra
			end
		end
	end

	for column_index = 1, #column_widths do
		width = width + column_widths[column_index]

		if column_index < #column_widths then
			width = width + column_gap
		end
	end

	local row_gap = context.unit
		* (node.compact_rows and 0.12 or 0.28)
		* (node.row_stretch or 1)
	local shared_width = width + outer_padding * 2
	local row_boxes = {}
	local height = 0

	for row_index = 1, #rows do
		local cells = rows[row_index]
		local baseline = 0
		local descent = 0

		for cell_index = 1, #cells do
			baseline = math.max(baseline, cells[cell_index].baseline)
			descent = math.max(
				descent,
				cells[cell_index].height - cells[cell_index].baseline
			)
		end

		local row_width = shared_columns and shared_width or 0
		local row_box = new_box(row_width, baseline + descent, baseline)
		local x = outer_padding

		for cell_index = 1, #cells do
			local cell = cells[cell_index]
			local cell_x = x
			local column_span = math.max(1, cell.column_span or 1)
			local cell_width = column_gap * (column_span - 1)

			for column_index = cell.column_start, cell.column_start + column_span - 1 do
				cell_width = cell_width + column_widths[column_index]
			end
			local cell_alignment = node.column_alignments
				and node.column_alignments[cell.column_start]
				or node.default_column_alignment
			cell_alignment = cell.column_alignment or cell_alignment

			if node.alignment == "aligned" and cell.column_start % 2 == 1 then
				cell_x = cell_x + cell_width - cell.width
			elseif node.alignment == "matrix" or cell_alignment == "center" then
				cell_x = cell_x + (cell_width - cell.width) / 2
			elseif cell_alignment == "right" then
				cell_x = cell_x + cell_width - cell.width
			end
			append_box(row_box, cell, cell_x, baseline - cell.baseline)
			x = x + cell_width + column_gap
		end
		if not shared_columns then
			row_box.width = x - column_gap
		end

		row_boxes[row_index] = row_box
		width = math.max(width, row_box.width)
		height = height + row_box.height

		if row_index < #rows then
			height = height + row_gap
				+ context.unit * (node.row_gaps and node.row_gaps[row_index] or 0)
		end
	end

	local axis_height = get_metric(context, "axis_height", context.unit * 0.25)
	local tags = {}
	local tag_width = 0

	for row_index = 1, #row_boxes do
		local tag_text = node.tags and node.tags[row_index]
		local tag_starred = node.tag_stars and node.tag_stars[row_index]

		if not tag_text and node.tag and row_index == #row_boxes then
			tag_text = node.tag
			tag_starred = node.tag_star
		end
		if tag_text then
			tags[row_index] = text_box(
				tag_starred and tag_text or "(" .. tag_text .. ")",
				context:font_for_style("mathrm")
			)
			tag_width = math.max(tag_width, tags[row_index].width)
		end
	end

	local tag_side = tag_width > 0 and tag_width + context.unit or 0
	local full_width = context.display_width
		and (node.environment == "align"
			or node.environment == "align*"
			or node.environment == "alignat"
			or node.environment == "alignat*"
			or node.environment == "gather"
			or node.environment == "gather*"
			or node.environment == "multline"
			or node.environment == "equation"
			or node.environment == "equation*")
	local box_width = full_width
		and math.max(context.display_width, width + tag_side * 2)
		or width + tag_side * 2
	local content_left = full_width and 0 or tag_side
	local content_width = full_width and box_width or width
	local box = new_box(box_width, height, height / 2 + axis_height)
	local row_tops = {}
	local y = 0

	for row_index = 1, #row_boxes do
		local row = row_boxes[row_index]
		local tag = tags[row_index]
		local x = content_left + (content_width - row.width) / 2
		row_tops[row_index] = y

		if node.alignment == "multline" and #row_boxes > 1 then
			local right_space = full_width and tag and tag_side or 0
			x = content_left
				+ (row_index == 1 and 0 or content_width - right_space - row.width)
		end
		append_box(box, row, x, y)
		if tag then
			append_box(box, tag, box.width - tag.width, y + row.baseline - tag.baseline)
		end
		y = y + row.height + row_gap
			+ context.unit * (node.row_gaps and node.row_gaps[row_index] or 0)
	end

	local rule_width = math.max(
		1,
		get_metric(context, "fraction_rule_thickness", context.unit * 0.055)
	)
	local table_x = content_left + (content_width - shared_width) / 2
	local boundary_x = table_x + outer_padding

	-- Column rules share the ordinary inter-column gap instead of widening the table
	for column_index = 1, #column_widths - 1 do
		boundary_x = boundary_x + column_widths[column_index]
		local count = node.column_rules and node.column_rules[column_index] or 0

		for rule_index = 1, count do
			local rule_x = boundary_x + column_gap / 2
				+ (rule_index - (count + 1) / 2) * rule_width * 2
			append_line(box, rule_x, 0, rule_width, box.height)
		end
		if node.column_rule_styles
			and node.column_rule_styles[column_index] == "dashed"
		then
			append_dashed_vertical_line(
				box,
				boundary_x + column_gap / 2,
				0,
				box.height,
				rule_width
			)
		end
		boundary_x = boundary_x + column_gap
	end

	local boundary_y = 0
	for row_index = 1, #row_boxes do
		local row = row_boxes[row_index]
		boundary_y = boundary_y + row.height
		local rule = node.row_rules and node.row_rules[row_index]

		if rule then
			local gap = row_gap
				+ context.unit * (node.row_gaps and node.row_gaps[row_index] or 0)
			local rule_y = boundary_y + gap / 2 - rule_width / 2
			local following = row_boxes[row_index + 1]

			if following then
				local ink = visible_ink(row)
				local following_ink = visible_ink(following)
				local ink_bottom = row_tops[row_index] + ink.y + ink.height
				local following_top = row_tops[row_index + 1] + following_ink.y

				-- Center rules in the visible gap, then lift their heavy lower edge optically
				rule_y = (ink_bottom + following_top - rule_width) / 2
					- context.unit * 0.07
			end

			local rule_x = table_x
			local rule_length = shared_width
			local range = node.row_rules.ranges and node.row_rules.ranges[row_index]

			if range then
				rule_x = table_x + outer_padding
				for column_index = 1, range.first - 1 do
					rule_x = rule_x + column_widths[column_index] + column_gap
				end
				rule_length = 0
				for column_index = range.first, math.min(range.last, #column_widths) do
					rule_length = rule_length + column_widths[column_index]
						+ (column_index < range.last and column_gap or 0)
				end
			end

			if rule == "dashed" then
				append_dashed_line(box, rule_x, rule_y, rule_length, rule_width)
			else
				append_line(box, rule_x, rule_y, rule_length, rule_width)
			end
		end
		boundary_y = boundary_y + row_gap
			+ context.unit * (node.row_gaps and node.row_gaps[row_index] or 0)
	end

	if (node.alignment == "matrix" or node.alignment == "array") and box.ink then
		local cropped_width = node.alignment == "matrix"
			and box.ink.width
			or box.width
		local cropped = new_box(
			cropped_width,
			box.ink.height,
			box.ink.height / 2 + axis_height
		)
		local cropped_x = node.alignment == "matrix"
			and -box.ink.x
			or 0

		-- Matrix alignment and padding follow visible entries instead of font line bounds
		append_box(cropped, box, cropped_x, -box.ink.y)

		return cropped
	end

	return box
end

--- Measure equal room above and below the math axis for one delimiter pair
-- @param content table measured delimiter body
-- @param context table font and scale context
-- @return number height required visual delimiter height
local function required_delimiter_height(content, context)
	local ink = visible_ink(content)
	local axis = content.baseline
		- get_metric(context, "axis_height", context.unit * 0.25)
	local upper_extent = axis - ink.y
	local lower_extent = ink.y + ink.height - axis

	return math.max(upper_extent, lower_extent) * 2 + context.unit * 0.08
end

--- Lay out paired automatic delimiters around their complete measured content
-- @param node table delimited node
-- @param context table font and scale context
-- @return table box delimited box
local function build_delimited(node, context)
	local body
	local target_height

	if node.segments then
		local segment_boxes = {}

		for segment_index = 1, #node.segments do
			segment_boxes[segment_index] = build_node(node.segments[segment_index], context)
		end

		local content = build_box_row(segment_boxes)
		local body_boxes = {}
		local middle_gap = context.unit * 0.22
		target_height = required_delimiter_height(content, context)

		for segment_index = 1, #segment_boxes do
			body_boxes[#body_boxes + 1] = segment_boxes[segment_index]

			local middle = node.middles[segment_index]

			if middle and middle ~= "" then
				body_boxes[#body_boxes + 1] = new_box(middle_gap, 0, 0)
				body_boxes[#body_boxes + 1] = stretchy_glyph_box(
					middle,
					context.font,
					target_height,
					context
				)
				body_boxes[#body_boxes + 1] = new_box(middle_gap, 0, 0)
			end
		end
		body = build_box_row(body_boxes)
	else
		body = build_node(node.body, context)
		target_height = required_delimiter_height(body, context)
	end

	local boxes = {}
	-- Visible delimiter gaps stay independent from cropped font side bearings
	local delimiter_gap = context.unit * (node.matrix_environment and 0.18 or 0.08)

	if node.left and node.left ~= "" then
		boxes[#boxes + 1] = stretchy_glyph_box(
			node.left,
			context.font,
			target_height,
			context
		)
		boxes[#boxes + 1] = new_box(delimiter_gap, 0, 0)
	end
	boxes[#boxes + 1] = body
	if node.right and node.right ~= "" then
		boxes[#boxes + 1] = new_box(delimiter_gap, 0, 0)
		boxes[#boxes + 1] = stretchy_glyph_box(
			node.right,
			context.font,
			target_height,
			context
		)
	end

	local box = build_box_row(boxes)
	if node.matrix_environment then
		local side_bearing = context.unit * 0.08
		local spaced = new_box(
			box.width + side_bearing * 2,
			box.height,
			box.baseline
		)

		-- Matrix delimiters need a little air when several matrices touch
		append_box(spaced, box, side_bearing, 0)

		return spaced
	end

	return box
end

--- Lay out one fraction around a centered rule
-- @param node table fraction node
-- @param context table font and scale context
-- @return table box fraction box
local function build_fraction(node, context)
	local small_context = context:scaled(0.82)
	local numerator = build_node(node.numerator, small_context)
	local denominator = build_node(node.denominator, small_context)
	local numerator_ink = visible_ink(numerator)
	local denominator_ink = visible_ink(denominator)
	local padding = context.unit * 0.22
	local side_bearing = context.unit * 0.06
	local comfortable_gap = context.unit * 0.16
	local requested_rule_width = node.rule_width and node.rule_width
		* (node.rule_unit == "pt" and context.unit / 10 or context.unit)
	local rule_width = requested_rule_width or math.max(
		1,
		get_metric(context, "fraction_rule_thickness", context.unit * 0.055)
	)
	-- Explicit thick rules keep one matching rule width of clear space on each side
	local rule_clearance = requested_rule_width and rule_width or comfortable_gap
	local numerator_gap = math.max(
		comfortable_gap,
		rule_clearance,
		get_metric(
			context,
			"fraction_numerator_gap",
			comfortable_gap
		)
	)
	local denominator_gap = math.max(
		comfortable_gap,
		rule_clearance,
		get_metric(
			context,
			"fraction_denominator_gap",
			comfortable_gap
		)
	)
	local width = math.max(numerator.width, denominator.width)
		+ padding * 2 + side_bearing * 2
	local axis_height = get_metric(context, "axis_height", context.unit * 0.25)
	local rule_top = -axis_height - rule_width / 2
	local numerator_bottom = rule_top - numerator_gap
	local numerator_y = numerator_bottom - numerator_ink.y - numerator_ink.height
	local denominator_top = rule_top + rule_width + denominator_gap
	local denominator_y = denominator_top - denominator_ink.y
	local top = math.min(numerator_y, -context.font:get_baseline())
	local bottom = math.max(
		denominator_y + denominator.height,
		context.font:get_height() - context.font:get_baseline()
	)
	local baseline = -top
	local box = new_box(width, bottom - top, baseline)

	-- Native fraction gaps are measured from the visible glyph edges to the rule
	append_box(box, numerator, (width - numerator.width) / 2, numerator_y - top)
	if not node.line_hidden then
		-- Side bearings keep neighboring fraction rules visibly separate
		append_line(
			box,
			side_bearing,
			rule_top - top,
			width - side_bearing * 2,
			rule_width
		)
	end
	append_box(box, denominator, (width - denominator.width) / 2, denominator_y - top)

	return box
end

--- Lay out one padded frame around measured content
-- @param node table frame node
-- @param context table font and scale context
-- @return table box framed box
local function build_frame(node, context)
	local body = build_node(node.body, context)
	local body_ink = visible_ink(body)
	local padding = node.padding and node.padding * context.scale or context.unit * 0.22
	local rule_width = node.rule_width and node.rule_width * context.scale or math.max(
		1,
		get_metric(context, "fraction_rule_thickness", context.unit * 0.055) * 0.5
	)
	local draw_frame = node.draw_frame ~= false
	local inset = (draw_frame and rule_width or 0) + padding
	local body_x = inset - body_ink.x
	local body_y = inset - body_ink.y
	local width = body_ink.width + inset * 2
	local height = body_ink.height + inset * 2
	local box = new_box(width, height, body.baseline + body_y)
	local background = node.background and context.resolve_color(node.background)
	local frame_color = node.frame_color and context.resolve_color(node.frame_color)

	-- Center the visible pixels rather than the font's uneven invisible line space
	if background then
		append_line(box, 0, 0, width, height, background)
	end
	append_box(box, body, body_x, body_y)
	if draw_frame then
		append_line(box, 0, 0, width, rule_width, frame_color)
		append_line(box, 0, height - rule_width, width, rule_width, frame_color)
		append_line(box, 0, rule_width, rule_width, height - rule_width * 2, frame_color)
		append_line(
			box,
			width - rule_width,
			rule_width,
			rule_width,
			height - rule_width * 2,
			frame_color
		)
	end

	return box
end

--- Lay out one supported enclosure around measured content
-- @param node table enclosure mode and body
-- @param context table font and scale context
-- @return table box enclosed body
local function build_enclose(node, context)
	local body = build_node(node.body, context)

	if node.mode == "actuarial" then
		local padding = context.unit * 0.08
		local rule_width = math.max(
			1,
			get_metric(context, "fraction_rule_thickness", context.unit * 0.055)
		)
		local box = new_box(
			body.width + padding + rule_width,
			body.height + padding + rule_width,
			body.baseline + padding + rule_width
		)

		-- Small inner space keeps both rules clear of the letter
		append_box(box, body, 0, padding + rule_width)
		append_line(box, 0, 0, box.width, rule_width)
		append_line(box, box.width - rule_width, 0, rule_width, box.height)

		return box
	elseif node.mode ~= "longdiv" then
		return body
	end

	local ink = visible_ink(body)
	local padding = context.unit * 0.12
	local entry_space = context.unit * 0.12
	local hook_width = context.unit * 0.16
	local rule_width = math.max(
		1,
		get_metric(context, "fraction_rule_thickness", context.unit * 0.055)
	)
	local rule_start = entry_space + hook_width
	local body_x = rule_start + padding - ink.x
	local body_y = padding + rule_width - ink.y
	local width = body_x + ink.x + ink.width + padding
	local height = ink.height + padding * 2 + rule_width
	local box = new_box(width, height, body.baseline + body_y)

	-- The sloped entry stroke leaves the divisor clear and joins the top rule
	append_box(box, body, body_x, body_y)
	append_diagonal(box, entry_space, height - rule_width, rule_start, 0, rule_width)
	append_line(box, rule_start, 0, width - rule_start, rule_width)

	return box
end

--- Lay out one vertical CD arrow with labels beside its shaft
-- @param node table arrow body and optional side labels
-- @param context table font and scale context
-- @return table box centered arrow and labels
local function build_cd_vertical_arrow(node, context)
	-- CD uses a large vertical symbol so both diagram rows remain separated
	local arrow = stretchy_glyph_box(
		node.body.text,
		context.font,
		context.unit * 1.8,
		context
	)
	local script_scale = context.metrics and context.metrics.script_scale
		or context.script_scale
	local label_context = context:scaled(script_scale)
	local left = node.left and build_node(node.left, label_context)
	local right = node.right and build_node(node.right, label_context)
	local left_ink = left and visible_ink(left)
	local right_ink = right and visible_ink(right)
	local label_width = math.max(
		left and left.width or 0,
		right and right.width or 0
	)
	local label_gap = context.unit * CD_LABEL_GAP
	local side = label_width > 0 and label_width + label_gap or 0
	local baseline = math.max(
		arrow.baseline,
		left and left.baseline or 0,
		right and right.baseline or 0
	)
	local descent = math.max(
		arrow.height - arrow.baseline,
		left and left.height - left.baseline or 0,
		right and right.height - right.baseline or 0
	)
	local box = new_box(arrow.width + side * 2, baseline + descent, baseline)
	local shaft_x = side + arrow.width / 2

	if left then
		-- Preserve the visible side distance while measuring from the actual shaft
		append_box(
			box,
			left,
			shaft_x - label_gap - left_ink.x - left_ink.width,
			baseline - left.baseline
		)
	end
	append_box(box, arrow, side, baseline - arrow.baseline)
	if right then
		append_box(
			box,
			right,
			shaft_x + label_gap - right_ink.x,
			baseline - right.baseline
		)
	end

	return box
end

--- Lay out the long double rule used by '@=' in a CD diagram
-- @param context table font and scale context
-- @return table box centered double rule
local function build_cd_equal(context)
	local reference = text_box("=", context.font)
	local width = context.unit * 3
	local thickness = math.max(
		1,
		get_metric(context, "fraction_rule_thickness", context.unit * 0.055)
	)
	local axis_height = get_metric(context, "axis_height", context.unit * 0.25)
	local center = reference.baseline - axis_height
	local separation = context.unit * 0.16
	local box = new_box(width, reference.height, reference.baseline)

	-- Equal rules share one center so the symbol stays on the math axis
	append_line(box, 0, center - separation / 2 - thickness, width, thickness)
	append_line(box, 0, center + separation / 2, width, thickness)

	return box
end

--- Draw one full-width horizontal CD arrow
-- @param direction string left or right
-- @param width number complete arrow width
-- @param context table font and scale context
-- @return table box centered arrow
local function build_cd_horizontal_arrow(direction, width, context)
	local reference = text_box("→", context.font)
	local thickness = math.max(
		1,
		get_metric(context, "fraction_rule_thickness", context.unit * 0.055)
	)
	local axis_height = get_metric(context, "axis_height", context.unit * 0.25)
	local center = reference.baseline - axis_height
	local head_width = context.unit * 0.22
	local head_height = context.unit * 0.14
	local tip = direction == "left" and 0 or width
	local neck = direction == "left" and head_width or width - head_width
	local box = new_box(width, reference.height, reference.baseline)
	-- Connector bounds let labels ignore the font box's invisible top and bottom space
	box.connector_top = center - thickness / 2
	box.connector_bottom = center + thickness / 2

	-- The shaft spans the complete CD connector instead of centering a short glyph
	append_line(box, 0, center - thickness / 2, width, thickness)
	append_diagonal(box, neck, center - head_height, tip, center, thickness)
	append_diagonal(box, neck, center + head_height, tip, center, thickness)

	return box
end

--- Lay out superscript and subscript beside a base
-- @param node table scripts node
-- @param context table font and scale context
-- @return table box scripted box
local function build_scripts(node, context)
	local base = build_node(node.base, context)
	local base_ink = visible_ink(base)
	local script_scale = context.metrics and context.metrics.script_scale
		or context.script_scale
	local script_context = context:scaled(script_scale)
	local prime_script = node.superscript
		and node.superscript.kind == "sequence"
		and #node.superscript.children == 1
		and node.superscript.children[1].command == "prime"
	local superscript_context = prime_script
		and context
		or script_context
	local superscript = node.superscript
		and build_node(node.superscript, superscript_context)
	local subscript = node.subscript and build_node(node.subscript, script_context)

	if (context.display_mode and node.display_limits) or node.stacked_scripts then
		local fallback_gap = context.unit * context.display_limit_gap_scale
		local upper_gap = math.max(
			fallback_gap,
			get_metric(context, "upper_limit_gap", fallback_gap)
		)
		local lower_gap = math.max(
			fallback_gap,
			get_metric(context, "lower_limit_gap", fallback_gap)
		)
		local upper_rise = get_metric(
			context,
			"upper_limit_baseline_rise",
			context.unit * 0.6
		)
		local lower_drop = get_metric(
			context,
			"lower_limit_baseline_drop",
			context.unit * 0.6
		)
		local center = base_ink.x + base_ink.width / 2
		local italics_correction = base.attachment
			and base.attachment.italics_correction
			or 0
		local superscript_x = 0
		local superscript_y = 0
		local subscript_x = 0
		local subscript_y = 0
		local left = 0
		local top = 0
		local right = base.width
		local bottom = base.height

		-- Center limits on visible symbols and measure gaps from visible pixels
		if superscript then
			local superscript_ink = visible_ink(superscript)
			superscript_x = center + italics_correction / 2
				- superscript_ink.x
				- superscript_ink.width / 2
			superscript_y = math.min(
				base.baseline - upper_rise - superscript.baseline,
				base_ink.y - upper_gap
					- superscript_ink.y - superscript_ink.height
			)
			left = math.min(left, superscript_x)
			top = math.min(top, superscript_y)
			right = math.max(right, superscript_x + superscript.width)
		end
		if subscript then
			local subscript_ink = visible_ink(subscript)
			subscript_x = center - italics_correction / 2
				- subscript_ink.x - subscript_ink.width / 2
			subscript_y = math.max(
				base.baseline + lower_drop - subscript.baseline,
				base_ink.y + base_ink.height + lower_gap - subscript_ink.y
			)
			left = math.min(left, subscript_x)
			right = math.max(right, subscript_x + subscript.width)
			bottom = math.max(bottom, subscript_y + subscript.height)
		end

		local box = new_box(right - left, bottom - top, base.baseline - top)

		if superscript then
			append_box(box, superscript, superscript_x - left, superscript_y - top)
		end
		append_box(box, base, -left, -top)
		if subscript then
			append_box(box, subscript, subscript_x - left, subscript_y - top)
		end

		return box
	end

	local script_width = math.max(
		superscript and superscript.width or 0,
		subscript and subscript.width or 0
	)
	local superscript_baseline = -get_metric(
		context,
		"superscript_shift_up",
		context.unit * 0.55
	)
	local tall_base_shift = base.baseline - base_ink.y - context.unit * 0.35
	superscript_baseline = math.min(superscript_baseline, -tall_base_shift)
	if superscript and base.script_clearance then
		-- Lift upper integral limits just above the sign's top end
		superscript_baseline = superscript_baseline - context.unit * 0.08
	end
	local superscript_bottom_min = get_metric(
		context,
		"superscript_bottom_min",
		context.unit * 0.12
	)
	if superscript then
		superscript_baseline = math.min(
			superscript_baseline,
			-superscript_bottom_min - (superscript.height - superscript.baseline)
		)
		if prime_script then
			local prime_ink = visible_ink(superscript)
			local prime_bottom = base_ink.y - base.baseline
				+ base_ink.height * 0.55

			-- Prime marks overlap the upper shoulder instead of floating above it
			superscript_baseline = prime_bottom
				+ superscript.baseline - prime_ink.y - prime_ink.height
		end
	end
	local subscript_baseline = get_metric(
		context,
		"subscript_shift_down",
		context.unit * 0.3
	)
	if subscript and base.script_clearance then
		local subscript_ink = visible_ink(subscript)
		local base_bottom = base_ink.y + base_ink.height - base.baseline
		local bottom_aligned_baseline = base_bottom
			+ subscript.baseline - subscript_ink.y - subscript_ink.height

		-- Drop side limits to the lower end of a tall integral sign
		subscript_baseline = math.max(
			subscript_baseline,
			bottom_aligned_baseline
		)
	end
	local top = -base.baseline
	local bottom = base.height - base.baseline

	if superscript then
		top = math.min(top, superscript_baseline - superscript.baseline)
	end
	if subscript then
		bottom = math.max(
			bottom,
			subscript_baseline + subscript.height - subscript.baseline
		)
	end

	local baseline = -top
	local script_clearance = base.script_clearance or 0
	local superscript_x = base.width + script_clearance
	local subscript_x = base.width + script_clearance

	if base.attachment then
		local attachment = base.attachment
		local italics_correction = attachment.italics_correction or 0
		if superscript then
			local script_bottom = superscript_baseline
				+ superscript.height - superscript.baseline
			superscript_x = superscript_x
				+ (base.script_clearance and 0 or italics_correction)
				+ attachment.font:get_math_glyph_kerning(
					attachment.text,
					0,
					-script_bottom
				)
			if prime_script then
				local prime_ink = visible_ink(superscript)

				-- Place visible prime ink just beyond the visible letter edge
				superscript_x = base_ink.x + base_ink.width
					+ context.unit * 0.015 - prime_ink.x
			end
		end
		if subscript then
			local script_top = subscript_baseline - subscript.baseline
			subscript_x = subscript_x + attachment.font:get_math_glyph_kerning(
				attachment.text,
				2,
				-script_top
			)
			if base.script_clearance then
				local subscript_ink = visible_ink(subscript)

				-- Tuck lower integral limits under the sign's lower hook
				subscript_x = base_ink.x + base_ink.width
					- context.unit * 0.25 - subscript_ink.x
			end
		end
	end
	if superscript and boundary_math_class(node.base, false) == "close" then
		local superscript_ink = visible_ink(superscript)

		-- A closing delimiter keeps its superscript beyond the visible curved edge
		superscript_x = math.max(
			superscript_x,
			base_ink.x + base_ink.width + context.unit * 0.08
				- superscript_ink.x
		)
	end

	local box_width = math.max(
		base.width,
		superscript and superscript_x + superscript.width or 0,
		subscript and subscript_x + subscript.width or 0
	)
	local box = new_box(box_width, bottom - top, baseline)

	append_box(box, base, 0, baseline - base.baseline)
	if superscript then
		append_box(
			box,
			superscript,
			superscript_x,
			superscript_baseline - superscript.baseline - top
		)
	end
	if subscript then
		append_box(
			box,
			subscript,
			subscript_x,
			subscript_baseline - subscript.baseline - top
		)
	end
	-- A side script needs the same clear visual break before any opening delimiter
	box.opening_clearance = context.unit * 0.1

	return box
end

--- Lay out one radical with an optional root index
-- @param node table root node
-- @param context table font and scale context
-- @return table box radical box
local function build_root(node, context)
	local body = build_node(node.body, context)
	local index = node.index and build_node(node.index, context:scaled(0.55))
	local body_ink = visible_ink(body)
	local body_top = math.min(body_ink.y, body.layout_top or body_ink.y)
	local body_bottom = math.max(
		body_ink.y + body_ink.height,
		body.layout_bottom or body_ink.y + body_ink.height
	)
	body_ink = {
		x = body_ink.x,
		y = body_top,
		width = body_ink.width,
		height = body_bottom - body_top,
	}
	local vertical_gap_name = context.display_mode
		and "radical_display_style_vertical_gap"
		or "radical_vertical_gap"
	-- Keep compact native font metrics from crowding the visible radicand
	local minimum_vertical_gap = context.unit * (context.display_mode and 0.22 or 0.18)
	local vertical_gap = math.max(
		get_metric(context, vertical_gap_name, minimum_vertical_gap),
		minimum_vertical_gap
	)
	local extra_ascender = get_metric(context, "radical_extra_ascender", 0)
	local rule_width = math.max(
		1,
		get_metric(context, "radical_rule_thickness", context.unit * 0.05)
	)
	local radical = stretchy_glyph_box(
		"√",
		context.font,
		body_ink.height + vertical_gap + rule_width,
		context
	)
	-- The radicand and overbar follow the radical's advance, even when its ink overhangs
	local radical_ink = visible_ink(radical)
	local radical_depth = math.max(
		0,
		radical_ink.y + radical_ink.height
			- (radical.outline_top or radical_ink.y)
			- rule_width
	)

	-- Split a taller font variant around the radicand instead of hanging it all below
	if radical_depth > body_ink.height + vertical_gap then
		vertical_gap = (
			vertical_gap + radical_depth - body_ink.height
		) / 2
	end

	local radical_x = 0
	local body_x = radical.advance or radical.width
	local rule_y = body_ink.y - vertical_gap - rule_width
	-- Anchor the surd's visible cap to its overbar, including nested radical variants
	local radical_y = rule_y - (radical.outline_top or radical_ink.y)
	local base_top = math.min(0, radical_y, rule_y - extra_ascender)
	local base_bottom = math.max(
		body.height,
		radical_y + radical.height
	)
	local index_x = 0
	local index_y = 0

	if index then
		local kern_before = math.max(
			0,
			get_signed_metric(context, "radical_kern_before_degree", context.unit * 0.28)
		)
		local kern_after = math.max(
			-index.width,
			get_signed_metric(context, "radical_kern_after_degree", -context.unit * 0.56)
		)
		local degree_raise = context.metrics and context.metrics.radical_degree_raise or 0.6

		-- Native OpenType values may arrive as either a ratio or a percentage
		if degree_raise > 1 then
			degree_raise = degree_raise / 100
		end

		index_x = kern_before
		radical_x = kern_before + index.width + kern_after
		body_x = radical_x + body_x
		local base_height = base_bottom - base_top
		index_y = base_bottom - base_height * degree_raise - index.height
	end

	local top = math.min(
		base_top,
		rule_y,
		index and index_y or base_top
	)
	local bottom = math.max(base_bottom, index and index_y + index.height or base_bottom)
	local joint_overlap = rule_width
	-- A visible terminal overhang keeps the bar clear of italic and rounded ink
	local terminal_overhang = context.unit * 0.25
	local rule_x = body_x - joint_overlap
	local overbar_width = body.width + joint_overlap + terminal_overhang
	local box = new_box(
		math.max(
			radical_x + radical.width,
			rule_x + overbar_width
		),
		bottom - top,
		body.baseline - top
	)
	local mask_components = {}

	-- Radical pieces keep fractional positions until one compound mask is rasterized
	for operation_index = 1, #radical.operations do
		local operation = radical.operations[operation_index]

		if operation.kind == "glyph" then
			mask_components[#mask_components + 1] = {
				glyph_id = operation.glyph_id,
				x = radical_x + operation.x,
				y = radical_y - top + operation.y,
			}
		end
	end

	-- The font's radical thickness keeps the cap and overbar visually equal
	mask_components[#mask_components + 1] = {
		x = rule_x,
		y = rule_y - top,
		width = overbar_width,
		height = rule_width,
	}
	local mask, mask_x, mask_y, mask_width, mask_height,
		mask_ink_x, mask_ink_y, mask_ink_width, mask_ink_height =
		context.font:compose_math_mask(mask_components, 4)

	if mask then
		box.operations[#box.operations + 1] = {
			kind = "mask",
			mask = mask,
			x = mask_x,
			y = mask_y,
			width = mask_width,
			height = mask_height,
		}
		-- The native rasterizer reports the exact visible pixel bounds
		include_ink(
			box,
			mask_ink_x,
			mask_ink_y,
			mask_ink_width,
			mask_ink_height
		)
	else
		-- An unusable optional font glyph must leave a readable, stable preview
		append_box(box, radical, radical_x, radical_y - top)
		append_line(box, rule_x, rule_y - top, overbar_width, rule_width)
	end
	append_box(box, body, body_x, -top)
	if index then
		append_box(box, index, index_x, index_y - top)
	end

	return box
end

--- Lay out one stretchable arrow with optional labels
-- @param node table arrow direction and labels
-- @param context table font and scale context
-- @return table box labelled arrow box
local function build_extensible_arrow(node, context)
	local script_context = context:scaled(context.script_scale)
	local above = node.above and build_node(node.above, script_context)
	local below = node.below and build_node(node.below, script_context)
	local label_width = math.max(
		above and above.width or 0,
		below and below.width or 0
	)
	local arrow_text = node.glyph or node.direction == "right" and "⟶" or "⟵"
	local minimum_width = node.cd and 3 or 1.4
	local target_width = math.max(
		context.unit * minimum_width,
		label_width + context.unit * 0.35
	)
	local arrow = node.cd
		and build_cd_horizontal_arrow(node.direction, target_width, context)
		or horizontal_glyph_box(arrow_text, context.font, target_width, true)
	-- CD labels use the same clear gap from the visible shaft as vertical side labels
	local gap = context.unit * (node.cd and CD_LABEL_GAP or 0.06)
	local above_ink = above and visible_ink(above)
	local below_ink = below and visible_ink(below)
	local arrow_y = above and (node.cd
		and above_ink.y + above_ink.height + gap - arrow.connector_top
		or above.height + gap) or 0
	local below_y = below and (node.cd
		and arrow_y + arrow.connector_bottom + gap - below_ink.y
		or arrow_y + arrow.height + gap) or 0
	local width = math.max(arrow.width, label_width)
	local height = below and below_y + below.height or arrow_y + arrow.height
	local axis_height = get_metric(context, "axis_height", context.unit * 0.25)
	local box = new_box(width, height, arrow_y + arrow.height / 2 + axis_height)

	if above then
		append_box(box, above, (width - above.width) / 2, 0)
	end
	append_box(box, arrow, (width - arrow.width) / 2, arrow_y)
	if below then
		append_box(box, below, (width - below.width) / 2, below_y)
	end

	return box
end

--- Draw KaTeX's short, shallow set-minus stroke
-- @param context table font and scale context
-- @return table box drawing box
local function build_small_setminus(context)
	local width = context.unit * 0.62
	local stroke_width = context.unit * 0.44
	local stroke_height = context.unit * 0.3
	local thickness = math.max(
		1,
		get_metric(context, "fraction_rule_thickness", context.unit * 0.055)
	)
	local center_y = context.font:get_baseline()
		- get_metric(context, "axis_height", context.unit * 0.25)
	local left = (width - stroke_width) / 2
	local top = center_y - stroke_height / 2
	local box = new_box(width, context.font:get_height(), context.font:get_baseline())

	-- A wider run than rise gives this command its distinct shallow angle
	append_diagonal(box, left, top, left + stroke_width, top + stroke_height, thickness)

	return box
end

--- Lay out a node recursively
-- @param node table parsed node
-- @param context table font and scale context
-- @return table box drawing box
build_node = function(node, context)
	if not node then
		return new_box(0, context.font:get_height(), context.font:get_baseline())
	end
	if node.kind == "empty" or node.kind == "equation_label" then
		return new_box(0, 1, 0)
	end

	if node.kind == "sequence" then
		return build_sequence(node.children, context)
	elseif node.kind == "fraction" then
		return build_fraction(node, context)
	elseif node.kind == "scripts" then
		return build_scripts(node, context)
	elseif node.kind == "root" then
		return build_root(node, context)
	elseif node.kind == "extensible_arrow" then
		return build_extensible_arrow(node, context)
	elseif node.kind == "small_setminus" then
		return build_small_setminus(context)
	elseif node.kind == "cd_vertical_arrow" then
		return build_cd_vertical_arrow(node, context)
	elseif node.kind == "cd_equal" then
		return build_cd_equal(context)
	elseif node.kind == "fixed_delimiter" then
		return stretchy_glyph_box(
			node.body.text or "",
			context.font,
			context.unit * node.scale,
			context
		)
	elseif node.kind == "delimited" then
		return build_delimited(node, context)
	elseif node.kind == "rows" then
		return build_rows(node, context)
	elseif node.kind == "phase" then
		local body = build_node(node.body, context)
		local ink = visible_ink(body)
		local rule_width = math.max(
			1,
			get_metric(context, "fraction_rule_thickness", context.unit * 0.055)
		)
		local slope_width = context.unit * 0.34
		local rule_y = ink.y + ink.height + context.unit * 0.05
		local width = slope_width + body.width
		local box = new_box(
			width,
			math.max(body.height, rule_y + rule_width),
			body.baseline
		)

		-- A phase mark is one rising stroke joined to a rule below its angle
		append_box(box, body, slope_width, 0)
		append_line(box, 0, rule_y, width, rule_width)
		append_diagonal(box, 0, rule_y, slope_width, ink.y, rule_width)

		return box
	elseif node.kind == "accent" then
		local body = build_node(node.body, context)
		local body_ink = visible_ink(body)
		local gap = context.unit * 0.08

		if node.rule then
			-- Brackets keep a firm outline while plain decoration lines stay light
			local rule_width = node.bracket and math.max(
				1,
				get_metric(
					context,
					"fraction_rule_thickness",
					context.unit * 0.055
				)
			) or math.max(1, context.unit * 0.025)
			local segment_extension = node.segment and context.unit * 0.12 or 0
			local leg_height = node.bracket and context.unit * 0.22 or 0
			local rule_gap = node.bracket and gap
				or node.segment and gap
				or rule_width * 3
			local side_space = context.unit * 0.08
			local line_x = body_ink.x - side_space
			local line_width = body_ink.width + side_space * 2
			local offset = leg_height + segment_extension
			local rule_y = node.under
				and body_ink.y + body_ink.height + rule_gap + offset
				or body_ink.y - rule_gap - offset - rule_width
			local decoration_top = rule_y - segment_extension
			local decoration_bottom = rule_y + rule_width + segment_extension
			local left = math.min(0, line_x)
			local top = math.min(0, decoration_top)
			local right = math.max(body.width, line_x + line_width)
			local bottom = math.max(body.height, decoration_bottom)
			local box = new_box(right - left, bottom - top, body.baseline - top)

			append_box(box, body, -left, -top)
			-- Visible ink plus equal side space keeps rules balanced around italic letters
			append_line(box, line_x - left, rule_y - top, line_width, rule_width)
			if node.bracket then
				local leg_y = node.under and rule_y - leg_height or rule_y
				local stroke_height = leg_height + rule_width

				append_line(box, line_x - left, leg_y - top, rule_width, stroke_height)
				append_line(
					box,
					line_x - left + line_width - rule_width,
					leg_y - top,
					rule_width,
					stroke_height
				)
			elseif node.segment then
				local cap_y = rule_y - segment_extension
				local cap_height = segment_extension * 2 + rule_width

				-- Line-segment caps cross the rule equally in both directions
				append_line(box, line_x - left, cap_y - top, rule_width, cap_height)
				append_line(
					box,
					line_x - left + line_width - rule_width,
					cap_y - top,
					rule_width,
					cap_height
				)
			end

			return box
		end

		-- Short bars and dot accents keep KaTeX-like air above visible letter ink
		gap = node.stretchy and gap
			or context.unit * (node.dots and 0.11 or node.bar and 0.1 or 0.03)
		local mark
		if node.bar then
			local rule_width = math.max(1, context.unit * 0.025)
			local bar_width = context.unit * 0.4
			mark = new_box(bar_width, rule_width, rule_width)
			append_line(mark, 0, 0, bar_width, rule_width)
			mark.attachment = { top_accent = bar_width / 2 }
		elseif node.dots then
			-- Separate dots stay round, full-sized, and evenly spaced
			local dot = horizontal_glyph_box(
				"˙",
				context:font_for_scale(1.45),
				0,
				false
			)
			local dot_gap = context.unit * 0.09
			local dot_advance = dot.width + dot_gap

			mark = new_box(
				dot.width * node.dots + dot_gap * (node.dots - 1),
				dot.height,
				dot.baseline
			)
			for dot_index = 1, node.dots do
				append_box(mark, dot, (dot_index - 1) * dot_advance, 0)
			end
		else
			local mark_font = node.stretchy
				and context:font_for_scale(0.75)
				or context:font_for_scale(1.25)

			mark = horizontal_glyph_box(
				node.mark,
				mark_font,
				body_ink.width,
				node.stretchy
			)
		end
		local mark_ink = visible_ink(mark)
		local mark_x = body_ink.x
			+ body_ink.width / 2
			- mark_ink.x
			- mark_ink.width / 2

		if not node.stretchy and body.attachment then
			local body_attachment = body.attachment.top_accent
			local mark_attachment = mark.attachment and mark.attachment.top_accent

			-- Missing font anchors fall back to each glyph's centered advance
			body_attachment = body_attachment > 0 and body_attachment or body.width / 2
			mark_attachment = mark_attachment and mark_attachment > 0
				and mark_attachment
				or mark.width / 2
			mark_x = body_attachment - mark_attachment
		end
		local mark_y = node.under
			and body_ink.y + body_ink.height + gap - mark_ink.y
			or body_ink.y - gap - mark_ink.y - mark_ink.height
		local left = math.min(0, mark_x)
		local top = math.min(0, mark_y)
		local right = math.max(body.width, mark_x + mark.width)
		local bottom = math.max(body.height, mark_y + mark.height)
		local box = new_box(right - left, bottom - top, body.baseline - top)

		append_box(box, body, -left, -top)
		append_box(box, mark, mark_x - left, mark_y - top)

		return box
	elseif node.kind == "space" then
		local width = context.unit * node.scale

		-- Negative spaces move the following atom left and therefore keep a signed width
		if width < 0 then
			return {
				width = width,
				height = context.font:get_height(),
				baseline = context.font:get_baseline(),
				operations = {},
			}
		end

		return new_box(width, context.font:get_height(), context.font:get_baseline())
	elseif node.kind == "rule" then
		local width = math.max(0, resolve_length(node.width, node.width_unit, context))
		local height = math.max(0, resolve_length(node.height, node.height_unit, context))
		local box = new_box(width, height, height)

		-- A TeX rule is a filled rectangle resting directly on the baseline
		append_line(box, 0, 0, width, height)

		return box
	elseif node.kind == "phantom" then
		local body = build_node(node.body, context)
		local width = node.mode == "vphantom" and 0 or body.width

		-- Horizontal phantoms occupy width without changing the surrounding line height
		if node.mode == "hphantom" then
			return new_box(width, 1, 0)
		end

		local box = new_box(width, body.height, body.baseline)
		local ink = visible_ink(body)
		box.layout_top = body.layout_top or ink.y
		box.layout_bottom = body.layout_bottom or ink.y + ink.height

		return box
	elseif node.kind == "smash" then
		local body = build_node(node.body, context)
		local box = new_box(
			body.width,
			context.font:get_height(),
			context.font:get_baseline()
		)
		local body_y = box.baseline - body.baseline
		local body_ink = visible_ink(body)
		local ink_top = body_ink.y + body_y
		local ink_bottom = ink_top + body_ink.height

		-- Keep the ink while hiding any height beyond an ordinary math line
		append_box(box, body, 0, body_y)
		box.ink = box.ink or {
			x = body_ink.x,
			y = ink_top,
			width = body_ink.width,
			height = body_ink.height,
		}
		if node.mode == "b" then
			box.ink.y = ink_top
			box.ink.height = math.max(1, box.baseline - ink_top)
		elseif node.mode == "t" then
			box.ink.y = box.baseline
			box.ink.height = math.max(1, ink_bottom - box.baseline)
		else
			box.ink.y = box.baseline
			box.ink.height = 1
		end

		return box
	elseif node.kind == "overlap" then
		local body = build_node(node.body, context)
		local ink = visible_ink(body)
		local box = new_box(0, body.height, body.baseline)
		local body_x = node.alignment == "left" and -body.width
			or node.alignment == "center" and -ink.x - ink.width / 2
			or 0

		-- Centered overlaps place visible ink equally around their zero-width point
		append_box(box, body, body_x, 0)

		return box
	elseif node.kind == "cancel" then
		local body = build_node(node.body, context)
		local ink = visible_ink(body)
		local thickness = math.max(
			1,
			get_metric(context, "fraction_rule_thickness", context.unit * 0.055)
		)
		local left = ink.x + thickness / 2
		local top = math.max(0, ink.y - context.unit * 0.16)
		local right = math.max(left, ink.x + ink.width - thickness / 2)
		local bottom = math.min(body.height, ink.y + ink.height + context.unit * 0.07)
		local box = new_box(body.width, body.height, body.baseline)

		-- Cancellation is an overlay and must not change the body's spacing or baseline
		append_box(box, body, 0, 0)
		if node.mode == "cancel" or node.mode == "xcancel" then
			append_diagonal(
				box,
				left,
				bottom,
				right,
				top,
				thickness
			)
		end
		if node.mode == "bcancel" or node.mode == "xcancel" then
			append_diagonal(
				box,
				left,
				top,
				right,
				bottom,
				thickness
			)
		end

		return box
	elseif node.kind == "strike" then
		local body = build_node(node.body, context)
		local ink = visible_ink(body)
		local thickness = math.max(1, context.unit * 0.08)
		local x_bounds = context:font_for_style("mathrm"):get_text_bounds("x")
		local line_y = body.baseline - x_bounds.height / 2 - thickness / 2
		local box = new_box(body.width, body.height, body.baseline)

		-- Strikeout crosses the x-height center used by KaTeX text decoration
		append_box(box, body, 0, 0)
		append_line(
			box,
			ink.x,
			line_y,
			ink.width,
			thickness
		)

		return box
	elseif node.kind == "negated" then
		local body = build_node(node.body, context)
		local ink = visible_ink(body)
		local center_y = ink.y + ink.height / 2
		local half_height = math.max(ink.height * 0.75, context.unit * 0.28)
		local top = math.max(0, center_y - half_height)
		local bottom = math.min(body.height, center_y + half_height)
		local thickness = math.max(
			1,
			get_metric(context, "fraction_rule_thickness", context.unit * 0.055)
		)
		local box = new_box(body.width, body.height, body.baseline)

		append_box(box, body, 0, 0)
		append_diagonal(
			box,
			ink.x,
			bottom,
			ink.x + ink.width,
			top,
			thickness
		)

		return box
	elseif node.kind == "vcenter" then
		local body = build_node(node.body, context)
		local axis_height = get_metric(context, "axis_height", context.unit * 0.25)

		-- TeX centers the complete box on the math axis rather than the baseline
		body.baseline = body.height / 2 + axis_height

		return body
	elseif node.kind == "raise" then
		local body = build_node(node.body, context)
		local offset = node.amount * context.unit

		if node.unit == "pt" then
			offset = offset / 10
		end

		local body_top = -body.baseline - offset
		local body_bottom = body.height - body.baseline - offset
		local top = math.min(0, body_top)
		local bottom = math.max(0, body_bottom)
		local box = new_box(body.width, bottom - top, -top)

		-- Positive lengths move the body above the unchanged surrounding baseline
		append_box(box, body, 0, body_top - top)

		return box
	elseif node.kind == "frame" then
		return build_frame(node, context)
	elseif node.kind == "enclose" then
		return build_enclose(node, context)
	elseif node.kind == "color" then
		local box = build_node(node.body, context)
		local color = context.resolve_color(node.name)

		-- Nested explicit colors keep precedence over their surrounding declaration
		if color then
			for _, operation in ipairs(box.operations) do
				operation.color = operation.color or color
			end
		end

		return box
	elseif node.kind == "class" then
		-- Explicit classes affect surrounding spacing but draw only their body
		return build_node(node.body, context)
	elseif node.kind == "math_style" then
		return build_node(node.body, context:with_math_style(node.style))
	elseif node.kind == "scaled" then
		return build_node(node.body, context:with_size(node.scale))
	elseif node.kind == "styled" then
		local styled_context = context:with_style(node.style)

		return build_node(node.body, styled_context)
	elseif node.kind == "text_styled" then
		return build_node(node.body, context:with_text_style(node))
	elseif node.kind == "link" then
		local box = build_node(node.body, context)
		local ink = visible_ink(box)
		local underline_gap = math.max(1, context.unit * 0.1)
		local underline_y = ink.y + ink.height + underline_gap

		box.height = math.max(box.height, underline_y + 1)
		include_ink(box, 0, underline_y, box.width, 1)

		-- Link regions follow their measured math body through later box placement
		box.links = box.links or {}
		box.links[#box.links + 1] = {
			target = node.target,
			x = 0,
			y = 0,
			width = box.width,
			height = box.height,
			underline_y = underline_y,
		}
		for operation_index = 1, #box.operations do
			box.operations[operation_index].link = node.target
		end

		return box
	elseif node.kind == "multicolumn" then
		local box = build_node(node.body, context)

		-- Row layout reads these two plain measurements without another wrapper
		box.column_span = node.columns
		box.column_alignment = node.alignment == "l" and "left"
			or node.alignment == "r" and "right"
			or "center"

		return box
	elseif node.kind == "plain_text" then
		local font = context.text_family and context.font
			or context:font_for_style("mathrm")

		return text_box(node.text, font)
	end

	local text = node.text or ""
	local font = context.font

	if node.kind == "operator" or node.kind == "unsupported" then
		font = context:font_for_style("mathrm")
	elseif node.kind == "text"
		and (context.style == "mathit" or context.style == "boldsymbol"
			or context.text_family == "roman" and context.text_shape == "italic")
	then
		text = mathematical_italic(text, font)
	elseif node.kind == "text" and context.style == "mathbb" then
		text = mathematical_double_struck(text, font)
	elseif node.kind == "text" and context.style == "mathcal" then
		text = mathematical_script(text, font)
	elseif node.kind == "symbol"
		and (context.style == "mathit" or context.style == "boldsymbol")
		and node.styled_codepoint
		and font:has_codepoint(node.styled_codepoint)
	then
		text = utf8.char(node.styled_codepoint)
	end
	if node.large_operator and context.display_mode then
		return stretchy_glyph_box(
			text,
			font,
			context.unit * context.display_operator_scale,
			context
		)
	end

	return text_box(text, font)
end

--- Build one scalable math drawing box
-- @param expression table parsed expression
-- @param font_provider function returns a font for scale and style
-- @param options table|nil layout measurements
-- @return table box measured drawing box
function layout.build(expression, font_provider, options)
	options = options or {}
	local context = {}

	--- Return a copy of this context at a relative scale
	-- @param scale number relative font scale
	-- @return table scaled_context copied context
	function context:scaled(scale)
		local scaled = {
			scale = self.scale * scale,
			style = self.style,
			provider = self.provider,
		}
		scaled.font = scaled.provider(scaled.scale, scaled.style)
		scaled.unit = scaled.font:get_height()
		setmetatable(scaled, { __index = self })

		return scaled
	end

	--- Return a copy with one explicit TeX font size
	-- @param scale number relative font-size scale
	-- @return table sized_context copied context
	function context:with_size(scale)
		local sized = self:scaled(scale)

		-- Relative em and ex lengths use this text-size unit through tighter math styles
		sized.text_unit = sized.unit

		return sized
	end

	--- Return a copy with one TeX font style
	-- @param style_name string TeX style command
	-- @return table styled_context copied context
	function context:with_style(style_name)
		local styled = self:scaled(1)
		styled.style = style_name
		-- A math font command replaces any surrounding stackable text face
		styled.text_family = false
		styled.text_weight = false
		styled.text_shape = false
		styled.font = styled.provider(styled.scale, style_name)
		styled.unit = styled.font:get_height()

		return styled
	end

	--- Return a copy with one stackable TeX text-face change
	-- @param node table text family, weight, or shape change
	-- @return table styled_context copied context
	function context:with_text_style(node)
		local styled = self:scaled(1)
		styled.text_family = node.family or self.text_family or "roman"
		styled.text_weight = node.weight or self.text_weight or "medium"
		styled.text_shape = node.shape or self.text_shape or "upright"
		styled.style = table.concat({
			"text",
			styled.text_family,
			styled.text_weight,
			styled.text_shape,
		}, ":")
		styled.font = styled.provider(styled.scale, styled.style)
		styled.unit = styled.font:get_height()

		return styled
	end

	--- Return a copy using one explicit TeX math size and limit mode
	-- @param math_style string display, text, script, or scriptscript
	-- @return table styled_context copied context
	function context:with_math_style(math_style)
		local scale = 1

		if math_style == "script" then
			scale = self.metrics and self.metrics.script_scale or self.script_scale
		elseif math_style == "scriptscript" then
			scale = self.metrics and self.metrics.script_script_scale
				or self.script_script_scale
		end

		local styled = {
			scale = self.scale * scale,
			style = self.style,
			provider = self.provider,
			display_mode = math_style == "display",
			math_style = math_style,
		}
		styled.font = styled.provider(styled.scale, styled.style)
		styled.unit = styled.font:get_height()
		setmetatable(styled, { __index = self })

		return styled
	end

	--- Return a font at a scale without changing this context
	-- @param scale number relative font scale
	-- @return userdata font selected font
	function context:font_for_scale(scale)
		return self.provider(self.scale * scale, self.style)
	end

	--- Return a font with another style without changing this context
	-- @param style_name string TeX style command
	-- @return userdata font selected font
	function context:font_for_style(style_name)
		return self.provider(self.scale, style_name)
	end

	context.scale = 1
	context.style = "mathit"
	context.provider = font_provider
	context.font = font_provider(1, context.style)
	context.unit = context.font:get_height()
	-- Fixed TeX units keep this initial measurement through nested size contexts
	context.base_unit = context.unit
	-- Relative em and ex units start from the ordinary text-style measurement
	context.text_unit = context.unit
	context.script_scale = options.script_scale or 0.7
	context.script_script_scale = options.script_script_scale or 0.5
	context.metrics = options.metrics
	context.display_mode = options.display_mode == true
	context.math_style = context.display_mode and "display" or "text"
	context.display_operator_scale = options.display_operator_scale or 1.45
	context.display_limit_gap_scale = options.display_limit_gap_scale or 0.08
	context.display_width = options.display_width
	-- Markdown math uses CSS names unless its caller supplies combined color tables
	context.resolve_color = options.color_resolver or resolve_css_color

	return build_node(expression, context)
end

--- Draw one measured math box
-- @param box table measured drawing box
-- @param x number left edge
-- @param y number top edge
-- @param color table text and rule color
function layout.draw(box, x, y, color, link_color)
	for operation_index = 1, #box.operations do
		local operation = box.operations[operation_index]

		local operation_color = operation.link and link_color or operation.color or color

		if operation.kind == "diagonal" then
			renderer.draw_line(
				x + operation.x,
				y + operation.y,
				x + operation.x2,
				y + operation.y2,
				operation.width,
				operation_color
			)
		elseif operation.kind == "line" then
			renderer.draw_rect(
				math.floor(x + operation.x),
				math.floor(y + operation.y),
				math.max(1, math.ceil(operation.width)),
				math.max(1, math.ceil(operation.height)),
				operation_color
			)
		elseif operation.kind == "mask" then
			-- Round only the complete compound so its internal joints share one pixel phase
			renderer.draw_mask(
				operation.mask,
				math.floor(x + operation.x + 0.5),
				math.floor(y + operation.y + 0.5),
				operation_color
			)
		elseif operation.kind == "glyph" then
			renderer.draw_glyph(
				operation.font,
				operation.glyph_id,
				x + operation.x,
				y + operation.y,
				operation_color
			)
		else
			renderer.draw_text(
				operation.font,
				operation.text,
				x + operation.x,
				y + operation.y,
				operation_color
			)
		end
	end
	for link_index = 1, #(box.links or {}) do
		local link = box.links[link_index]

		-- Math links use the same simple underline cue as Markdown links
		renderer.draw_rect(
			x + link.x,
			y + link.underline_y,
			link.width,
			1,
			link_color or color
		)
	end
end

--- Return the target inside one measured math box
-- @param box table measured math box
-- @param x number horizontal position inside the box
-- @param y number vertical position inside the box
-- @return string|nil target matching link target
function layout.get_link_at(box, x, y)
	for link_index = 1, #(box.links or {}) do
		local link = box.links[link_index]

		if x >= link.x and x <= link.x + link.width
			and y >= link.y and y <= link.y + link.height
		then
			return link.target
		end
	end

	return nil
end

return layout
