-- lx-bundle:true

-- This file is part of Lx, created by Liqube <www.liqube.com>
-- Copyright Liqube Audio. All rights reserved.
-- Lx Interim License. See LICENSE for details.

--- Read-only rendered Markdown preview view
-- @module plugins.mdpreview.view

local common = require("core.common")
local config = require("core.config")
local core = require("core")
local html_decode = require("core.lib.html_decode")
local code_blocks = require("plugins.mdpreview.code_blocks")
local markdown_design = require("plugins.mdpreview.design")
local markdown_escapes = require("plugins.mdpreview.escapes")
local Doc = require("core.doc")
local file_location = require("core.file.location")
local fontcache = require("core.style.fontcache")
local fonts = require("core.style.fonts")
local images = require("plugins.mdpreview.images")
local markdown_math = require("plugins.mdpreview.math")
local open_mode = require("core.open_mode")
local outline = require("plugins.mdpreview.outline")
local Scrollbar = require("core.ui.scrollbar")
local references = require("plugins.mdpreview.references")
local core_style = require("core.style")
local DocView = require("core.doc.view")
local tables = require("plugins.mdpreview.tables")
local Tooltip = require("core.ui.tooltip")
local utf8 = require("utf8")

local MarkdownPreview = DocView:extend()

-- Commands use this marker without forcing optional renderer to load early
MarkdownPreview.is_markdown_preview = true

-- Keep operational timing beside behavior it controls
local RESIZE_REFLOW_DELAY = 0.08
local INITIAL_LAYOUT_LINES = 32
-- Height chunks avoid walking every source line when a distant page is requested
local LAYOUT_HEIGHT_CHUNK_LINES = 128
local HOVER_TOOLTIP_DELAY = 0.5
local HOVER_TOOLTIP_DURATION = 4

-- Markdown tabs use fixed four-column stops independently of editor indentation settings
local MARKDOWN_TAB_WIDTH = 4

--- Return whether first small part of line contains one Unicode box-drawing character
-- @param text string source line
-- @return boolean found true when U+2500 through U+257F appears
local function has_box_drawing_character(text)
	local character_count = 0

	for _, codepoint in utf8.codes(text) do
		character_count = character_count + 1

		if character_count > markdown_design.box_drawings.scan_character_limit then
			break
		end

		if codepoint >= 0x2500 and codepoint <= 0x257F then
			return true
		end
	end

	return false
end

--- Scale one logical preview measurement to screen pixels
-- @param value number logical measurement
-- @return number pixels scaled measurement
local function scale_measurement(value)
	return value * (SCALE or 1)
end

--- Draw one horizontal rule using central Markdown design
-- @param x number line left edge
-- @param y number line top edge
-- @param width number line width
local function draw_horizontal_rule(x, y, width)
	renderer.draw_rect(
		x,
		y,
		width,
		markdown_design.get_horizontal_rule_width(),
		markdown_design.get_horizontal_rule_color()
	)
end

--- Draw one heading separator using its resolved level design
-- @param heading_level number heading level
-- @param x number line left edge
-- @param y number line top edge
-- @param width number line width
local function draw_heading_separator(heading_level, x, y, width)
	renderer.draw_rect(
		x,
		y,
		width,
		markdown_design.get_heading_separator_width(heading_level),
		markdown_design.get_heading_separator_color(heading_level)
	)
end

--- Load base preview font and start an empty optional-face cache
-- @param zoom_multiplier number view-local zoom
-- @return table fonts preview font set
local function load_preview_fonts(zoom_multiplier)
	local scale = (SCALE or 1) * zoom_multiplier
	local preview_fonts = {
		body = fonts.load_style(markdown_design.typography.font_size * scale),
		-- Fixed interface outline remains readable while document text zooms
		outline = fonts.load_style(
			markdown_design.typography.font_size
				* markdown_design.outline.font_scale
				* (SCALE or 1)
		),
		by_style = {},
	}

	if markdown_design.code_blocks.header.enabled then
		-- Small header face exists only inside optional Markdown preview
		preview_fonts.code_header = fonts.load_style(
			markdown_design.typography.font_size
				* markdown_design.code_blocks.header.font_scale
				* scale
		)
	end

	if markdown_design.code_blocks.badge.enabled then
		-- Floating badge uses its own scale without changing code layout
		preview_fonts.code_badge = fonts.load_style(
			markdown_design.typography.font_size
				* markdown_design.code_blocks.badge.font_scale
				* scale
		)
	end

	return preview_fonts
end

--- Remove stored line ending from one document line
-- @param line string raw document line
-- @return string text line without its ending
local function strip_line_ending(line)
	-- Pattern breakdown:
	-- '\r?\n$' matches one optional carriage return and final newline
	return (line or ""):gsub("\r?\n$", "")
end

--- Measure leading Markdown indentation in visual columns
-- Tabs advance to next four-column Markdown tab stop
-- @param indent string leading spaces and tabs
-- @return number columns indentation width
local function measure_markdown_indent(indent)
	local column = 0

	for character in indent:gmatch(".") do
		if character == "\t" then
			column = column + MARKDOWN_TAB_WIDTH - column % MARKDOWN_TAB_WIDTH
		else
			column = column + 1
		end
	end

	return column
end

--- Read one ATX heading with up to three leading spaces
-- @param line string source line
-- @return number|nil level heading level
-- @return string|nil text visible heading text
local function read_heading(line)
	-- Pattern breakdown:
	-- '^(%s*)' captures indentation at start
	-- '(#+)' captures heading markers
	-- '%s+(.+)$' requires a separator and captures visible text
	local indent, markers, text = line:match("^(%s*)(#+)%s+(.+)$")

	if not indent or measure_markdown_indent(indent) > 3 or #markers > 6 then
		return nil
	end

	-- Pattern breakdown:
	-- '%s+#+%s*$' removes optional closing hashes only when whitespace separates them from text
	text = text:gsub("%s+#+%s*$", "")

	return #markers, text
end

--- Read one Setext heading underline with up to three leading spaces
-- @param line string source line
-- @return number|nil level heading level
local function read_setext_underline(line)
	-- Pattern breakdown:
	-- '^(%s*)' captures indentation at start
	-- '(=+)' captures a level-one underline containing only equals signs
	-- '%s*$' permits trailing spacing but no visible text
	local indent, markers = line:match("^(%s*)(=+)%s*$")

	if not markers then
		-- Pattern breakdown: '^(%s*)(%-+)%s*$' applies same rule to a level-two dash underline
		indent, markers = line:match("^(%s*)(%-+)%s*$")
	end

	if not markers or measure_markdown_indent(indent) > 3 then
		return nil
	end

	return markers:sub(1, 1) == "=" and 1 or 2
end

--- Read one unordered or ordered list item
-- @param line string source line
-- @return string|nil marker visible list marker
-- @return string|nil text visible item text
-- @return number|nil indent source indentation columns
local function read_list_item(line)
	-- Pattern breakdown:
	-- '^(%s*)' captures indentation before marker
	-- '([-+*])%s+' captures an unordered marker followed by spacing
	-- '(.+)$' captures visible item text
	local indent, marker, text = line:match("^(%s*)([-+*])%s+(.+)$")

	if not marker then
		-- Pattern breakdown:
		-- '^(%s*)' captures indentation before marker
		-- '(%d+[.)])%s+' captures a numbered marker such as '1.' or '2)'
		-- '(.+)$' captures visible item text
		indent, marker, text = line:match("^(%s*)(%d+[%.%)])%s+(.+)$")
	end

	if not marker then
		return nil
	end

	return marker:match("^[-+*]$") and "•" or marker, text, measure_markdown_indent(indent)
end

--- Read a possible indented continuation of current list item
-- @param line string source line
-- @return string|nil text visible continuation text
-- @return number|nil indent source indentation columns
local function read_list_continuation(line)
	-- Pattern breakdown:
	-- '^(%s+)' captures required indentation before continuation
	-- '(%S.*)$' captures visible text without structural list indentation
	local indent, text = line:match("^(%s+)(%S.*)$")

	if not text then
		return nil
	end

	return text, measure_markdown_indent(indent)
end

--- Add readable spacing after one rendered list marker
-- Unordered markers need more separation because filled circle is visually heavier
-- @param marker string visible list marker
-- @return string text marker including its trailing spacing
local function format_list_marker(marker)
	local space_count = marker == "•"
		and markdown_design.lists.unordered_marker_spaces
		or markdown_design.lists.ordered_marker_spaces

	return marker .. string.rep(" ", space_count)
end

--- Return whether a visible list marker is ordered
-- @param marker string visible list marker
-- @return boolean ordered marker state
local function is_ordered_list_marker(marker)
	-- Pattern breakdown: '^%d+[.)]$' matches a complete numeric marker such as '1.' or '2)'
	return marker:match("^%d+[.)]$") ~= nil
end

--- Find visual parent for one consecutive list item
-- @param active_context table latest list item context
-- @param marker string visible list marker
-- @param indent number source indentation columns
-- @return table|nil parent_context visual parent item
local function find_list_parent(active_context, marker, indent)
	local candidate = active_context

	-- Walk back through deeper items before comparing requested source level
	while candidate and indent < candidate.list_indent do
		candidate = candidate.list_parent
	end

	if not candidate then
		return nil
	end

	if indent > candidate.list_indent then
		return candidate
	end

	if is_ordered_list_marker(marker) ~= is_ordered_list_marker(candidate.list_marker) then
		return candidate
	end

	return candidate.list_parent
end

--- Return marker-only font options without thickening ordered numbers
-- @param marker string visible list marker
-- @return table options marker font options
local function get_list_marker_options(marker)
	return { heavy = marker == "•" }
end

--- Read one blockquote line with up to three leading spaces
-- @param line string source line
-- @return string|nil text visible quote text
local function read_blockquote(line)
	-- Pattern breakdown:
	-- '^(%s*)' captures optional indentation before quote marker
	-- '>%s?' removes marker and at most one following space
	-- '(.*)$' captures visible quote text, including an empty quote row
	local indent, text = line:match("^(%s*)>%s?(.*)$")

	if indent and measure_markdown_indent(indent) <= 3 then
		return text
	end

	return nil
end

--- Read a backtick or tilde fence
-- @param line string source line
-- @return string|nil marker repeated fence marker
local function read_fence(line)
	-- Pattern breakdown:
	-- '^(%s*)' captures indentation before fence
	-- '(```+)' or '(~~~+)' captures at least three matching fence characters
	local indent, marker = line:match("^(%s*)(```+)")

	if not marker then
		indent, marker = line:match("^(%s*)(~~~+)")
	end

	-- Fenced blocks allow at most three spaces while deeper indentation is literal code
	if not marker or #indent > 3 or indent:find("\t", 1, true) then
		return nil
	end

	return marker
end

--- Remove one four-column Markdown code indentation
-- Tabs advance to next four-column stop and remaining indentation stays visible
-- @param line string source line
-- @return string|nil text literal code text
local function read_indented_code(line)
	local column = 0
	local position = 1

	while position <= #line and column < MARKDOWN_TAB_WIDTH do
		local character = line:sub(position, position)

		if character == " " then
			column = column + 1
		elseif character == "\t" then
			column = column + MARKDOWN_TAB_WIDTH - column % MARKDOWN_TAB_WIDTH
		else
			return nil
		end

		position = position + 1
	end

	if column < MARKDOWN_TAB_WIDTH then
		return nil
	end

	return line:sub(position)
end

--- Read optional info string following an opening fence
-- @param line string source line
-- @param marker string repeated fence marker
-- @return string info untrimmed fence information
local function read_fence_info(line, marker)
	-- Pattern breakdown:
	-- '^%s*' permits same indentation accepted by 'read_fence'
	-- marker consumes complete backtick or tilde opening fence
	-- '(.*)$' captures its optional language and later attributes
	return line:match("^%s*" .. marker .. "(.*)$") or ""
end

--- Return whether a line is a Markdown horizontal rule
-- @param line string source line
-- @return boolean rule true for three or more matching markers
local function is_horizontal_rule(line)
	local compact = line:gsub("%s", "")

	-- Pattern breakdown: each pattern accepts only one repeated rule marker, at least three times
	return compact:match("^%-%-%-+$") ~= nil
		or compact:match("^%*%*%*+$") ~= nil
		or compact:match("^___+$") ~= nil
end

--- Return whether an unmarked line may continue current quote paragraph
-- A blank or another visible block starts outside quote instead
-- @param line string source line
-- @return boolean continuation true when line belongs to quote paragraph
local function is_lazy_quote_continuation(line)
	if line:match("^%s*$")
		or read_heading(line)
		or read_list_item(line)
		or read_fence(line)
		or is_horizontal_rule(line)
		or images.read_block(line) then
		return false
	end

	return true
end

--- Return whether ordinary source text can receive a following Setext underline
-- @param line string source line before underline
-- @param context table scanned block context for that line
-- @return boolean eligible true when line is ordinary heading text
local function can_be_setext_heading(line, context)
	if not context
		or context.fence_marker
		or context.is_fence_delimiter
		or context.quote_text ~= nil
		or context.image_target
		or context.heading_level
		or context.list_text
		or context.list_continuation_text then
		return false
	end

	return line:match("^%s*$") == nil
		and read_heading(line) == nil
		and read_list_item(line) == nil
		and read_fence(line) == nil
		and not is_horizontal_rule(line)
end

--- Copy formatting flags without sharing mutable parser state
-- @param options table current formatting flags
-- @return table copy copied flags
local function copy_options(options)
	return {
		bold = options.bold == true,
		embolden = tonumber(options.embolden) or 0,
		italic = options.italic == true,
		light = options.light == true,
		heavy = options.heavy == true,
		link = options.link,
	}
end

--- Apply one configured heading face while retaining inline italic and bold emphasis
-- @param options table copied inline font options
-- @param heading_style table heading size and face definition
local function apply_heading_font_style(options, heading_style)
	local inline_bold = options.bold == true
	local face = heading_style.font_style
	options.bold = false
	options.embolden = inline_bold and 0 or tonumber(heading_style.font_embolden) or 0
	options.light = false
	options.heavy = false

	if face == "heavy" then
		options.heavy = true
	elseif face == "light" and not inline_bold then
		options.light = true
	elseif face == "bold" or inline_bold then
		options.bold = true
	end
end

--- Return selected face name used by preview font cache
-- @param options table resolved font options
-- @return string face regular, light, bold, or heavy
local function get_font_face_name(options)
	if options.heavy then
		return "heavy"
	end
	if options.light then
		return "light"
	end
	if options.bold then
		return "bold"
	end

	return "regular"
end

--- Classify one link target without opening it
-- @param target string raw Markdown target
-- @return string kind url, anchor, file, invalid, or unsupported
-- @return table|nil reference parsed local file reference
local function classify_link_target(target)
	if type(target) ~= "string" or target == "" then
		return "invalid"
	end
	if target:lower():match("^https?://") then
		return "url"
	end
	if target:sub(1, 1) == "#" then
		return #target > 1 and "anchor" or "unsupported"
	end

	-- Parse locations first so 'file.cpp:10' stays a local file
	local path = target:gsub("#.*$", "")
	local reference = system.parse_file_reference(path, true)
	if not reference then
		return "invalid"
	end

	-- Virtual paths and locations make ':' part of a file reference
	if target:match("^%a:[/\\]")
		or target:match("^[/\\]")
		or reference.virtual_path
		or #reference.locations > 0
	then
		return "file", reference
	end

	-- Pattern breakdown: scheme name followed by ':' identifies unsupported non-file links
	if target:match("^[%a][%w+.-]*:") then
		return "unsupported"
	end

	return reference.filename ~= "" and "file" or "unsupported", reference
end

--- Return whether preview can open one link target
-- @param target string|nil raw Markdown target
-- @return boolean supported
local function link_target_is_supported(target)
	local kind = classify_link_target(target)

	return kind ~= "invalid" and kind ~= "unsupported"
end

--- Read one supported Markdown link starting at a source position
-- @param text string Markdown inline source
-- @param position number first candidate byte
-- @param reference_definitions table|nil normalized reference targets
-- @return string|nil label visible link label
-- @return string|nil target URL or local path
-- @return number|nil next_position first byte after complete link
local function read_inline_link(text, position, reference_definitions)
	if text:sub(position, position) == "[" then
		local label_end = markdown_escapes.find_unescaped(text, "](", position + 1)
		if label_end then
			-- Links and images share same balanced destination boundary
			local target_content, next_position = images.read_parenthesized(text, label_end + 1)
			local label = text:sub(position + 1, label_end - 1)
			local target = target_content and target_content:match("^%s*(.-)%s*$")

			if label ~= "" and target and target ~= "" then
				return label, markdown_escapes.decode(target), next_position
			end
		end
	elseif text:sub(position, position) == "<" then
		local target_end = markdown_escapes.find_unescaped(text, ">", position + 1)
		local target = target_end and text:sub(position + 1, target_end - 1)

		-- Pattern breakdown: '^https?://' accepts only ordinary HTTP and HTTPS automatic links
		if target and target:lower():match("^https?://[^%s<>]+$") then
			return target, target, target_end + 1
		end
	end

	if reference_definitions then
		return references.read_link(text, position, reference_definitions)
	end

	return nil
end

--- Append text while merging adjacent runs with identical formatting
-- @param runs table destination run list
-- @param text string visible text
-- @param options table formatting flags
-- @param code boolean|nil inline-code state
local function append_inline_run(runs, text, options, code)
	if text == "" then
		return
	end

	local previous = runs[#runs]
	local face = options.heavy and "heavy" or options.bold and "bold" or "regular"
	local style_key = table.concat({
		face,
		options.italic and "italic" or "upright",
		code and "code" or "text",
		options.link or "",
	}, ":")

	if previous and previous.style_key == style_key then
		previous.text = previous.text .. text
		return
	end

	runs[#runs + 1] = {
		text = text,
		options = copy_options(options),
		code = code == true,
		style_key = style_key,
	}
end

--- Append one inline math expression without exposing its TeX source as ordinary text
-- @param runs table destination run list
-- @param source string TeX-like expression source
-- @param options table inherited formatting flags
local function append_math_run(runs, source, options)
	runs[#runs + 1] = {
		text = "",
		math_source = source,
		options = copy_options(options),
		code = false,
		style_key = "math",
	}
end

--- Append one child run without flattening structured inline content
-- @param runs table destination run list
-- @param run table parsed child run
local function append_parsed_run(runs, run)
	if run.math_source then
		runs[#runs + 1] = run
		return
	end

	append_inline_run(runs, run.text, run.options, run.code)
end

--- Return true when an underscore delimiter sits inside an identifier
-- @param text string Markdown inline source
-- @param position number first delimiter byte
-- @param marker string emphasis delimiter
-- @return boolean intraword true when letters or numbers touch both sides
local function underscore_is_intraword(text, position, marker)
	if marker:sub(1, 1) ~= "_" then
		return false
	end

	-- Pattern breakdown: '%w' recognizes letters and numbers used in ordinary identifiers
	return text:sub(position - 1, position - 1):match("%w") ~= nil
		and text:sub(position + #marker, position + #marker):match("%w") ~= nil
end

--- Find an emphasis closer that is not an underscore inside an identifier
-- @param text string Markdown inline source
-- @param marker string emphasis delimiter
-- @param position number first candidate byte
-- @return number|nil close_position valid closing delimiter byte
local function find_emphasis_close(text, marker, position)
	while position <= #text do
		local close_position = markdown_escapes.find_unescaped(text, marker, position)
		if not close_position then
			return nil
		end
		if not underscore_is_intraword(text, close_position, marker) then
			return close_position
		end

		position = close_position + #marker
	end

	return nil
end

--- Parse supported inline Markdown markers into visible styled runs
-- Unmatched markers are copied literally
-- @param text string Markdown inline source
-- @param base_options table inherited heading or emphasis flags
-- @param reference_definitions table|nil normalized reference targets
-- @return table runs parsed visible runs
local function parse_inline(text, base_options, reference_definitions)
	local runs = {}
	local position = 1
	local marker_specs = {
		{ marker = "***", bold = true, italic = true },
		{ marker = "___", bold = true, italic = true },
		{ marker = "**", bold = true },
		{ marker = "__", bold = true },
		{ marker = "*", italic = true },
		{ marker = "_", italic = true },
	}

	while position <= #text do
		local source_character = text:sub(position, position)
		local escaped_character
		local escaped_next_position

		if source_character == "\\" then
			escaped_character, escaped_next_position = markdown_escapes.read(text, position)
		end

		if escaped_character and escaped_next_position then
			append_inline_run(runs, escaped_character, base_options)
			position = escaped_next_position
		elseif source_character == "`" then
			local close_position = text:find("`", position + 1, true)

			if close_position then
				append_inline_run(runs, text:sub(position + 1, close_position - 1), base_options, true)
				position = close_position + 1
			else
				append_inline_run(runs, "`", base_options)
				position = position + 1
			end
		elseif source_character == "$" then
			local math_source, next_position = markdown_math.read_inline(text, position)

			if math_source and next_position then
				append_math_run(runs, math_source, base_options)
				position = next_position
			else
				append_inline_run(runs, "$", base_options)
				position = position + 1
			end
		else
			local link_label, link_target, next_position =
				read_inline_link(text, position, reference_definitions)

			if link_label and link_target and next_position then
				local options = copy_options(base_options)
				options.link = link_target_is_supported(link_target) and link_target or nil
				local link_runs = parse_inline(link_label, options, reference_definitions)

				for run_index = 1, #link_runs do
					append_parsed_run(runs, link_runs[run_index])
				end

				position = next_position
			else
				local decoded
				local decoded_next_position

				if text:sub(position, position) == "&" then
					decoded, decoded_next_position = html_decode.decode_reference(text, position)
				end

				if decoded then
					append_inline_run(runs, decoded, base_options)
					position = decoded_next_position
				else
					local matched = false

					for spec_index = 1, #marker_specs do
						local spec = marker_specs[spec_index]
						local marker = spec.marker

						if text:sub(position, position + #marker - 1) == marker
							and not underscore_is_intraword(text, position, marker)
						then
							local close_position = find_emphasis_close(text, marker, position + #marker)

							if close_position and close_position > position + #marker then
								local options = copy_options(base_options)
								options.bold = options.bold or spec.bold == true
								options.italic = options.italic or spec.italic == true
								local inner_runs = parse_inline(
									text:sub(position + #marker, close_position - 1),
									options,
									reference_definitions
								)

								for run_index = 1, #inner_runs do
									append_parsed_run(runs, inner_runs[run_index])
								end

								position = close_position + #marker
								matched = true
								break
							end
						end
					end

					if not matched then
						append_inline_run(runs, text:sub(position, position), base_options)
						position = position + 1
					end
				end
			end
		end
	end

	return runs
end

--- Turn visible heading text into a stable Markdown anchor name
-- @param text string visible heading or fragment text
-- @return string anchor normalized anchor name
local function slugify_heading_text(text)
	local anchor = (text or ""):lower()

	-- Pattern breakdown:
	-- '%s+' turns each run of spacing into one anchor separator
	-- '[^%w_%-\128-\255]' removes punctuation except existing hyphens and underscores
	anchor = anchor:gsub("%s+", "-"):gsub("[^%w_%-\128-\255]", "")
	anchor = anchor:gsub("^-+", ""):gsub("-+$", "")

	return anchor ~= "" and anchor or "section"
end

--- Build one anchor from parsed heading runs
-- @param runs table parsed visible heading runs
-- @return string anchor normalized anchor name
local function make_heading_anchor(runs)
	local parts = {}

	for run_index = 1, #runs do
		parts[#parts + 1] = runs[run_index].text
	end

	return slugify_heading_text(table.concat(parts))
end

--- Build a read-only preview for one Markdown document
-- @param doc table Markdown document
function MarkdownPreview:new(doc)
	MarkdownPreview.super.new(self, doc)
	self.can_select = false
	self.can_edit = false
	self.has_cursor_model = false
	self.draw_cursor = false
	self.zoom_multiplier = 1
	self.preview_fonts = load_preview_fonts(self.zoom_multiplier)
	self.layout_rows = {}
	self.layout_height = 0
	self.layout_key = nil
	self.layout_change_id = nil
	self.layout_zoom_multiplier = nil
	self.pending_layout_width = nil
	self.pending_layout_since = nil
	self.resize_anchor = nil
	self.heading_anchors = {}
	self.math_references = {}
	self.math_equation_tags = {}
	self.document_scan_cache = nil
	self.outline_entries = {}
	-- Generated documents may hide an outline that adds no useful navigation
	local outline_visible = doc.markdown_outline_visible
	if outline_visible == nil then
		outline_visible = markdown_design.outline.visible
	end
	self.outline_visible = outline_visible ~= false
	self.outline_first_index = nil
	self.outline_active_anchor = nil
	self.outline_scrollbar = Scrollbar()
	self.jump_history = {}
	self.hovered_link = nil
	self.hover_tooltip = Tooltip()
	self.hover_tooltip_key = nil
	self.hover_tooltip_text = nil
	self.hover_tooltip_show_time = 0
	self.hover_tooltip_shown = false
	self.hover_observed_scroll_x = self.scroll.x or 0
	self.hover_observed_scroll_y = self.scroll.y or 0
	self.hover_observed_scroll_to_x = self.scroll.to.x or 0
	self.hover_observed_scroll_to_y = self.scroll.to.y or 0
end

--- Return key binding contexts for Markdown preview surface
-- @return table contexts ordered from most specific to broadest
function MarkdownPreview:get_keymap_contexts()
	return { "markdown_preview", "doc_view", "global" }
end

--- Keep shared zoom from changing editor fonts outside this preview
-- @return boolean apply_absolute_scale
function MarkdownPreview:should_apply_absolute_scale()
	return false
end

--- Return current view-local font zoom
-- @return number zoom_multiplier
function MarkdownPreview:get_zoom_multiplier()
	return self.zoom_multiplier
end

--- Return ordinary body-row height used by keyboard scrolling
-- @return number line_height body font height
function MarkdownPreview:get_line_height()
	return self.preview_fonts.body:get_height() * markdown_design.typography.line_height_scale
end

--- Scroll vertically by one body row using shared animated target
-- @param direction number negative for up, positive for down
function MarkdownPreview:scroll_vertical_step(direction)
	-- Ordinary scrolling resumes automatic outline tracking
	self.outline_active_anchor = nil
	self.scroll.to.y = (self.scroll.to.y or self.scroll.y or 0) + direction * self:get_line_height()
	self:clamp_scroll_position()
	self:prioritize_layout_for_y(self.scroll.to.y)
end

--- Scroll vertically by one visible page using text-viewer animation rules
-- @param direction number negative for up, positive for down
function MarkdownPreview:scroll_page(direction)
	-- Ordinary scrolling resumes automatic outline tracking
	self.outline_active_anchor = nil
	local viewport_height = select(4, self:get_content_viewport_rect())
	local line_height = self:get_line_height()
	local step = math.max(line_height, viewport_height - line_height)

	self.scroll.to.y = (self.scroll.to.y or self.scroll.y or 0) + direction * step
	self:clamp_scroll_position()
	self:prioritize_layout_for_y(self.scroll.to.y)

	if config.animate_page_scroll ~= true then
		self.scroll.y = self.scroll.to.y
	end
end

--- Jump immediately to top or bottom edge
-- @param to_end boolean|nil true jumps to bottom
function MarkdownPreview:scroll_to_vertical_edge(to_end)
	-- Ordinary navigation resumes automatic outline tracking
	self.outline_active_anchor = nil
	local target = to_end and self:get_scroll_max_y() or 0

	self.scroll.y = target
	self.scroll.to.y = target
	self:prioritize_layout_for_y(target)

	if to_end then
		target = self:get_scroll_max_y()
		self.scroll.y = target
		self.scroll.to.y = target
	end
end

--- Materialize estimated source neighborhood containing one scroll position
-- @param y number requested vertical scroll position
-- @return boolean changed true when new source lines were measured
function MarkdownPreview:prioritize_layout_for_y(y)
	if type(self.layout_priority_for_y) ~= "function" then
		return false
	end

	return self.layout_priority_for_y(y)
end

--- Return document viewport width without outer padding
-- @return number width available pane width
function MarkdownPreview:get_document_viewport_width()
	-- Detached or embedded views may provide their own document-only viewport
	return self.document_viewport_width_override
		or self.size.x - outline.get_width(self)
end

--- Return width available to rendered document text
-- @return number width content width
function MarkdownPreview:get_render_width()
	return math.max(
		markdown_design.document.minimum_content_width,
		self:get_document_viewport_width()
			- scale_measurement(markdown_design.document.padding_horizontal) * 2
	)
end

--- Return source line at one vertical position inside document viewport
-- @param viewport_offset number|nil distance below viewport top
-- @return number source_line
function MarkdownPreview:get_visible_source_line(viewport_offset)
	local document_y = (self.scroll.y or 0) + (viewport_offset or 0)
	local row_index = self:get_first_visible_layout_row(document_y)
	local row = self.layout_rows[row_index]

	return row and row.source_line or 1
end

--- Toggle local heading outline and preserve visible source position
-- @return boolean visible new outline state
function MarkdownPreview:toggle_outline()
	self:rebuild_layout()
	local anchor = self:capture_layout_anchor()
	-- Manual choice overrides automatic window-based visibility for this document
	self.outline_visible = not outline.is_visible(self)
	self.doc.markdown_outline_visible = self.outline_visible
	self.layout_key = nil
	self.pending_layout_width = nil
	self.pending_layout_since = nil
	self.resize_anchor = nil
	self:rebuild_layout(anchor)
	core.redraw = true

	return self.outline_visible
end

--- Resolve one run font inside this preview's private cache
-- @param heading_level number|nil heading level or body text
-- @param run table parsed inline run
-- @return userdata font styled run font
function MarkdownPreview:get_run_font(heading_level, run)
	local heading_style = heading_level and markdown_design.typography.heading_levels[heading_level]
	local logical_size = heading_style and markdown_design.get_heading_font_size(heading_level)
		or markdown_design.typography.font_size
	local options = copy_options(run.options)

	if run.code then
		-- Fixed-width faces often appear larger than proportional faces at same nominal size
		logical_size = logical_size * markdown_design.monospace.font_scale
	end

	if heading_style then
		apply_heading_font_style(options, heading_style)
	end

	local face = get_font_face_name(options)
	local key = table.concat({
		tostring(logical_size),
		face,
		string.format("%.4f", options.embolden),
		options.italic and "italic" or "upright",
		run.code and "code" or "text",
	}, ":")
	local cached = self.preview_fonts.by_style[key]

	if cached then
		return cached
	end

	local size = logical_size * (SCALE or 1) * self.zoom_multiplier
	local font

	if run.code then
		font = fontcache.load_group_with_fallbacks(
			FONTS_DIR .. "mono.ttf",
			size,
			fonts.get_active_fallback_paths(),
			{ embolden = options.embolden }
		)
	else
		font = fonts.load_style(size, options)
	end

	self.preview_fonts.by_style[key] = font

	return font
end

--- Resolve one math font without loading it outside optional preview
-- @param relative_scale number scale relative to preview body font
-- @param tex_style string TeX-like font style name
-- @param display_mode boolean|nil use separate display-math scale
-- @return userdata font selected font
function MarkdownPreview:get_math_font(relative_scale, tex_style, display_mode)
	local key = table.concat({
		"math",
		display_mode and "display" or "inline",
		string.format("%.4f", relative_scale),
		tex_style,
	}, ":")
	local cached = self.preview_fonts.by_style[key]

	if cached then
		return cached
	end

	local font_scale = display_mode
		and markdown_design.math.display_font_scale
		or markdown_design.math.font_scale
	local size = markdown_design.typography.font_size
		* font_scale
		* relative_scale
		* (SCALE or 1)
		* self.zoom_multiplier
	local text_family, text_weight, text_shape = tex_style:match(
		"^text:([^:]+):([^:]+):([^:]+)$"
	)
	local bold = tex_style == "mathbf" or tex_style == "boldsymbol"
		or text_weight == "bold"
	local font_options = { embolden = bold and 0.6 or 0 }
	local font

	if tex_style == "mathsf" or text_family == "sans" then
		-- Sans text commands use real regular, bold, italic, or bold-italic faces
		font = fonts.load_style(size, {
			bold = bold,
			italic = text_shape == "italic",
		})
	elseif tex_style == "mathtt" or text_family == "mono" then
		-- Typewriter math and verbatim text use preview's real monospace face
		font = fontcache.load_group_with_fallbacks(
			FONTS_DIR .. "mono.ttf",
			size,
			fonts.get_active_fallback_paths(),
			font_options
		)
	elseif tex_style == "mathscr" then
		-- Dedicated script face keeps 'mathscr' visibly distinct from 'mathcal'
		local fallback_paths = { FONTS_DIR .. "math.otf" }

		-- Main math and document fonts cover symbols omitted from the letter-only subset
		for _, path in ipairs(fonts.get_active_fallback_paths()) do
			fallback_paths[#fallback_paths + 1] = path
		end
		font = fontcache.load_group_with_fallbacks(
			FONTS_DIR .. "mathscr.otf",
			size,
			fallback_paths,
			font_options
		)
	elseif tex_style == "mathrm" or text_family == "roman" then
		-- Roman text keeps math face while gaining normal document fallbacks
		font = fontcache.load_group_with_fallbacks(
			FONTS_DIR .. "math.otf",
			size,
			fonts.get_active_fallback_paths(),
			font_options
		)
	else
		font = fontcache.load(FONTS_DIR .. "math.otf", size, font_options)
	end
	self.preview_fonts.by_style[key] = font

	return font
end

--- Resolve OpenType math measurements only after math font exists
-- @param display_mode boolean|nil true for a separate display formula
-- @return table options layout settings and native font measurements
function MarkdownPreview:get_math_layout_options(display_mode)
	local options = {}

	for key, value in pairs(markdown_design.math) do
		options[key] = value
	end

	local font = self:get_math_font(1, "mathit", display_mode)
	options.metrics = font:get_math_metrics()
	options.display_mode = display_mode == true
	options.references = self.math_references

	return options
end

--- Wrap parsed inline runs while preserving their fonts and code state
-- @param inline_runs table parsed inline runs
-- @param heading_level number|nil heading level or body text
-- @param width number available row width
-- @param break_long_words boolean|nil split tokens that cannot fit an empty row
-- @return table rows wrapped styled rows
function MarkdownPreview:wrap_inline_runs(inline_runs, heading_level, width, break_long_words)
	local rows = {}
	local row = { runs = {}, width = 0 }
	local pending_space_run
	local code_padding_horizontal = scale_measurement(markdown_design.code_spans.padding_horizontal)

	for run_index = 1, #inline_runs do
		local inline_run = inline_runs[run_index]
		if inline_run.math_source then
			local math_box = markdown_math.build(
				inline_run.math_source,
				function(relative_scale, tex_style)
					return self:get_math_font(relative_scale, tex_style, false)
				end,
				self:get_math_layout_options(false)
			)
			local space_font = pending_space_run and self:get_run_font(heading_level, pending_space_run) or nil
			local space_width = space_font and #row.runs > 0 and space_font:get_width(" ") or 0

			if #row.runs > 0 and row.width + space_width + math_box.width > width then
				rows[#rows + 1] = row
				row = { runs = {}, width = 0 }
				space_width = 0
			end

			if space_width > 0 then
				row.runs[#row.runs + 1] = {
					text = " ",
					font = space_font,
					width = space_width,
					code = false,
					options = pending_space_run.options,
				}
				row.width = row.width + space_width
			end

			row.runs[#row.runs + 1] = {
				text = "",
				font = self:get_math_font(1, "mathit", false),
				width = math_box.width,
				math_box = math_box,
				code = false,
				options = inline_run.options,
			}
			row.width = row.width + math_box.width
			pending_space_run = nil
		else
			local font = self:get_run_font(heading_level, inline_run)
			local position = 1
			local code_word_started = false

			while position <= #inline_run.text do
				local space_start, space_end = inline_run.text:find("%s+", position)

				if space_start == position then
					pending_space_run = inline_run
					position = space_end + 1
				else
					local word_end = space_start and space_start - 1 or #inline_run.text
					local word = inline_run.text:sub(position, word_end)
					local text_width = font:get_width(word)
					local code_padding_left = inline_run.code and not code_word_started and code_padding_horizontal or 0
					local code_padding_right = inline_run.code
							and inline_run.text:sub(word_end + 1):match("^%s*$")
							and code_padding_horizontal
						or 0
					local run_width = text_width + code_padding_left + code_padding_right
					local space_font = pending_space_run and self:get_run_font(heading_level, pending_space_run) or nil
					local space_width = space_font and #row.runs > 0 and space_font:get_width(" ") or 0

					if #row.runs > 0 and row.width + space_width + run_width > width then
						rows[#rows + 1] = row
						row = { runs = {}, width = 0 }
						space_width = 0
					end

					if space_width > 0 then
						row.runs[#row.runs + 1] = {
							text = " ",
							font = space_font,
							width = space_width,
							code = pending_space_run.code,
							options = pending_space_run.options,
							link_offset = 0,
							link_width = pending_space_run.options.link and space_width or 0,
						}
						row.width = row.width + space_width
					end

					if break_long_words and run_width > width then
						-- Table cells split an oversized token at UTF-8 character boundaries
						local chunk = ""
						local chunk_width = 0
						local character_position = 1
						local chunk_limit = math.max(1, width - code_padding_left - code_padding_right)

						while character_position <= #word do
							local character, _, next_position = common.next_utf8_char(word, character_position)
							local character_width = font:get_width(character)

							if chunk ~= "" and chunk_width + character_width > chunk_limit then
								row.runs[#row.runs + 1] = {
									text = chunk,
									font = font,
									width = chunk_width + code_padding_left,
									text_offset = code_padding_left,
									code = inline_run.code,
									options = inline_run.options,
									link_offset = 0,
									link_width = inline_run.options.link and chunk_width or 0,
								}
								row.width = row.width + chunk_width + code_padding_left
								rows[#rows + 1] = row
								row = { runs = {}, width = 0 }
								chunk = ""
								chunk_width = 0
								code_padding_left = 0
								chunk_limit = math.max(1, width - code_padding_right)
							end

							chunk = chunk .. character
							chunk_width = chunk_width + character_width
							character_position = next_position + 1
						end

						if chunk ~= "" then
							row.runs[#row.runs + 1] = {
								text = chunk,
								font = font,
								width = chunk_width + code_padding_left + code_padding_right,
								text_offset = code_padding_left,
								code = inline_run.code,
								options = inline_run.options,
								link_offset = 0,
								link_width = inline_run.options.link and chunk_width or 0,
							}
							row.width = row.width + chunk_width + code_padding_left + code_padding_right
						end
					else
						row.runs[#row.runs + 1] = {
							text = word,
							font = font,
							width = run_width,
							text_offset = code_padding_left,
							code = inline_run.code,
							options = inline_run.options,
							-- Include leading space so multi-word links get one continuous underline
							link_offset = 0,
							link_width = inline_run.options.link and run_width or 0,
						}
						row.width = row.width + run_width
					end
					code_word_started = true
					pending_space_run = nil
					position = word_end + 1
				end
			end
		end
	end

	if #row.runs > 0 then
		rows[#rows + 1] = row
	end

	return rows
end

--- Wrap literal code without collapsing its spaces
-- @param text string visible code text
-- @param width number available row width
-- @param tokens table|nil alternating syntax roles and text
-- @return table rows wrapped monospace rows
function MarkdownPreview:wrap_literal_text(text, width, tokens)
	local source_run = { text = "", options = {}, code = true }
	local font = self:get_run_font(nil, source_run)
	local rows = {}
	local row = { runs = {}, width = 0, min_height = font:get_height() }
	local segments = {}

	if type(tokens) == "table" then
		for token_index = 1, #tokens, 2 do
			segments[#segments + 1] = {
				type = tokens[token_index],
				text = tokens[token_index + 1] or "",
			}
		end
	else
		segments[1] = { text = text }
	end

	--- Append one same-color chunk to active visual row
	-- @param chunk string literal code
	-- @param chunk_width number measured width
	-- @param syntax_type string|nil tokenizer color role
	local function append_chunk(chunk, chunk_width, syntax_type)
		if chunk == "" then
			return
		end

		row.runs[#row.runs + 1] = {
			text = chunk,
			font = font,
			width = chunk_width,
			code = true,
			options = {},
			syntax_type = syntax_type,
		}
		row.width = row.width + chunk_width
	end

	for segment_index = 1, #segments do
		local segment = segments[segment_index]
		local chunk = ""
		local chunk_width = 0
		local position = 1

		while position <= #segment.text do
			local character, _, next_position = common.next_utf8_char(segment.text, position)
			local character_width = font:get_width(character)

			if row.width + chunk_width > 0 and row.width + chunk_width + character_width > width then
				append_chunk(chunk, chunk_width, segment.type)
				rows[#rows + 1] = row
				row = { runs = {}, width = 0, min_height = font:get_height() }
				chunk = ""
				chunk_width = 0
			end

			chunk = chunk .. character
			chunk_width = chunk_width + character_width
			position = next_position + 1
		end

		append_chunk(chunk, chunk_width, segment.type)
	end

	-- Empty source lines still need one visible code-block row
	if #row.runs > 0 or #rows == 0 then
		rows[#rows + 1] = row
	end

	return rows
end

--- Add one visible row to cached document layout
-- @param row table wrapped styled row
-- @param gap_before number space before this row
-- @param decoration table|nil preview-only block decoration
function MarkdownPreview:add_layout_row(row, gap_before, decoration)
	decoration = decoration or {}
	local height = row.min_height or 0
	local baseline = 0

	for run_index = 1, #row.runs do
		local run = row.runs[run_index]
		height = math.max(height, run.math_box and run.math_box.height or run.font:get_height())
		baseline = math.max(
			baseline,
			run.math_box and run.math_box.baseline or run.font:get_baseline()
		)
	end

	local natural_height = height
	local line_height_scale = decoration.fixed_height and 1
		or decoration.line_height_scale
		or markdown_design.typography.line_height_scale
	local line_height = natural_height * line_height_scale
	local line_leading = math.max(0, line_height - natural_height)
	local content_padding_top = decoration.content_padding_top or 0
	local content_padding_bottom = decoration.content_padding_bottom or 0
	height = line_height + content_padding_top + content_padding_bottom
	baseline = baseline + line_leading / 2 + content_padding_top

	self.layout_rows[#self.layout_rows + 1] = {
		runs = row.runs,
		height = height,
		baseline = baseline,
		y = self.layout_height + gap_before,
		x_offset = decoration.x_offset or 0,
		quote = decoration.quote,
		code_block = decoration.code_block,
		code_block_x_offset = decoration.code_block_x_offset,
		code_block_width = decoration.code_block_width,
		code_header = decoration.code_header == true,
		code_badge = decoration.code_badge,
		rule = decoration.rule == true,
		heading_separator = decoration.heading_separator,
		content_padding_bottom = content_padding_bottom,
		content_padding_top = content_padding_top,
		table_row = decoration.table_row,
		table_images = decoration.table_images,
		image_block = decoration.image_block,
		math_display = decoration.math_display,
		source_line = self.layout_source_line,
	}
	self.layout_height = self.layout_height + gap_before + height
end

--- Add a list marker to first wrapped item row
-- @param row table first wrapped content row
-- @param marker string visible list marker
-- @param marker_width number width reserved by marker
function MarkdownPreview:prepend_list_marker(row, marker, marker_width)
	local marker_run = { text = format_list_marker(marker), options = get_list_marker_options(marker), code = false }
	local font = self:get_run_font(nil, marker_run)

	table.insert(row.runs, 1, {
		text = marker_run.text,
		font = font,
		width = marker_width,
		code = false,
		options = marker_run.options,
	})
	row.width = row.width + marker_width
end

--- Capture source line currently touching preview's top edge
-- @return table anchor source line and pixel offset inside its rendered row
function MarkdownPreview:capture_layout_anchor()
	local scroll_y = self.scroll.y or 0
	local anchor_row = self.layout_rows[1]

	for row_index = 1, #self.layout_rows do
		local row = self.layout_rows[row_index]

		if row.y + row.height >= scroll_y then
			anchor_row = row
			break
		end
	end

	if not anchor_row then
		return { source_line = 1, source_row = 1, offset = 0, target_delta = 0 }
	end

	local source_row = 0

	for row_index = 1, #self.layout_rows do
		local row = self.layout_rows[row_index]

		if row.source_line == anchor_row.source_line then
			source_row = source_row + 1
		end
		if row == anchor_row then
			break
		end
	end

	return {
		source_line = anchor_row.source_line or 1,
		source_row = math.max(1, source_row),
		offset = scroll_y - anchor_row.y,
		target_delta = (self.scroll.to.y or scroll_y) - scroll_y,
	}
end

--- Restore a captured source line after its wrapped layout changes
-- @param anchor table source-line viewport anchor
function MarkdownPreview:restore_layout_anchor(anchor)
	if type(anchor) ~= "table" then
		return
	end

	local target_row
	local source_row = 0

	for row_index = 1, #self.layout_rows do
		local row = self.layout_rows[row_index]

		if row.source_line == anchor.source_line then
			source_row = source_row + 1
			target_row = row

			if source_row >= (anchor.source_row or 1) then
				break
			end
		end
	end

	if not target_row then
		return
	end

	local max_scroll_y = math.max(0, self.layout_height - self:get_scroll_viewport_height())
	local target_y = common.clamp(target_row.y + (anchor.offset or 0), 0, max_scroll_y)
	self.scroll.y = target_y
	self.scroll.to.y = common.clamp(target_y + (anchor.target_delta or 0), 0, max_scroll_y)
end

--- Capture visible Markdown source position before an automatic reload
-- @return table anchor source line and pixel offset inside its rendered row
function MarkdownPreview:capture_reload_view_state()
	return self:capture_layout_anchor()
end

--- Rebuild changed Markdown and restore its previous source position
-- @param anchor table|nil captured source-line viewport anchor
function MarkdownPreview:restore_reload_view_state(anchor)
	self.layout_key = nil
	self:clear_hover_tooltip()
	self:rebuild_layout(anchor)
end

--- Return document-wide Markdown scans, reusing them while source stays unchanged
-- @param change_id number current document change identifier
-- @return table scans references, math, parsed inline runs, and block ownership
function MarkdownPreview:get_document_scans(change_id)
	if self.document_scan_cache
		and self.document_scan_cache.change_id == change_id
	then
		return self.document_scan_cache
	end

	local reference_definitions, reference_definition_lines =
		references.scan(self.doc.lines, strip_line_ending, read_fence)
	local math_block_contexts =
		markdown_math.scan_blocks(self.doc.lines, strip_line_ending, read_fence)
	local math_references, math_equation_tags = markdown_math.scan_references(
		self.doc.lines,
		strip_line_ending,
		read_fence,
		math_block_contexts
	)

	-- Width and zoom do not affect these source-only results
	self.document_scan_cache = {
		change_id = change_id,
		reference_definitions = reference_definitions,
		reference_definition_lines = reference_definition_lines,
		math_block_contexts = math_block_contexts,
		math_references = math_references,
		math_equation_tags = math_equation_tags,
		inline_runs = {},
	}

	return self.document_scan_cache
end

--- Rebuild wrapped preview rows when text, width, or zoom changes
-- @param forced_anchor table|nil top-left source position that must be ready before returning
function MarkdownPreview:rebuild_layout(forced_anchor)
	-- New views briefly have zero geometry before workspace assigns their real pane
	if self.size.x <= 0 or self.size.y <= 0 then
		return
	end

	local width = self:get_render_width()
	local change_id = self.doc:get_change_id()
	local layout_key = table.concat({ change_id, tostring(width), tostring(self.zoom_multiplier) }, ":")

	if self.layout_key == layout_key then
		return
	end

	local width_only_change = self.layout_key ~= nil
		and self.layout_change_id == change_id
		and self.layout_zoom_multiplier == self.zoom_multiplier

	if width_only_change then
		local now = system.get_time()

		if not self.resize_anchor then
			self.resize_anchor = self:capture_layout_anchor()
		end

		if self.pending_layout_width ~= width then
			self.pending_layout_width = width
			self.pending_layout_since = now
			-- Stop obsolete background wrapping while pane keeps moving
			self.layout_generation = (self.layout_generation or 0) + 1
		end

		-- Keep drawing cached visible rows until an animated pane stops changing width
		if now - (self.pending_layout_since or now) < RESIZE_REFLOW_DELAY then
			core.redraw = true
			return
		end
	else
		self.pending_layout_width = nil
		self.pending_layout_since = nil
		self.resize_anchor = nil
	end

	local resize_anchor = forced_anchor or self.resize_anchor
	self.layout_rows = {}
	self.layout_height = 0
	self.estimated_layout_height = nil
	self.layout_complete = false
	self.layout_generation = (self.layout_generation or 0) + 1
	local layout_generation = self.layout_generation
	self.heading_anchors = {}
	self.layout_key = layout_key
	self.layout_change_id = change_id
	self.layout_zoom_multiplier = self.zoom_multiplier
	self.pending_layout_width = nil
	self.pending_layout_since = nil
	self.resize_anchor = nil
	local document_scans = self:get_document_scans(change_id)
	local reference_definitions = document_scans.reference_definitions
	local reference_definition_lines = document_scans.reference_definition_lines
	local math_block_contexts = document_scans.math_block_contexts
	self.math_references = document_scans.math_references
	self.math_equation_tags = document_scans.math_equation_tags

	--- Parse inline Markdown against every definition in this document
	-- @param text string Markdown inline source
	-- @param options table inherited formatting
	-- @return table runs visible styled runs
	local function parse_document_inline(text, options)
		local cache_key = table.concat({
			text,
			options.bold and "bold" or "regular",
			tostring(tonumber(options.embolden) or 0),
			options.italic and "italic" or "upright",
			options.light and "light" or "normal",
			options.heavy and "heavy" or "normal",
			options.link or "",
		}, "\0")
		local cached_runs = document_scans.inline_runs[cache_key]

		if not cached_runs then
			cached_runs = parse_inline(text, options, reference_definitions)
			document_scans.inline_runs[cache_key] = cached_runs
		end

		return cached_runs
	end

	local body_font = self.preview_fonts.body
	local monospace_font = self:get_run_font(nil, { options = {}, code = true })
	local space_width = body_font:get_width(" ")
	local block_indent = space_width * markdown_design.quotes.base_indent_spaces
	local quote_block_offset = block_indent * markdown_design.quotes.block_offset_scale
	local quote_text_offset = block_indent * markdown_design.quotes.text_offset_scale
	local quote_padding_y = scale_measurement(markdown_design.quotes.padding_vertical)
	local quote_gap_below = scale_measurement(markdown_design.quotes.gap_below)
	local code_gap_above = scale_measurement(markdown_design.code_blocks.gap_above)
	local code_padding_x = scale_measurement(markdown_design.code_blocks.padding_horizontal)
	local code_padding_y = scale_measurement(markdown_design.code_blocks.padding_vertical)
	local code_header_padding_x = scale_measurement(
		markdown_design.code_blocks.header.padding_horizontal
	)
	local code_header_padding_y = scale_measurement(
		markdown_design.code_blocks.header.padding_vertical
	)
	local code_header_font = self.preview_fonts.code_header
	local code_header_height = code_header_font
		and code_header_font:get_height() * markdown_design.typography.line_height_scale
			+ code_header_padding_y * 2
		or 0
	local code_badge_font = self.preview_fonts.code_badge
	local cached_structure = document_scans.structure
	local fence_marker
	local line_contexts = cached_structure and cached_structure.line_contexts or {}
	local compact_box_drawing_lines = {}
	local scanned_fence_marker
	local scanned_code_block
	local scanned_code_row_count = 0
	local previous_code_context
	local scanned_indented_code = false
	local pending_indented_code_contexts = {}
	local scanned_anchor_counts = {}
	local anchor_source_lines = cached_structure and cached_structure.anchor_source_lines or {}
	local outline_entries = cached_structure and cached_structure.outline_entries or {}
	local estimated_source_heights = {}
	local average_character_width = body_font:get_width("abcdefghijklmnopqrstuvwxyz") / 26
	local has_prior_source_spacing = false
	local previous_quote_context

	if markdown_design.box_drawings.auto_detect then
		if not document_scans.box_drawing_lines then
			local inside_drawing_block = false
			document_scans.box_drawing_lines = {}

			for line_index = 1, #self.doc.lines do
				local line = strip_line_ending(self.doc.lines[line_index])

				if line:match("^%s*$") then
					-- Blank line ends temporary drawing block
					inside_drawing_block = false
				elseif inside_drawing_block or has_box_drawing_character(line) then
					-- First drawing character starts block; following nonblank lines finish it
					inside_drawing_block = true
					document_scans.box_drawing_lines[line_index] = true
				end
			end
		end

		-- Source-only detection is reused when resize or zoom rebuilds the layout
		compact_box_drawing_lines = document_scans.box_drawing_lines
	end
	local quote_block_contexts = {}
	local quote_block_has_visible_content = false
	-- Continuation ownership must survive lazy layout starting at any source line
	local active_list_context

	--- Finish one quote block and hide it when every quoted row is empty
	local function close_quote_block()
		if not previous_quote_context then
			return
		end

		previous_quote_context.quote_last = true

		if quote_block_has_visible_content then
			-- A real quote counts as content only after its complete block is known
			has_prior_source_spacing = true
		else
			-- Empty quote markers retain source mapping without drawing an empty panel
			for context_index = 1, #quote_block_contexts do
				local context = quote_block_contexts[context_index]
				context.suppress_empty_quote = true
				context.estimated_height = 0
				estimated_source_heights[context.line_index] = 0
			end
		end

		previous_quote_context = nil
		quote_block_contexts = {}
		quote_block_has_visible_content = false
	end

	--- Append one scanned source row to current code block
	-- @param context table source-line context
	-- @param text string literal code text after structural indentation
	-- @param indented boolean true for an indented rather than fenced block
	local function append_scanned_code_line(context, text, indented)
		context.code_first = scanned_code_row_count == 0
		context.code_block = scanned_code_block
		context.code_text = text
		context.indented_code = indented == true
		context.code_line_index = code_blocks.add_line(scanned_code_block, text)
		if scanned_code_block.math then
			context.math_block = scanned_code_block
			context.math_first = context.code_first
		end
		previous_code_context = context
		scanned_code_row_count = scanned_code_row_count + 1
	end

	--- Finish current scanned code block
	local function close_scanned_code_block()
		if previous_code_context then
			previous_code_context.code_last = true
		end

		scanned_code_block = nil
		scanned_code_row_count = 0
		previous_code_context = nil
		scanned_indented_code = false
		pending_indented_code_contexts = {}
	end

	--- Start one plain indented code block
	local function start_indented_code_block()
		scanned_code_block = code_blocks.new()
		scanned_code_row_count = 0
		previous_code_context = nil
		scanned_indented_code = true
		pending_indented_code_contexts = {}
	end

	--- Add deferred blank rows after another indented code row confirms block continues
	local function flush_indented_code_blanks()
		for context_index = 1, #pending_indented_code_contexts do
			append_scanned_code_line(pending_indented_code_contexts[context_index], "", true)
		end

		pending_indented_code_contexts = {}
	end

	--- Estimate wrapped height without measuring every word before it becomes visible
	-- @param text string source text
	-- @param font userdata estimated rendering font
	-- @param available_width number estimated wrapping width
	-- @param first_gap number gap before first rendered row
	-- @param later_gap number gap before later wrapped rows
	-- @param line_height_scale number|nil optional compact row scale
	-- @return number height estimated source-line height
	local function estimate_text_height(text, font, available_width, first_gap, later_gap, line_height_scale)
		local character_count = utf8.len(text) or #text
		local estimated_width = character_count * average_character_width
		local row_count = math.max(1, math.ceil(estimated_width / math.max(1, available_width)))

		return row_count * font:get_height()
			* (line_height_scale or markdown_design.typography.line_height_scale)
			+ first_gap
			+ math.max(0, row_count - 1) * later_gap
	end

	--- Attach shared heading details and one stable anchor to a source context
	-- @param context table source-line context
	-- @param heading_level number heading level
	-- @param heading_text string visible heading text
	local function register_heading(context, heading_level, heading_text)
		context.heading_level = heading_level
		context.heading_text = heading_text
		local heading_runs = parse_document_inline(heading_text, {})
		local base_anchor = make_heading_anchor(heading_runs)
		local duplicate_index = scanned_anchor_counts[base_anchor] or 0
		context.anchor = duplicate_index == 0 and base_anchor or base_anchor .. "-" .. duplicate_index
		scanned_anchor_counts[base_anchor] = duplicate_index + 1
		anchor_source_lines[context.anchor] = context.line_index

		local visible_parts = {}

		for run_index = 1, #heading_runs do
			visible_parts[#visible_parts + 1] = heading_runs[run_index].text
		end

		outline_entries[#outline_entries + 1] = {
			text = table.concat(visible_parts),
			level = heading_level,
			anchor = context.anchor,
			source_line = context.line_index,
		}
	end

	--- Estimate one heading using same spacing as measured layout
	-- @param context table heading source context
	-- @return number height estimated height
	local function estimate_heading_height(context)
		local heading_probe = { options = {}, code = false }
		local heading_font = self:get_run_font(context.heading_level, heading_probe)

		return estimate_text_height(
			context.heading_text,
			heading_font,
			width,
			context.suppress_heading_gap
				and 0
				or scale_measurement(markdown_design.get_heading_gap_above(context.heading_level)),
			scale_measurement(markdown_design.paragraphs.wrapped_line_gap)
		)
			+ scale_measurement(markdown_design.get_heading_gap_below(context.heading_level))
			+ (markdown_design.is_heading_separator_enabled(context.heading_level)
				and markdown_design.get_heading_separator_width(context.heading_level)
				or 0)
	end

	--- Measure marker and text offsets shared by one list item
	-- @param marker string visible list marker
	-- @param indent number source indentation columns
	-- @param context table source line context
	-- @return number marker_offset marker left edge
	-- @return number text_offset item text left edge
	local function get_list_offsets(marker, indent, context)
		local marker_offset = scale_measurement(markdown_design.lists.base_indent)
			+ indent * space_width * markdown_design.lists.nested_indent_scale

		if context.list_parent then
			local _, parent_text_offset = get_list_offsets(
				context.list_parent.list_marker,
				context.list_parent.list_indent,
				context.list_parent
			)

			-- Child markers begin where their parent item text begins
			marker_offset = parent_text_offset
		end

		local marker_probe = {
			text = format_list_marker(marker),
			options = get_list_marker_options(marker),
			code = false,
		}
		local marker_width = self:get_run_font(nil, marker_probe):get_width(marker_probe.text)

		return marker_offset, marker_offset + marker_width
	end

	--- Refresh one source line's approximate height for current width and zoom
	-- @param line_index number one-based source line
	-- @param context table cached structural context
	-- @param line string source text without its line ending
	-- @param blank_line boolean true when source line contains only whitespace
	-- @param scanning_structure boolean true while discovering first-content ownership
	local function estimate_source_context(
		line_index,
		context,
		line,
		blank_line,
		scanning_structure
	)
		if context.math_delimiter or context.math_block and not context.math_first then
			context.estimated_height = 0
		elseif context.math_first then
			context.estimated_height = body_font:get_height() * 3
				+ scale_measurement(markdown_design.math.display_gap_above)
				+ scale_measurement(markdown_design.math.display_padding_vertical) * 2
		elseif context.opened_code_block
			and code_header_font
			and context.opened_code_block.language_label
			and not context.opened_code_block.math
		then
			-- Opening fence owns optional header's source mapping and estimated height
			context.estimated_height = code_gap_above + code_header_height
		elseif context.is_fence_delimiter
			or context.reference_definition
			or context.setext_delimiter
		then
			context.estimated_height = 0
		elseif context.heading_level then
			if scanning_structure then
				context.suppress_heading_gap = not has_prior_source_spacing
					or context.preceded_by_blank_line
			end

			context.estimated_height = estimate_heading_height(context)
		elseif context.image_target then
			context.estimated_height = math.min(width * 0.5625, body_font:get_height() * 12)
				+ images.get_block_gap() * 2
		elseif context.list_text or context.list_continuation_text then
			local _, text_offset = get_list_offsets(
				context.list_marker,
				context.list_indent,
				context
			)

			context.estimated_height = estimate_text_height(
				context.list_text or context.list_continuation_text,
				body_font,
				math.max(markdown_design.document.minimum_content_width, width - text_offset),
				context.list_text and scale_measurement(markdown_design.lists.item_gap) or 0,
				scale_measurement(markdown_design.paragraphs.wrapped_line_gap)
			)
		elseif context.skip_initial_blank_line then
			-- Leading blank source lines retain their line mapping without adding preview height
			context.estimated_height = 0
		elseif blank_line then
			-- Pattern breakdown: '^%s*$' matches an empty or whitespace-only source line
			context.estimated_height = scale_measurement(markdown_design.paragraphs.paragraph_gap)
		else
			local compact_drawing = compact_box_drawing_lines[line_index] == true
			context.estimated_height = estimate_text_height(
				line,
				compact_drawing and monospace_font or body_font,
				width,
				compact_drawing and 0
					or scale_measurement(markdown_design.paragraphs.source_line_gap),
				compact_drawing and 0
					or scale_measurement(markdown_design.paragraphs.wrapped_line_gap),
				compact_drawing and 1 or nil
			)
		end

		if context.follows_list then
			context.estimated_height = context.estimated_height
				+ scale_measurement(markdown_design.lists.gap_below)
		end
		estimated_source_heights[line_index] = context.estimated_height

		if scanning_structure and context.estimated_height > 0 and context.quote_text == nil then
			context.is_first_rendered_content = not has_prior_source_spacing
			has_prior_source_spacing = true
		end
	end

	-- Scan only lightweight block state so any visible source line can be laid out first
	local first_structure_line = cached_structure and #self.doc.lines + 1 or 1

	for line_index = first_structure_line, #self.doc.lines do
		local line = strip_line_ending(self.doc.lines[line_index])
		local previous_line = line_index > 1
			and strip_line_ending(self.doc.lines[line_index - 1])
			or nil
		local possible_fence = read_fence(line)
		local first_character = line:sub(1, 1)
		local indented_code_text

		if first_character == " " or first_character == "\t" then
			indented_code_text = read_indented_code(line)
		end

		local blank_line = line:match("^%s*$") ~= nil
		-- Pattern breakdown: '^%s*$' matches an empty or whitespace-only source line
		local preceded_by_blank_line = previous_line ~= nil
			and previous_line:match("^%s*$") ~= nil
		local context = {
			fence_marker = scanned_fence_marker,
			line_index = line_index,
			preceded_by_blank_line = preceded_by_blank_line,
			skip_initial_blank_line = markdown_design.document.skip_initial_blank_lines
				and blank_line
				and not has_prior_source_spacing,
		}
		context.reference_definition = reference_definition_lines[line_index] == true
		local math_block_context = math_block_contexts[line_index]
		line_contexts[line_index] = context
		local handled_indented_code = false

		if math_block_context then
			close_quote_block()
			close_scanned_code_block()
			active_list_context = nil
			context.math_block = math_block_context.block
			context.math_first = math_block_context.first == true
			context.math_delimiter = math_block_context.delimiter == true
		elseif scanned_indented_code then
			if blank_line then
				-- Blank rows join block only when another indented row follows
				pending_indented_code_contexts[#pending_indented_code_contexts + 1] = context
				handled_indented_code = true
			elseif indented_code_text ~= nil then
				flush_indented_code_blanks()
				append_scanned_code_line(
					context,
					markdown_escapes.decode(indented_code_text),
					true
				)
				handled_indented_code = true
			else
				close_scanned_code_block()
			end
		end

		if math_block_context then
			-- Display-math source belongs to its own block and bypasses Markdown parsing
		elseif handled_indented_code then
			close_quote_block()
			active_list_context = nil
		elseif scanned_fence_marker then
			close_quote_block()
			active_list_context = nil

			local remainder = possible_fence and line:gsub("^%s*" .. possible_fence, "", 1) or line
			local closes_fence = possible_fence
				and possible_fence:sub(1, 1) == scanned_fence_marker:sub(1, 1)
				and #possible_fence >= #scanned_fence_marker
				and remainder:match("^%s*$") ~= nil

			if closes_fence then
				context.is_fence_delimiter = true
				close_scanned_code_block()
				scanned_fence_marker = nil
			else
				append_scanned_code_line(context, line, false)
			end
		elseif possible_fence then
			close_quote_block()
			local attached_list_context = active_list_context
			active_list_context = nil

			context.is_fence_delimiter = true
			scanned_fence_marker = possible_fence
			scanned_code_block = code_blocks.new(read_fence_info(line, possible_fence))
			scanned_code_block.math = scanned_code_block.language == "math"
			context.opened_code_block = scanned_code_block
			-- An immediate fence belongs to active list item and shares its text edge
			scanned_code_block.list_context = attached_list_context
			scanned_code_row_count = 0
			previous_code_context = nil
		elseif context.reference_definition then
			close_quote_block()
			active_list_context = nil
		else
			local heading_level, heading_text = read_heading(line)
			local setext_level = read_setext_underline(line)
			local image_alt_text, image_target, image_link_target, image_title = images.read_block(line)
			local quote_text = read_blockquote(line)
			local list_marker, list_text, list_indent = read_list_item(line)
			local continuation_text
			local continuation_indent

			if active_list_context then
				continuation_text, continuation_indent = read_list_continuation(line)
			end

			local previous_list_context = active_list_context
			local previous_context = line_contexts[line_index - 1]
			local follows_block_boundary = not previous_line
				or previous_line:match("^%s*$") ~= nil
				or previous_context and (
					previous_context.heading_level
					or previous_context.image_target
					or previous_context.quote_text ~= nil
					or previous_context.list_text
					or previous_context.list_continuation_text
					or previous_context.code_block
					or previous_context.is_fence_delimiter
					or previous_context.setext_delimiter
				)
				or is_horizontal_rule(previous_line or "")

			if setext_level and can_be_setext_heading(previous_line or "", previous_context) then
				close_quote_block()
				active_list_context = nil

				context.setext_delimiter = true
				register_heading(previous_context, setext_level, previous_line)
				previous_context.suppress_heading_gap =
					previous_context.is_first_rendered_content == true
					or previous_context.preceded_by_blank_line
				previous_context.estimated_height = estimate_heading_height(previous_context)
				estimated_source_heights[line_index - 1] = previous_context.estimated_height
			elseif quote_text ~= nil then
				active_list_context = nil
				context.quote_text = quote_text
				context.quote_first = previous_quote_context == nil
				previous_quote_context = context
				quote_block_contexts[#quote_block_contexts + 1] = context
				quote_block_has_visible_content =
					quote_block_has_visible_content or quote_text:match("%S") ~= nil
			elseif quote_block_has_visible_content
				and previous_quote_context
				and is_lazy_quote_continuation(line)
			then
				active_list_context = nil
				context.quote_text = line
				context.quote_first = false
				previous_quote_context = context
				quote_block_contexts[#quote_block_contexts + 1] = context
			elseif heading_level then
				close_quote_block()
				active_list_context = nil

				register_heading(context, heading_level, heading_text)
			elseif list_text
				and active_list_context
				and not active_list_context.has_pending_blank_line
			then
				close_quote_block()

				-- A marked child remains a nested item instead of plain continuation text
				context.list_marker = list_marker
				context.list_text = list_text
				context.list_indent = list_indent
				context.list_parent = find_list_parent(active_list_context, list_marker, list_indent)
				active_list_context = context
			elseif active_list_context
				and continuation_text
				and continuation_indent > active_list_context.list_indent
			then
				close_quote_block()

				-- Structural indentation disappears while inherited text edge remains
				context.list_continuation_text = continuation_text
				context.list_marker = active_list_context.list_marker
				context.list_indent = active_list_context.list_indent
				context.list_parent = active_list_context.list_parent
				active_list_context.has_pending_blank_line = nil
			elseif active_list_context
				and not active_list_context.has_pending_blank_line
				and not blank_line
				and not image_target
				and not is_horizontal_rule(line)
			then
				close_quote_block()

				-- CommonMark permits an unindented paragraph line to continue active item
				context.list_continuation_text = line
				context.list_marker = active_list_context.list_marker
				context.list_indent = active_list_context.list_indent
				context.list_parent = active_list_context.list_parent
			elseif indented_code_text ~= nil and not blank_line and follows_block_boundary then
				close_quote_block()
				active_list_context = nil

				start_indented_code_block()
				append_scanned_code_line(
					context,
					markdown_escapes.decode(indented_code_text),
					true
				)
			elseif image_target then
				close_quote_block()
				active_list_context = nil

				context.image_alt_text = image_alt_text
				context.image_target = image_target
				context.image_link_target = link_target_is_supported(image_link_target)
					and image_link_target or nil
				context.image_title = image_title
			elseif list_text then
				close_quote_block()

				-- Store complete list ownership on source context used by lazy layout
				context.list_marker = list_marker
				context.list_text = list_text
				context.list_indent = list_indent
				active_list_context = context
			elseif blank_line
				and active_list_context
				and not active_list_context.has_pending_blank_line
			then
				-- One blank may separate a list marker from its indented continuation block
				active_list_context.has_pending_blank_line = true
			elseif previous_quote_context then
				close_quote_block()
				active_list_context = nil
			else
				active_list_context = nil
			end

			-- First non-list block owns visual separation after a completed list
			context.follows_list = previous_list_context ~= nil
				and active_list_context == nil
				and not blank_line
		end

		estimate_source_context(line_index, context, line, blank_line, true)
	end

	close_quote_block()

	close_scanned_code_block()

	if cached_structure then
		-- Width and zoom change estimates, but not Markdown ownership already discovered
		for line_index = 1, #line_contexts do
			local line = strip_line_ending(self.doc.lines[line_index])
			local blank_line = line:match("^%s*$") ~= nil
			-- Pattern breakdown: '^%s*$' matches an empty or whitespace-only source line
			estimate_source_context(line_index, line_contexts[line_index], line, blank_line, false)
		end
	else
		-- Structural ownership depends only on source text, so later reflows can reuse it
		document_scans.structure = {
			line_contexts = line_contexts,
			anchor_source_lines = anchor_source_lines,
			outline_entries = outline_entries,
		}
	end

	-- Quote estimates include only outer padding of complete merged block
	for line_index = 1, #line_contexts do
		local context = line_contexts[line_index]

		if context.suppress_empty_quote then
			-- A quote block containing only markers has no visible preview height
			context.estimated_height = 0
			estimated_source_heights[line_index] = 0
		elseif context.quote_text ~= nil then
			context.estimated_height = estimate_text_height(
				context.quote_text,
				body_font,
				math.max(markdown_design.document.minimum_content_width, width - quote_text_offset),
				0,
				scale_measurement(markdown_design.paragraphs.wrapped_line_gap)
			)
				+ (context.quote_first and quote_padding_y or 0)
				+ (context.quote_last and quote_padding_y or 0)
				+ (context.quote_last and quote_gap_below or 0)
			estimated_source_heights[line_index] = context.estimated_height
		elseif context.code_block then
			-- Lazy height estimates must match compact drawing rows before they become visible
			local compact_drawing = compact_box_drawing_lines[line_index] == true
			local list_context = context.code_block.list_context
			local code_block_x_offset = 0
			if list_context then
				local _, list_text_offset = get_list_offsets(
					list_context.list_marker,
					list_context.list_indent,
					list_context
				)
				code_block_x_offset = list_text_offset
			end

			context.estimated_height = estimate_text_height(
				context.code_text,
				monospace_font,
				math.max(
					markdown_design.document.minimum_content_width,
					width - code_block_x_offset - code_padding_x * 2
				),
				0,
				0,
				compact_drawing and 1 or markdown_design.code_blocks.line_height_scale
			)
				+ (
					context.code_first
					and not (code_header_font and context.code_block.language_label)
					and code_gap_above
					or 0
				)
				+ (context.code_first and code_padding_y or 0)
				+ (context.code_last and code_padding_y or 0)
			estimated_source_heights[line_index] = context.estimated_height
		end
	end

	local table_definitions = tables.scan(self.doc.lines, line_contexts, {
		strip_line_ending = strip_line_ending,
		parse_inline = parse_document_inline,
		definitions = cached_structure and cached_structure.table_definitions,
		width = width,
		body_font = body_font,
		line_height = body_font:get_height() * markdown_design.typography.line_height_scale,
		average_character_width = average_character_width,
	}, estimated_source_heights)

	if not cached_structure then
		document_scans.structure.table_definitions = table_definitions
	end

	self.layout_estimated_source_heights = estimated_source_heights

	--- Resolve left edge and width shared by one code block and its header
	-- @param code_block table scanned code block
	-- @return number x_offset left edge inside document content
	-- @return number block_width available block width
	local function get_code_block_geometry(code_block)
		local list_context = code_block.list_context
		local code_block_x_offset = 0

		if list_context then
			local _, list_text_offset = get_list_offsets(
				list_context.list_marker,
				list_context.list_indent,
				list_context
			)
			code_block_x_offset = list_text_offset
		end

		-- A deeply nested block may overflow, but its background must always remain drawable
		return code_block_x_offset, math.max(1, width - code_block_x_offset)
	end

	--- Lay out optional language row owned by an opening fence
	-- @param code_block table scanned fenced-code block
	local function layout_code_header(code_block)
		if not code_header_font or not code_block or not code_block.language_label
			or code_block.math
		then
			return
		end

		local code_block_x_offset, code_block_width = get_code_block_geometry(code_block)
		local label = code_block.language_label
		local row = {
			runs = {
				{
					text = label,
					font = code_header_font,
					width = code_header_font:get_width(label),
					options = {},
				},
			},
		}

		self:add_layout_row(
			row,
			code_gap_above,
			{
				x_offset = code_block_x_offset + code_header_padding_x,
				content_padding_top = code_header_padding_y,
				content_padding_bottom = code_header_padding_y,
				code_block = code_block,
				code_block_x_offset = code_block_x_offset,
				code_block_width = code_block_width,
				code_header = true,
			}
		)
	end

	--- Lay out one literal row from either fenced or indented code
	-- @param context table scanned code-line context
	local function layout_code_line(context)
		-- Detected drawings override the comfortable line height used by ordinary code
		local compact_drawing = compact_box_drawing_lines[self.layout_source_line] == true
		local code_block_x_offset, code_block_width = get_code_block_geometry(context.code_block)
		local code_width = math.max(
			markdown_design.document.minimum_content_width,
			code_block_width - code_padding_x * 2
		)
		local code_tokens = code_blocks.get_tokens(context.code_block, context.code_line_index)
		local wrapped = self:wrap_literal_text(context.code_text, code_width, code_tokens)

		for wrapped_index = 1, #wrapped do
			local is_first_row = context.code_first and wrapped_index == 1
			local is_last_row = context.code_last and wrapped_index == #wrapped
			local has_header = code_header_font and context.code_block.language_label
			local badge_label = is_first_row
				and not has_header
				and code_badge_font
				and context.code_block.language_label
				or nil

			self:add_layout_row(
				wrapped[wrapped_index],
				is_first_row and not has_header and code_gap_above or 0,
				{
					x_offset = code_block_x_offset + code_padding_x,
					content_padding_top = is_first_row and code_padding_y or 0,
					content_padding_bottom = is_last_row and code_padding_y or 0,
					code_block = context.code_block,
					code_block_x_offset = code_block_x_offset,
					code_block_width = code_block_width,
					code_badge = badge_label,
					fixed_height = compact_drawing,
					line_height_scale = markdown_design.code_blocks.line_height_scale,
				}
			)
		end
	end

	--- Lay out one complete display expression as a single drawing box
	-- @param math_block table scanned display-math block
	local function layout_math_block(math_block)
		local source = table.concat(math_block.lines or {}, "\n")
		local padding_x = scale_measurement(markdown_design.math.display_padding_horizontal)
		local padding_y = scale_measurement(markdown_design.math.display_padding_vertical)
		local options = self:get_math_layout_options(true)
		options.display_width = math.max(1, width - padding_x * 2)
		local font_scale = 1

		if math_block.first_line then
			for line_index = math_block.first_line + 1, (math_block.last_line or 1) - 1 do
				if self.math_equation_tags[line_index] then
					options.equation_tags = self.math_equation_tags[line_index]
					break
				end
			end
		end

		--- Build this display formula at one scale relative to requested preview zoom
		-- High zoom can make natural formula wider than document
		-- Caller rebuilds it smaller to prevent clipping outside preview area
		-- @return table box measured display formula
		local function build_math_box()
			options.metrics = self:get_math_font(font_scale, "mathit", true):get_math_metrics()

			return markdown_math.build(
				source,
				function(relative_scale, tex_style)
					return self:get_math_font(relative_scale * font_scale, tex_style, true)
				end,
				options
			)
		end

		local math_box = build_math_box()

		if math_box.width > options.display_width then
			-- Oversized display formulas shrink uniformly instead of disappearing past right edge
			font_scale = options.display_width / math_box.width
			math_box = build_math_box()

			if math_box.width > options.display_width then
				-- One bucketed correction handles small-font rounding without per-pixel retries
				local fit_width = math.max(1, math.floor((options.display_width - 16) / 16) * 16)
				font_scale = font_scale * fit_width / math_box.width
				math_box = build_math_box()
			end
		end

		if math_box.width > options.display_width then
			-- Minimum glyphs may need display padding to use remaining panel width
			padding_x = math.max(0, math.floor((width - math_box.width) / 2))
		end

		self:add_layout_row(
			{ runs = {}, min_height = math_box.height },
			scale_measurement(markdown_design.math.display_gap_above),
			{
				fixed_height = true,
				content_padding_top = padding_y,
				content_padding_bottom = padding_y,
				math_display = {
					box = math_box,
					padding_horizontal = padding_x,
				},
			}
		)
	end

	--- Lay out one source line while retaining surrounding block state
	-- @param line_index number one-based source line
	local function layout_source_line(line_index)
		self.layout_source_line = line_index
		local context = line_contexts[line_index]
		fence_marker = context.fence_marker
		local line = strip_line_ending(self.doc.lines[line_index])
		local possible_fence = read_fence(line)

		if context.follows_list then
			self.layout_height = self.layout_height
				+ scale_measurement(markdown_design.lists.gap_below)
		end

		if context.math_first then
			layout_math_block(context.math_block)
		elseif context.math_block or context.math_delimiter then
			-- Only first source row owns complete display expression
		elseif context.code_block then
			layout_code_line(context)
		elseif fence_marker then
			local remainder = possible_fence and line:gsub("^%s*" .. possible_fence, "", 1) or line
			local closes_fence = possible_fence
				and possible_fence:sub(1, 1) == fence_marker:sub(1, 1)
				and #possible_fence >= #fence_marker
				and remainder:match("^%s*$") ~= nil

			if closes_fence then
				fence_marker = nil
			end
		elseif possible_fence then
			fence_marker = possible_fence
			layout_code_header(context.opened_code_block)
		elseif context.table_delimiter
			or context.table_hidden_header
			or context.reference_definition
			or context.setext_delimiter
			or context.suppress_empty_quote
		then
			-- Hidden source structure retains mapping without owning a visible row
		else
			local heading_level = context.heading_level
			local heading_text = context.heading_text

			if not heading_level then
				heading_level, heading_text = read_heading(line)
			end

			local list_marker = context.list_marker
			local list_text = context.list_text
			local list_indent = context.list_indent
			local quote_text = context.quote_text

			if context.table_definition then
				local table_rows = tables.build_rows(
					self,
					context.table_cells,
					context.table_definition,
					context.table_header == true,
					parse_document_inline
				)

				for table_row_index = 1, #table_rows do
					local is_first_visual_row = table_row_index == 1
					local is_last_visual_row = table_row_index == #table_rows
					local is_first_table_row =
						self.layout_source_line == context.table_definition.first_visible_line
					local cell_padding_y = tables.get_cell_padding_y()

					self:add_layout_row(
						table_rows[table_row_index],
						is_first_table_row and is_first_visual_row
							and tables.get_gap_before(context.table_definition)
							or 0,
						{
							content_padding_top = is_first_visual_row and cell_padding_y or 0,
							content_padding_bottom = is_last_visual_row and cell_padding_y or 0,
							table_images = table_rows[table_row_index].table_images,
							table_row = {
								column_widths = context.table_definition.column_widths,
								header = context.table_header == true,
								top = is_first_table_row and is_first_visual_row,
								bottom = is_last_visual_row,
								last = self.layout_source_line == context.table_definition.last_line
									and is_last_visual_row,
							},
						}
					)
				end
			elseif context.image_target then
				local image_block = images.build_block(
					self,
					context.image_alt_text,
					context.image_target,
					width,
					context.image_link_target,
					context.image_title
				)

				self:add_layout_row(
					{ runs = {}, min_height = image_block.height },
					images.get_block_gap(),
					{ image_block = image_block, fixed_height = true }
				)
				self.layout_height = self.layout_height + images.get_block_gap()
			elseif heading_level then
				local inline_runs = parse_document_inline(heading_text, {})
				local wrapped = self:wrap_inline_runs(inline_runs, heading_level, width)
				local heading_gap_below =
					scale_measurement(markdown_design.get_heading_gap_below(heading_level))
				local heading_separator = markdown_design.is_heading_separator_enabled(heading_level)
					and heading_level
				local heading_separator_width = heading_separator
					and markdown_design.get_heading_separator_width(heading_level)
					or 0

				for wrapped_index = 1, #wrapped do
					local is_first_row = wrapped_index == 1
					local is_last_row = wrapped_index == #wrapped

					self:add_layout_row(
						wrapped[wrapped_index],
						is_first_row
							and (context.suppress_heading_gap
								and 0
								or scale_measurement(
									markdown_design.get_heading_gap_above(heading_level)
								))
							or scale_measurement(markdown_design.paragraphs.wrapped_line_gap),
						{
							content_padding_bottom = is_last_row
								and heading_gap_below + heading_separator_width
								or 0,
							heading_separator = is_last_row and heading_separator,
						}
					)
				end
			elseif context.skip_initial_blank_line then
				-- Source line remains addressable even though it has no visual row
			elseif line:match("^%s*$") then
				-- Pattern breakdown: '^%s*$' matches an empty or whitespace-only source line
				self.layout_height = self.layout_height
					+ scale_measurement(markdown_design.paragraphs.paragraph_gap)
			elseif is_horizontal_rule(line) then
				self:add_layout_row(
					{ runs = {}, min_height = math.max(1, body_font:get_height() / 2) },
					scale_measurement(markdown_design.paragraphs.paragraph_gap) / 2,
					{ rule = true, fixed_height = true }
				)
			elseif list_text then
				local item_offset, text_offset = get_list_offsets(list_marker, list_indent, context)
				local marker_width = text_offset - item_offset
				local item_width = math.max(
					markdown_design.document.minimum_content_width,
					width - text_offset
				)
				local wrapped = self:wrap_inline_runs(
					parse_document_inline(list_text, {}),
					nil,
					item_width
				)

				for wrapped_index = 1, #wrapped do
					local x_offset = text_offset

					if wrapped_index == 1 then
						self:prepend_list_marker(wrapped[wrapped_index], list_marker, marker_width)
						x_offset = item_offset
					end

					self:add_layout_row(
						wrapped[wrapped_index],
						wrapped_index == 1 and scale_measurement(markdown_design.lists.item_gap)
							or scale_measurement(markdown_design.paragraphs.wrapped_line_gap),
						{ x_offset = x_offset }
					)
				end
			elseif context.list_continuation_text then
				local _, text_offset = get_list_offsets(list_marker, list_indent, context)
				local continuation_width = math.max(
					markdown_design.document.minimum_content_width,
					width - text_offset
				)
				local wrapped = self:wrap_inline_runs(
					parse_document_inline(context.list_continuation_text, {}),
					nil,
					continuation_width
				)

				for wrapped_index = 1, #wrapped do
					self:add_layout_row(
						wrapped[wrapped_index],
						scale_measurement(markdown_design.paragraphs.wrapped_line_gap),
						{ x_offset = text_offset }
					)
				end
			elseif quote_text ~= nil then
				local quote_width = math.max(
					markdown_design.document.minimum_content_width,
					width - quote_text_offset
				)
				local wrapped = self:wrap_inline_runs(
					parse_document_inline(quote_text, {}),
					nil,
					quote_width
				)

				if #wrapped == 0 then
					wrapped[1] = { runs = {}, min_height = body_font:get_height() }
				end

				for wrapped_index = 1, #wrapped do
					local is_first_row = context.quote_first and wrapped_index == 1
					local is_last_row = context.quote_last and wrapped_index == #wrapped

					self:add_layout_row(
						wrapped[wrapped_index],
						scale_measurement(markdown_design.paragraphs.wrapped_line_gap),
						{
							x_offset = quote_text_offset,
							content_padding_top = is_first_row and quote_padding_y or 0,
							content_padding_bottom = is_last_row and quote_padding_y or 0,
							quote = {
								background_offset = quote_block_offset,
								guide_width = markdown_design.quotes.guide_width * (SCALE or 1),
								first = is_first_row,
								last = is_last_row,
							},
						}
					)
				end

				if context.quote_last then
					-- Gap begins after quote panel instead of extending its background
					self.layout_height = self.layout_height + quote_gap_below
				end
			else
				-- Preserve source line feeds while allowing each source line to wrap visually
				local compact_drawing = compact_box_drawing_lines[line_index] == true
				local wrapped

				if compact_drawing then
					-- Literal fixed-width text preserves every drawing column without code styling
					wrapped = self:wrap_literal_text(line, width)

					for wrapped_index = 1, #wrapped do
						for run_index = 1, #wrapped[wrapped_index].runs do
							wrapped[wrapped_index].runs[run_index].code = false
						end
					end
				else
					local inline_runs = parse_document_inline(line, {})
					wrapped = self:wrap_inline_runs(inline_runs, nil, width)
				end

				for wrapped_index = 1, #wrapped do
					self:add_layout_row(
						wrapped[wrapped_index],
						compact_drawing and 0 or wrapped_index == 1
							and scale_measurement(markdown_design.paragraphs.source_line_gap)
							or scale_measurement(markdown_design.paragraphs.wrapped_line_gap),
						{
							fixed_height = compact_drawing,
						}
					)
				end
			end
		end
	end

	local last_line = #self.doc.lines
	local line_layouts = {}
	local measured_lines = {}
	local line_heights = {}
	local height_chunks = {}
	local measured_line_count = 0

	-- Build one compact height index from estimates already calculated above
	for line_index = 1, last_line do
		local line_height = line_contexts[line_index].estimated_height
		local chunk_index = math.ceil(line_index / LAYOUT_HEIGHT_CHUNK_LINES)
		line_heights[line_index] = line_height
		height_chunks[chunk_index] = (height_chunks[chunk_index] or 0) + line_height
	end

	--- Measure one source line independently of all surrounding line widths
	-- @param line_index number one-based source line
	local function materialize_source_line(line_index)
		if line_layouts[line_index] then
			return
		end

		local previous_rows = self.layout_rows
		local previous_height = self.layout_height
		self.layout_rows = {}
		self.layout_height = 0
		layout_source_line(line_index)

		for row_index = 1, #self.layout_rows do
			self.layout_rows[row_index].relative_y = self.layout_rows[row_index].y
		end

		line_layouts[line_index] = {
			rows = self.layout_rows,
			height = self.layout_height,
		}
		local chunk_index = math.ceil(line_index / LAYOUT_HEIGHT_CHUNK_LINES)
		height_chunks[chunk_index] = height_chunks[chunk_index] - line_heights[line_index] + self.layout_height
		line_heights[line_index] = self.layout_height
		measured_lines[#measured_lines + 1] = line_index
		measured_line_count = measured_line_count + 1
		self.layout_rows = previous_rows
		self.layout_height = previous_height
	end

	--- Build starting height of each fixed source chunk
	-- @return table chunk_starts layout position of each chunk
	-- @return number content_height current measured and estimated content height
	local function build_chunk_starts()
		local chunk_starts = {}
		local content_height = 0

		for chunk_index = 1, #height_chunks do
			chunk_starts[chunk_index] = content_height
			content_height = content_height + height_chunks[chunk_index]
		end

		return chunk_starts, content_height
	end

	--- Resolve one source line to its current measured or estimated layout position
	-- @param line_index number one-based source line
	-- @param chunk_starts table layout position of each chunk
	-- @return number y current layout position
	local function get_source_line_y(line_index, chunk_starts)
		local chunk_index = math.ceil(line_index / LAYOUT_HEIGHT_CHUNK_LINES)
		local first_line = (chunk_index - 1) * LAYOUT_HEIGHT_CHUNK_LINES + 1
		local y = chunk_starts[chunk_index] or 0

		for earlier_line = first_line, line_index - 1 do
			y = y + line_heights[earlier_line]
		end

		return y
	end

	--- Assemble only measured rows around indexed estimated gaps
	local function assemble_materialized_rows()
		local rows = {}
		local chunk_starts, content_height = build_chunk_starts()
		self.heading_anchors = {}
		table.sort(measured_lines)

		for measured_index = 1, #measured_lines do
			local line_index = measured_lines[measured_index]
			local line_layout = line_layouts[line_index]
			local y = get_source_line_y(line_index, chunk_starts)

			for row_index = 1, #line_layout.rows do
				local row = line_layout.rows[row_index]
				row.y = y + row.relative_y
				rows[#rows + 1] = row
			end

			local anchor = line_contexts[line_index].anchor

			if anchor and line_layout.rows[1] then
				self.heading_anchors[anchor] = y + line_layout.rows[1].relative_y
			end
		end

		self.layout_rows = rows
		self.layout_height = content_height + scale_measurement(markdown_design.document.padding_vertical) * 2
		self.estimated_layout_height = measured_line_count < last_line and self.layout_height or nil
		self.layout_complete = measured_line_count >= last_line
	end

	local anchor_line = common.clamp(
		resize_anchor and resize_anchor.source_line or 1,
		1,
		math.max(1, last_line)
	)
	local visible_line_count = math.max(
		INITIAL_LAYOUT_LINES,
		math.ceil(self:get_scroll_viewport_height() / math.max(
			1,
			body_font:get_height() * markdown_design.typography.line_height_scale
		))
	)

	--- Resolve one estimated document position back to its source line
	-- @param y number document-layout position
	-- @return number line_index estimated source line
	local function get_source_line_at_y(y)
		local remaining_y = math.max(0, tonumber(y) or 0)
		local chunk_index = 1

		-- Skip whole chunks before checking small chunk that contains target
		while chunk_index < #height_chunks and remaining_y >= height_chunks[chunk_index] do
			remaining_y = remaining_y - height_chunks[chunk_index]
			chunk_index = chunk_index + 1
		end

		local first_line = (chunk_index - 1) * LAYOUT_HEIGHT_CHUNK_LINES + 1
		local final_line = math.min(last_line, first_line + LAYOUT_HEIGHT_CHUNK_LINES - 1)

		for line_index = first_line, final_line do
			if remaining_y < line_heights[line_index] then
				return line_index
			end

			remaining_y = remaining_y - line_heights[line_index]
		end

		return math.max(1, last_line)
	end

	--- Materialize one source neighborhood and rebuild its lightweight row index
	-- @param center_line number source line at requested viewport center
	-- @return boolean changed true when at least one source line was newly measured
	local function materialize_source_neighborhood(center_line)
		if self.layout_generation ~= layout_generation then
			return false
		end

		local first_line = math.max(1, center_line - visible_line_count)
		local final_line = math.min(last_line, center_line + visible_line_count * 2)
		local previous_count = measured_line_count

		for line_index = first_line, final_line do
			materialize_source_line(line_index)
		end

		if measured_line_count == previous_count then
			return false
		end

		assemble_materialized_rows()
		core.redraw = true

		return true
	end

	self.layout_anchor_source_lines = anchor_source_lines
	self.outline_entries = outline_entries
	self.layout_materialize_neighborhood = materialize_source_neighborhood
	self.layout_priority_for_y = function(y)
		local source_line = get_source_line_at_y(y)

		if self.prioritized_layout_source_line == source_line and line_layouts[source_line] then
			return false
		end

		self.prioritized_layout_source_line = source_line

		return materialize_source_neighborhood(source_line)
	end
	self.prioritized_layout_source_line = anchor_line

	local first_immediate_line = math.max(1, anchor_line - visible_line_count)
	local last_immediate_line = math.min(last_line, anchor_line + visible_line_count * 2)

	for line_index = first_immediate_line, last_immediate_line do
		materialize_source_line(line_index)
	end

	assemble_materialized_rows()
	self.layout_source_line = nil
	self:restore_layout_anchor(resize_anchor)

	-- Distant lines stay estimated until scrolling or navigation requests their neighborhood
	-- Previous background pass is kept below because it may be useful if exact eager layout returns
	-- Upside: exact total height, stable scrollbar geometry, and no first-visit layout work on distant pages
	-- Downside: every resize, zoom, or width change eventually measures entire document again
	-- It is disabled because mdpreview now accepts estimated distant pages to avoid that delayed full rebuild
	-- if self.layout_complete then
	-- 	return
	-- end
	--
	-- core.add_coroutine(function()
	-- 	local next_line = 1
	--
	-- 	while self.layout_generation == layout_generation and measured_line_count < last_line do
	-- 		local live_anchor = self:capture_layout_anchor()
	-- 		local slice_started = system.get_time()
	--
	-- 		repeat
	-- 			while next_line <= last_line and line_layouts[next_line] do
	-- 				next_line = next_line + 1
	-- 			end
	--
	-- 			if next_line <= last_line then
	-- 				materialize_source_line(next_line)
	-- 				next_line = next_line + 1
	-- 			end
	-- 		until next_line > last_line or system.get_time() - slice_started >= 0.001
	--
	-- 		assemble_materialized_rows()
	-- 		self:restore_layout_anchor(live_anchor)
	-- 		core.redraw = true
	-- 		coroutine.yield()
	-- 	end
	-- end, self)
end

--- Return height of all rendered Markdown content
-- @return number content_height
function MarkdownPreview:get_scrollable_size()
	self:rebuild_layout()

	return self.estimated_layout_height or self.layout_height
end

--- Apply preview zoom while keeping visible top-left content stable
-- @param zoom_multiplier number requested view-local zoom
-- @return boolean changed
function MarkdownPreview:set_zoom_multiplier(zoom_multiplier)
	local requested_zoom = common.clamp(tonumber(zoom_multiplier) or 1, 0.25, 4)

	if math.abs(requested_zoom - self.zoom_multiplier) < 1e-6 then
		return false
	end

	self:rebuild_layout()
	local zoom_anchor = self:capture_layout_anchor()
	self.zoom_multiplier = requested_zoom
	self.preview_fonts = load_preview_fonts(requested_zoom)
	self.layout_key = nil
	self.pending_layout_width = nil
	self.pending_layout_since = nil
	self.resize_anchor = nil

	-- A zoom step must materialize its anchored position before exposing new layout
	self:rebuild_layout(zoom_anchor)
	self:clamp_scroll_position()
	core.redraw = true

	return true
end

--- Return one image block under a pointer position
-- @param x number pointer x
-- @param y number pointer y
-- @return table|nil block image block
function MarkdownPreview:get_image_block_at(x, y)
	if type(x) ~= "number"
		or type(y) ~= "number"
		or outline.contains(self, x, y)
		or self:scrollbar_overlaps_point(x, y)
	then
		return nil
	end

	local content_x = self.position.x
		+ scale_measurement(markdown_design.document.padding_horizontal)
	local content_top = self.position.y
		+ scale_measurement(markdown_design.document.padding_vertical)
		- (self.scroll.y or 0)
	local layout_y = y - content_top
	local row_index = self:get_first_visible_layout_row(layout_y)
	local row = self.layout_rows[row_index]

	if not row or not row.image_block or layout_y < row.y or layout_y > row.y + row.height then
		return nil
	end

	local image_x = content_x + row.x_offset
	if x < image_x or x > image_x + row.image_block.width then
		return nil
	end

	return row.image_block
end

--- Return one link under a pointer position
-- Uses same cached rows and run widths as drawing so hover and clicks cannot drift
-- @param x number pointer x
-- @param y number pointer y
-- @return string|nil target link target
function MarkdownPreview:get_link_at(x, y)
	self:rebuild_layout()

	if type(x) ~= "number" or type(y) ~= "number" then
		return nil
	end

	if self:scrollbar_overlaps_point(x, y) then
		return nil
	end

	if outline.contains(self, x, y) then
		return nil
	end

	local content_x = self.position.x
		+ scale_measurement(markdown_design.document.padding_horizontal)
	local content_top = self.position.y
		+ scale_measurement(markdown_design.document.padding_vertical)
		- (self.scroll.y or 0)

	for row_index = 1, #self.layout_rows do
		local row = self.layout_rows[row_index]
		local row_y = content_top + row.y

		if y >= row_y and y <= row_y + row.height then
			local run_x = content_x + row.x_offset

			if row.math_display then
				local math_box = row.math_display.box
				local math_x = content_x + math.max(
					row.math_display.padding_horizontal,
					(self:get_render_width() - math_box.width) / 2
				)
				local math_y = row_y + (row.height - math_box.height) / 2
				local target = markdown_math.get_link_at(
					math_box,
					x - math_x,
					y - math_y
				)

				-- Display formulas can contain more than one independent link
				if target then
					return link_target_is_supported(target) and target or nil
				end
			end

			if row.image_block and row.image_block.link_target
				and x >= run_x and x <= run_x + row.image_block.width
			then
				return row.image_block.link_target
			end

			for run_index = 1, #row.runs do
				local run = row.runs[run_index]

				if run.math_box then
					local math_y = row_y + row.baseline - run.math_box.baseline
					local target = markdown_math.get_link_at(
						run.math_box,
						x - run_x,
						y - math_y
					)

					-- Inline math links reuse normal preview click path
					if target then
						return link_target_is_supported(target) and target or nil
					end
				end

				local link_x = run_x + (run.link_offset or 0)
				local link_width = run.link_width or run.width

				if run.options.link and x >= link_x and x <= link_x + link_width then
					return run.options.link
				end

				run_x = run_x + run.width
			end

			return nil
		end
	end

	return nil
end

--- Return tooltip text and identity for a rendered link or titled image
-- @param x number pointer x
-- @param y number pointer y
-- @param known_link_target string|nil link target already found for this point
-- @param link_was_checked boolean|nil true when a nil known target is authoritative
-- @return string|nil text tooltip text
-- @return any key stable hovered item identity
function MarkdownPreview:get_hover_tooltip_at(x, y, known_link_target, link_was_checked)
	local image_block = self:get_image_block_at(x, y)
	local link_target = link_was_checked and known_link_target or self:get_link_at(x, y)

	if image_block then
		if image_block.title and link_target then
			return image_block.title .. " — " .. link_target, image_block
		end
		if image_block.title then
			return image_block.title, image_block
		end
	end

	if link_target then
		return link_target, image_block or link_target
	end

	return nil
end

--- Clear current delayed hover tooltip
function MarkdownPreview:clear_hover_tooltip()
	self.hover_tooltip_key = nil
	self.hover_tooltip_text = nil
	self.hover_tooltip_show_time = 0
	self.hover_tooltip_shown = false
	self.hover_tooltip:hide()
end

--- Start a fresh delayed tooltip when hovered target changes
-- @param x number pointer x
-- @param y number pointer y
-- @param text string|nil tooltip text
-- @param key any hovered item identity
function MarkdownPreview:set_hover_tooltip(x, y, text, key)
	if text == nil or text == "" then
		self:clear_hover_tooltip()
		return
	end

	-- Keep following pointer events current while delay is still running
	self.hover_tooltip_x = x
	self.hover_tooltip_y = y

	if key == self.hover_tooltip_key and text == self.hover_tooltip_text then
		return
	end

	self.hover_tooltip:hide()
	self.hover_tooltip_key = key
	self.hover_tooltip_text = text
	self.hover_tooltip_show_time = system.get_time() + HOVER_TOOLTIP_DELAY
	self.hover_tooltip_shown = false
	core.redraw = true
end

--- Show and expire current hover tooltip at its fixed deadlines
function MarkdownPreview:update_hover_tooltip()
	if self.hover_tooltip_text and not self.hover_tooltip_shown then
		if system.get_time() < self.hover_tooltip_show_time then
			core.redraw = true
		else
			self.hover_tooltip:show_at(
				self.hover_tooltip_x,
				self.hover_tooltip_y - core_style.padding.y,
				self.hover_tooltip_text,
				{ hook_x = "center", hook_y = "bottom" }
			)
			self.hover_tooltip:begin_hide(HOVER_TOOLTIP_DURATION)
			self.hover_tooltip_shown = true
		end
	end

	self.hover_tooltip:update()
end

--- Re-evaluate link hover and tooltip state at one current pointer position
-- @param x number pointer x
-- @param y number pointer y
function MarkdownPreview:refresh_hover_at(x, y)
	local inside_view = type(x) == "number"
		and type(y) == "number"
		and x >= self.position.x
		and y >= self.position.y
		and x < self.position.x + self.size.x
		and y < self.position.y + self.size.y

	if not inside_view or self._mouse_pan then
		self.hovered_link = nil
		self.mouse_cursor = "arrow"
		self:clear_hover_tooltip()
		return
	end

	if outline.contains(self, x, y) then
		self.hovered_link = nil
		self.mouse_cursor = outline.get_entry_at(self, x, y) and "hand" or "arrow"
		self:clear_hover_tooltip()
		return
	end

	self.hovered_link = self:get_link_at(x, y)
	self.mouse_cursor = self.hovered_link and "hand" or "arrow"
	local tooltip_text, tooltip_key = self:get_hover_tooltip_at(x, y, self.hovered_link, true)
	self:set_hover_tooltip(x, y, tooltip_text, tooltip_key)
end

--- Resolve one Markdown link without opening it
-- @param target string raw Markdown target
-- @return string|nil resolved URL or absolute local path
-- @return string|nil kind 'url', 'file', or a reason target is unsupported
-- @return table|nil reference parsed local file reference
function MarkdownPreview:resolve_link_target(target)
	local kind, reference = classify_link_target(target)
	if kind == "invalid" or kind == "unsupported" then
		return nil, kind
	end
	if kind == "url" then
		return target, "url"
	end
	if kind == "anchor" then
		return slugify_heading_text(target:sub(2)), "anchor"
	end

	-- Make physical filename absolute, then rebuild virtual path
	local path = reference.filename
	local absolute_path = path:match("^%a:[/\\]") or path:match("^[/\\]")

	if not absolute_path then
		local filename = self.doc and self.doc.filename
		local normalized_filename = filename and (system.absolute_path(filename) or filename):gsub("\\", "/")
		local directory = normalized_filename and normalized_filename:match("^(.*)/[^/]*$")

		if directory then
			path = system.join_path(directory, path)
		end
	end

	path = system.absolute_path(path) or path
	reference.filename = path

	-- Open rebuilt reference without location text in its path
	return system.format_filename(path, reference.virtual_path), "file", reference
end

--- Open one rendered link through its appropriate owner
-- @param target string URL or local Markdown target
-- @return boolean handled true once a real link consumed click
function MarkdownPreview:open_link(target)
	local resolved, kind, reference = self:resolve_link_target(target)

	if kind == "anchor" and resolved then
		return self:scroll_to_anchor(resolved)
	end

	if kind == "url" and resolved then
		local opened, open_error = system.open_url(resolved)

		if not opened and core.status_view then
			core.status_view:show_error("Could not open link: " .. tostring(open_error or resolved))
		end

		return true
	end

	if kind == "file" and resolved then
		local physical_filename = reference.filename
		local locations = reference.locations

		-- Check disk path without locations or virtual entry
		if not reference.virtual_path and #locations == 0 and system.is_dir(physical_filename) == true then
			-- Local directories belong to platform file explorer, not URL handler
			local opened, open_error = system.open_directory(physical_filename)

			if not opened and core.status_view then
				core.status_view:show_error(
					"Could not open directory: " .. tostring(open_error or resolved)
				)
			end

			return true
		end
		if system.is_file(physical_filename) ~= true then
			if core.status_view then
				core.status_view:show_error("Markdown link target does not exist: " .. resolved)
			end

			return true
		end

		-- Local links continue through same rendered Markdown mode as their source
		-- Retain source bytes only for byte-based locations
		local doc = Doc.open_file(resolved, {
			open_mode = open_mode.preview,
			retain_location_source = file_location.needs_source_data(locations),
		})

		if doc then
			local view = core.workspace_view:open_doc(doc, {
				open_mode = open_mode.preview,
			})

			if #locations > 0 then
				-- Apply locations after target view exists
				local source_error = doc._location_source_error
				local applied, location_error = file_location.apply(
					doc,
					view,
					locations,
					nil,
					doc._location_source_data
				)
				-- Release temporary source bytes after location resolution
				doc._location_source_data = nil
				doc._location_source_error = nil

				if not applied and core.status_view then
					core.status_view:show_error(
						"Could not apply Markdown link location: "
							.. tostring(source_error or location_error)
					)
				end
			end
		elseif core.status_view then
			core.status_view:show_error("Could not open Markdown link: " .. resolved)
		end

		return true
	end

	if target ~= nil and core.status_view then
		core.status_view:show_error("Unsupported Markdown link: " .. tostring(target))
	end

	return false
end
--- Return one heading's rendered vertical position, materializing it when needed
-- @param view table Markdown preview
-- @param anchor string normalized heading anchor
-- @return number|nil position
local function get_heading_position(view, anchor)
	local target = view.heading_anchors[anchor]

	if target == nil then
		local source_line = view.layout_anchor_source_lines and view.layout_anchor_source_lines[anchor]

		if source_line and view.layout_materialize_neighborhood then
			view.layout_materialize_neighborhood(source_line)
			target = view.heading_anchors[anchor]
		end
	end

	return target
end

--- Find first rendered row on or before one source line
-- @param view table Markdown preview
-- @param source_line number requested source line
-- @return number|nil position
local function get_source_position(view, source_line)
	if view.layout_materialize_neighborhood then
		view.layout_materialize_neighborhood(source_line)
	end

	local target_line
	local target

	-- Keep first visual row for closest rendered source line
	for row_index = 1, #view.layout_rows do
		local row = view.layout_rows[row_index]

		if row.source_line and row.source_line <= source_line
		and (not target_line or row.source_line > target_line)
		then
			target_line = row.source_line
			target = row.y
		end
	end

	return target
end

--- Place closest rendered Markdown source row at top
-- Falls back to owning heading when source area has no rendered row
-- @param source_line number editor source line
-- @return boolean found true when preview accepted source position
function MarkdownPreview:scroll_to_source_line(source_line)
	self:rebuild_layout()
	source_line = math.max(1, math.floor(tonumber(source_line) or 1))
	local owner

	for entry_index = 1, #self.outline_entries do
		local entry = self.outline_entries[entry_index]

		if entry.source_line > source_line then
			break
		end

		owner = entry
	end

	local target = get_source_position(self, source_line)

	if target == nil then
		target = owner and get_heading_position(self, owner.anchor) or 0
	end

	if target == nil then
		return false
	end

	self.outline_active_anchor = owner and owner.anchor or nil
	self.scroll.y = common.clamp(target, 0, self:get_scroll_max_y())
	self.scroll.to.y = self.scroll.y

	return true
end

--- Scroll toward one cached heading without jumping current position
-- @param anchor string normalized heading anchor
-- @return boolean found true when heading exists
function MarkdownPreview:scroll_to_anchor(anchor)
	self:rebuild_layout()
	local target = get_heading_position(self, anchor)

	if target == nil then
		return false
	end

	local current_position = self.scroll.y
	local target_position = common.clamp(target, 0, self:get_scroll_max_y())

	-- Keep only real moves so Backspace always returns somewhere useful
	if target_position ~= current_position then
		self.jump_history[#self.jump_history + 1] = current_position
	end
	self.scroll.to.y = target_position

	return true
end

--- Scroll to previous or next heading in document outline
-- @param direction number negative for previous, positive for next
-- @return boolean found true when an adjacent heading exists
function MarkdownPreview:scroll_to_adjacent_heading(direction)
	local active_index = outline.get_layout(self).active_index
	local target_index = active_index + (direction < 0 and -1 or 1)
	local entry = self.outline_entries[target_index]

	if not entry then
		return false
	end

	self.outline_active_anchor = entry.anchor

	return self:scroll_to_anchor(entry.anchor)
end

--- Return to visible position saved before latest heading jump
-- @return boolean found true when a previous jump position exists
function MarkdownPreview:scroll_to_previous_jump()
	local history_index = #self.jump_history

	if history_index == 0 then
		return false
	end

	local target_position = table.remove(self.jump_history, history_index)
	self.scroll.to.y = common.clamp(target_position, 0, self:get_scroll_max_y())

	return true
end

--- Open a link under a mouse-command position
-- @param x number pointer x
-- @param y number pointer y
-- @return boolean handled true when point belongs to a link
function MarkdownPreview:open_link_at(x, y)
	local target = self:get_link_at(x, y)

	return target and self:open_link(target) or false
end

--- Open heading under an outline click and consume blank panel clicks
-- @param x number pointer x
-- @param y number pointer y
-- @return boolean handled true when point belongs to outline
function MarkdownPreview:open_outline_at(x, y)
	if not outline.contains(self, x, y) then
		return false
	end

	if outline.on_mouse_down(self, "left", x, y) then
		return true
	end

	local entry = outline.get_entry_at(self, x, y)

	if entry then
		self.outline_active_anchor = entry.anchor
		self:scroll_to_anchor(entry.anchor)
	end

	return true
end

--- Update link hover while preserving shared panning and scrollbar behavior
-- @param x number pointer x
-- @param y number pointer y
-- @param dx number pointer delta x
-- @param dy number pointer delta y
-- @return boolean|nil handled
function MarkdownPreview:on_mouse_move(x, y, dx, dy)
	if self.outline_scrollbar.dragging
		or outline.contains(self, x, y)
	then
		if outline.on_mouse_move(self, x, y, dx, dy) then
			self.mouse_cursor = "arrow"
			return true
		end
	end

	local handled = MarkdownPreview.super.on_mouse_move(self, x, y, dx, dy)

	if self._mouse_pan then
		self:clear_hover_tooltip()
		return handled
	end

	self:refresh_hover_at(x, y)

	return handled
end

--- Finish either outline or document scrollbar dragging
-- @param button string mouse button
-- @param x number pointer x
-- @param y number pointer y
-- @return boolean|nil handled
function MarkdownPreview:on_mouse_up(button, x, y)
	self.outline_scrollbar:on_mouse_up(button, x, y)

	return MarkdownPreview.super.on_mouse_up(self, button, x, y)
end

--- Scroll pane under pointer instead of moving both panes together
function MarkdownPreview:on_mouse_wheel(a, b, c, d)
	local mouse = core.workspace_view and core.workspace_view.mouse
	local y = (d ~= nil and d) or (c ~= nil and c) or (b ~= nil and b) or a

	if mouse and outline.contains(self, mouse.x, mouse.y) then
		outline.scroll(self, y * -3)
		return true
	end

	self.outline_active_anchor = nil

	return MarkdownPreview.super.on_mouse_wheel(self, a, b, c, d)
end

--- Clear link hover when pointer leaves preview
-- @return boolean|nil handled
function MarkdownPreview:on_mouse_left(...)
	local handled = MarkdownPreview.super.on_mouse_left(self, ...)
	self.outline_scrollbar:on_mouse_left()
	self.hovered_link = nil
	self.mouse_cursor = "arrow"
	self:clear_hover_tooltip()

	return handled
end

--- Keep wheel, scrollbar, and mouse-pan targets backed by measured source rows
function MarkdownPreview:update()
	MarkdownPreview.super.update(self)
	self.outline_scrollbar:update()

	local scroll_changed = self.hover_observed_scroll_x ~= self.scroll.x
		or self.hover_observed_scroll_y ~= self.scroll.y
		or self.hover_observed_scroll_to_x ~= self.scroll.to.x
		or self.hover_observed_scroll_to_y ~= self.scroll.to.y
	self.hover_observed_scroll_x = self.scroll.x
	self.hover_observed_scroll_y = self.scroll.y
	self.hover_observed_scroll_to_x = self.scroll.to.x
	self.hover_observed_scroll_to_y = self.scroll.to.y

	if scroll_changed then
		-- Scrolling changes content beneath a stationary pointer
		self:clear_hover_tooltip()
		local mouse = core.workspace_view and core.workspace_view.mouse
		if mouse then
			self:refresh_hover_at(mouse.x, mouse.y)
		end
	end

	self:update_hover_tooltip()

	if images.refresh_changed(self) then
		-- A replacement image may have different dimensions and needs fresh wrapping
		self.layout_key = nil
		core.redraw = true
	end

	self:prioritize_layout_for_y(self.scroll.to.y)
end

--- Find first cached row that can appear inside viewport
-- @param visible_top number top edge in document-layout coordinates
-- @return number row_index first potentially visible row
function MarkdownPreview:get_first_visible_layout_row(visible_top)
	local low = 1
	local high = #self.layout_rows
	local first_visible = high + 1

	while low <= high do
		local middle = math.floor((low + high) / 2)
		local row = self.layout_rows[middle]

		if row.y + row.height >= visible_top then
			first_visible = middle
			high = middle - 1
		else
			low = middle + 1
		end
	end

	return first_visible
end

--- Draw visible part of one fenced block as a single background
-- @param first_row_index number first block row reached by this draw
-- @param x number block left edge
-- @param width number block width
-- @param top number document origin after scrolling
-- @param visible_bottom number viewport bottom in document-layout coordinates
function MarkdownPreview:draw_code_block_background(first_row_index, x, width, top, visible_bottom)
	local first_row = self.layout_rows[first_row_index]
	local block = first_row.code_block
	local block_top = top + first_row.y
	local block_bottom = block_top + first_row.height

	for row_index = first_row_index + 1, #self.layout_rows do
		local row = self.layout_rows[row_index]

		if row.code_block ~= block then
			break
		end

		block_bottom = top + row.y + row.height

		if row.y > visible_bottom then
			break
		end
	end

	local rounded_top = math.floor(block_top)
	local rounded_bottom = math.ceil(block_bottom)

	renderer.draw_rect(
		x + (first_row.code_block_x_offset or 0),
		rounded_top,
		first_row.code_block_width or width,
		math.max(1, rounded_bottom - rounded_top),
		markdown_design.get_code_block_background_color()
	)
end

--- Draw document background and visible Markdown rows without interface lanes
function MarkdownPreview:draw_document()
	self:draw_background(markdown_design.get_document_background_color())
	self:rebuild_layout()

	local x = self.position.x + scale_measurement(markdown_design.document.padding_horizontal)
	local width = self:get_render_width()
	local top = self.position.y
		+ scale_measurement(markdown_design.document.padding_vertical)
		- (self.scroll.y or 0)
	local visible_top = self.position.y - top
	local visible_bottom = self.position.y + self.size.y - top
	local first_visible_row = self:get_first_visible_layout_row(visible_top)
	local code_span_padding_vertical = scale_measurement(
		markdown_design.code_spans.padding_vertical
	)

	core.push_clip_rect(
		self.position.x,
		self.position.y,
		self:get_document_viewport_width(),
		self.size.y
	)
	for row_index = first_visible_row, #self.layout_rows do
		local row = self.layout_rows[row_index]
		local y = top + row.y

		if row.y > visible_bottom then
			break
		end

		do
			local row_x = x + row.x_offset
			local run_x = row_x

			if row.code_block then
				local previous_row = self.layout_rows[row_index - 1]
				local starts_visible_block = row_index == first_visible_row
					or not previous_row
					or previous_row.code_block ~= row.code_block

				if starts_visible_block then
					self:draw_code_block_background(row_index, x, width, top, visible_bottom)
				end
			end

			if row.code_header then
				-- Header overlays shared block background without creating a gap
				renderer.draw_rect(
					x + (row.code_block_x_offset or 0),
					math.floor(y),
					row.code_block_width or width,
					math.max(1, math.ceil(row.height)),
					markdown_design.get_code_block_header_background_color()
				)
			end

			if row.code_badge then
				local badge_design = markdown_design.code_blocks.badge
				local badge_font = self.preview_fonts.code_badge
				local badge_padding_x = scale_measurement(badge_design.padding_horizontal)
				local badge_padding_y = scale_measurement(badge_design.padding_vertical)
				local badge_width = badge_font:get_width(row.code_badge) + badge_padding_x * 2
				local badge_height = badge_font:get_height() + badge_padding_y * 2
				local badge_x = x
					+ (row.code_block_x_offset or 0)
					+ (row.code_block_width or width)
					- scale_measurement(badge_design.margin_right)
					- badge_width
				local badge_y = y + scale_measurement(badge_design.margin_top)

				-- Badge floats inside existing top padding and adds no document height
				if badge_design.background_enabled then
					renderer.draw_rect(
						math.floor(badge_x),
						math.floor(badge_y),
						math.ceil(badge_width),
						math.ceil(badge_height),
						markdown_design.get_code_block_badge_background_color()
					)
				end
				renderer.draw_text(
					badge_font,
					row.code_badge,
					badge_x + badge_padding_x,
					badge_y + badge_padding_y,
					markdown_design.get_code_block_badge_text_color()
				)
			end

			if row.table_row then
				tables.draw_row(row_x, y, row.height, row.table_row)
			end

			if row.table_images and #row.table_images > 0 then
				local previous_row = self.layout_rows[row_index - 1]
				local starts_visible_image_row = row_index == first_visible_row
					or not previous_row
					or previous_row.source_line ~= row.source_line

				if starts_visible_image_row then
					local first_image_row_index = row_index

					while first_image_row_index > 1 do
						local candidate = self.layout_rows[first_image_row_index - 1]
						if candidate.source_line ~= row.source_line
							or not candidate.table_images
							or #candidate.table_images == 0
						then
							break
						end

						first_image_row_index = first_image_row_index - 1
					end

					local first_image_row = self.layout_rows[first_image_row_index]
					local image_y = top + first_image_row.y + first_image_row.content_padding_top

					for image_index = 1, #row.table_images do
						local item = row.table_images[image_index]
						images.draw_block(
							item.block,
							row_x + item.x_offset,
							image_y,
							self.position.y,
							self.position.y + self.size.y,
							self.preview_fonts.body
						)
					end
				end
			end

			if row.image_block then
				images.draw_block(
					row.image_block,
					row_x,
					y,
					self.position.y,
					self.position.y + self.size.y,
					self.preview_fonts.body
				)
			end

			if row.math_display then
				local math_box = row.math_display.box
				local math_x = x + math.max(
					row.math_display.padding_horizontal,
					(width - math_box.width) / 2
				)
				local math_y = y + (row.height - math_box.height) / 2

				-- Display math owns one continuous panel independent of its source-line count
				renderer.draw_rect(
					x,
					math.floor(y),
					width,
					math.ceil(row.height),
					markdown_design.get_math_background_color()
				)
				markdown_math.draw(
					math_box,
					math_x,
					math_y,
					markdown_design.get_text_color(),
					markdown_design.get_link_color()
				)
			end

			if row.quote then
				local quote_x = x + row.quote.background_offset
				-- Round shared quote edges outwards so fractional line heights cannot expose seams
				local quote_top = math.floor(y)
				local quote_bottom = math.ceil(y + row.height)
				local quote_height = math.max(1, quote_bottom - quote_top)

				renderer.draw_rect(
					quote_x,
					quote_top,
					math.max(1, width - row.quote.background_offset),
					quote_height,
					markdown_design.get_quote_background_color()
				)
				renderer.draw_rect(
					quote_x,
					quote_top,
					row.quote.guide_width,
					quote_height,
					markdown_design.get_quote_guide_color()
				)
			end

			if row.rule then
				draw_horizontal_rule(
					x,
					y + math.floor(row.height / 2),
					width
				)
			end

			if row.heading_separator then
				draw_heading_separator(
					row.heading_separator,
					x,
					y + row.height
						- row.content_padding_bottom,
					width
				)
			end

			-- Paint every inline-code surface first so horizontal padding cannot cover nearby text
			local background_run_x = row_x

			for run_index = 1, #row.runs do
				local run = row.runs[run_index]
				local run_y = y + row.baseline - run.font:get_baseline()

				if run.code and not row.code_block and not run.spacer then
					-- Run width already contains horizontal padding reserved during wrapping
					renderer.draw_rect(
						background_run_x,
						run_y - code_span_padding_vertical,
						run.width,
						run.font:get_height() + code_span_padding_vertical * 2,
						markdown_design.get_code_span_background_color()
					)
				end

				background_run_x = background_run_x + run.width
			end

			for run_index = 1, #row.runs do
				local run = row.runs[run_index]
				local run_y = y + row.baseline
					- (run.math_box and run.math_box.baseline or run.font:get_baseline())
				local text_color = row.code_header
					and markdown_design.get_code_block_header_text_color()
					or run.options.link and markdown_design.get_link_color()
					or markdown_design.get_text_color(run.syntax_type)
				if run.math_box then
					markdown_math.draw(
						run.math_box,
						run_x,
						run_y,
						text_color,
						markdown_design.get_link_color()
					)
				elseif not run.spacer then
					renderer.draw_text(
						run.font,
						run.text,
						run_x + (run.text_offset or 0),
						run_y,
						text_color
					)
				end

				if run.options.link and not run.spacer then
					-- Link underline shares exact width used by drawing and hit testing
					renderer.draw_rect(
						run_x + (run.link_offset or 0),
						run_y + math.min(
							run.font:get_height() - 1,
							run.font:get_baseline()
								+ (SCALE or 1) * markdown_design.links.underline_offset
						),
						run.link_width or run.width,
						math.max(1, (SCALE or 1) * markdown_design.links.underline_width),
						text_color
					)
				end

				run_x = run_x + run.width
			end
		end
	end
	core.pop_clip_rect()
end

--- Draw rendered Markdown document and its interactive preview interface
function MarkdownPreview:draw()
	self:draw_document()
	outline.draw(self)
	self:draw_scrollbar()
	self.hover_tooltip:defer_draw()
end

-- Export installs its public method while keeping page mechanics out of this view unit
require("plugins.mdpreview.export").install(MarkdownPreview, load_preview_fonts)

return MarkdownPreview
