Module:TextFit

Revision as of 19:14, 1 August 2026 by Vergir (talk | contribs) (Measure everything at bold weight: drop the regular width table and the weight-tracking parser, measure each word once (with help from vergir-bot LLM))
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)

Documentation for this module may be created at Module:TextFit/doc

--[==[

Estimates the largest font size at which a string will wrap into a box of a
given width and height, using real per-glyph advance widths rather than a
character count.

Everything is measured at bold weight. Retail Demo's bold advances run about 4%
wider than its regular ones, so measuring regular text against the bold table
can only make the estimate slightly conservative -- text comes out a hair small
rather than overflowing -- and it removes the need to track font weight through
the markup at all. Both callers render their text bold in any case.

Widths are in em: each font's raw advances divided by its own unitsPerEm, so
the tables are directly comparable despite the differing internal grids.
Extracted with fonttools from:
  Retail Demo bold  [[:File:Retaildemo-bold.woff2]]  unitsPerEm 1000
  Open Sans         [[:File:Open_Sans.woff2]]        unitsPerEm 2048

Note the level-2 long brackets on this comment: a plain --[[ ]] comment would
be closed by the first ]] below, which the wiki links above contain.
]==]

local p = {}
local ustring = mw.ustring

-- Retail Demo, bold weight. Invisible characters are spelled with
-- ustring.char so they cannot be mistaken for a plain space when edited.
local WIDTHS = {
	[" "]=0.240, [","]=0.281, ["."]=0.281, ["0"]=0.662, ["1"]=0.312, ["2"]=0.560, ["3"]=0.566, ["4"]=0.612,
	["5"]=0.562, ["6"]=0.601, ["7"]=0.520, ["8"]=0.583, ["9"]=0.598, A=0.677, B=0.590, C=0.621,
	D=0.693, E=0.552, F=0.530, G=0.686, H=0.702, I=0.276, J=0.301, K=0.637,
	L=0.491, M=0.904, N=0.695, O=0.741, P=0.585, Q=0.740, R=0.628, S=0.555,
	T=0.555, U=0.694, V=0.668, W=0.913, X=0.667, Y=0.635, Z=0.666, a=0.573,
	b=0.608, c=0.473, d=0.604, e=0.570, f=0.382, g=0.558, h=0.608, i=0.269,
	j=0.266, k=0.584, l=0.268, m=0.907, n=0.602, o=0.611, p=0.604, q=0.605,
	r=0.392, s=0.496, t=0.404, u=0.602, v=0.549, w=0.776, x=0.571, y=0.550,
	z=0.561,
	[ustring.char(0xA0)]=0.240, -- no-break space
}

-- No bold Open Sans is loaded, so browsers synthesise it. Estimated from
-- Retail Demo's measured bold/regular ratio of 1.037.
local SYNTH_BOLD = 1.04

-- Open Sans, for the characters Retail Demo does not contain
local FALLBACK_WIDTHS = {
	["!"]=0.264, ["\""]=0.398, ["#"]=0.646, ["$"]=0.572, ["%"]=0.827, ["&"]=0.729, ["'"]=0.219, ["("]=0.295,
	[")"]=0.295, ["*"]=0.551, ["+"]=0.572, ["-"]=0.322, ["/"]=0.367, [":"]=0.263, [";"]=0.263, ["<"]=0.572,
	["="]=0.572, [">"]=0.572, ["?"]=0.432, ["@"]=0.896, ["["]=0.327, ["\\"]=0.367, ["]"]=0.327, ["^"]=0.572,
	["_"]=0.438, ["`"]=0.277, ["{"]=0.375, ["|"]=0.549, ["}"]=0.375, ["~"]=0.572, ["¡"]=0.264, ["¢"]=0.572,
	["£"]=0.572, ["¤"]=0.572, ["¥"]=0.572, ["¦"]=0.549, ["§"]=0.514, ["¨"]=0.580, ["©"]=0.832, ["ª"]=0.353,
	["«"]=0.496, ["¬"]=0.572, [ustring.char(0xAD)]=0.322, -- soft hyphen
	["®"]=0.832, ["¯"]=0.500, ["°"]=0.428, ["±"]=0.572, ["²"]=0.348,
	["³"]=0.348, ["´"]=0.277, ["µ"]=0.618, ["¶"]=0.655, ["·"]=0.263, ["¸"]=0.222, ["¹"]=0.348, ["º"]=0.374,
	["»"]=0.496, ["¼"]=0.740, ["½"]=0.768, ["¾"]=0.778, ["¿"]=0.432, ["À"]=0.632, ["Á"]=0.632, ["Â"]=0.632,
	["Ã"]=0.632, ["Ä"]=0.632, ["Å"]=0.632, ["Æ"]=0.868, ["Ç"]=0.630, ["È"]=0.556, ["É"]=0.556, ["Ê"]=0.556,
	["Ë"]=0.556, ["Ì"]=0.279, ["Í"]=0.279, ["Î"]=0.279, ["Ï"]=0.279, ["Ð"]=0.726, ["Ñ"]=0.753, ["Ò"]=0.778,
	["Ó"]=0.778, ["Ô"]=0.778, ["Õ"]=0.778, ["Ö"]=0.778, ["×"]=0.572, ["Ø"]=0.778, ["Ù"]=0.729, ["Ú"]=0.729,
	["Û"]=0.729, ["Ü"]=0.729, ["Ý"]=0.559, ["Þ"]=0.602, ["ß"]=0.623, ["à"]=0.556, ["á"]=0.556, ["â"]=0.556,
	["ã"]=0.556, ["ä"]=0.556, ["å"]=0.556, ["æ"]=0.862, ["ç"]=0.479, ["è"]=0.562, ["é"]=0.562, ["ê"]=0.562,
	["ë"]=0.562, ["ì"]=0.252, ["í"]=0.252, ["î"]=0.252, ["ï"]=0.252, ["ð"]=0.600, ["ñ"]=0.613, ["ò"]=0.602,
	["ó"]=0.602, ["ô"]=0.602, ["õ"]=0.602, ["ö"]=0.602, ["÷"]=0.572, ["ø"]=0.602, ["ù"]=0.613, ["ú"]=0.613,
	["û"]=0.613, ["ü"]=0.613, ["ý"]=0.501, ["þ"]=0.612, ["ÿ"]=0.501, ["Ā"]=0.632, ["ā"]=0.556, ["Ă"]=0.632,
	["ă"]=0.556, ["Ą"]=0.632, ["ą"]=0.556, ["Ć"]=0.630, ["ć"]=0.479, ["Ĉ"]=0.630, ["ĉ"]=0.479, ["Ċ"]=0.630,
	["ċ"]=0.479, ["Č"]=0.630, ["č"]=0.479, ["Ď"]=0.726, ["ď"]=0.612, ["Đ"]=0.726, ["đ"]=0.613, ["Ē"]=0.556,
	["ē"]=0.562, ["Ĕ"]=0.556, ["ĕ"]=0.562, ["Ė"]=0.556, ["ė"]=0.562, ["Ę"]=0.556, ["ę"]=0.562, ["Ě"]=0.556,
	["ě"]=0.562, ["Ĝ"]=0.727, ["ĝ"]=0.543, ["Ğ"]=0.727, ["ğ"]=0.543, ["Ġ"]=0.727, ["ġ"]=0.543, ["Ģ"]=0.727,
	["ģ"]=0.543, ["Ĥ"]=0.737, ["ĥ"]=0.613, ["Ħ"]=0.737, ["ħ"]=0.613, ["Ĩ"]=0.279, ["ĩ"]=0.252, ["Ī"]=0.279,
	["ī"]=0.252, ["Ĭ"]=0.279, ["ĭ"]=0.252, ["Į"]=0.279, ["į"]=0.252, ["İ"]=0.279, ["ı"]=0.252, ["IJ"]=0.548,
	["ij"]=0.505, ["Ĵ"]=0.269, ["ĵ"]=0.252, ["Ķ"]=0.612, ["ķ"]=0.525, ["ĸ"]=0.525, ["Ĺ"]=0.522, ["ĺ"]=0.252,
	["Ļ"]=0.522, ["ļ"]=0.252, ["Ľ"]=0.522, ["ľ"]=0.252, ["Ŀ"]=0.522, ["ŀ"]=0.261, ["Ł"]=0.522, ["ł"]=0.252,
	["Ń"]=0.753, ["ń"]=0.613, ["Ņ"]=0.753, ["ņ"]=0.613, ["Ň"]=0.753, ["ň"]=0.613, ["ʼn"]=0.676, ["Ŋ"]=0.753,
	["ŋ"]=0.613, ["Ō"]=0.778, ["ō"]=0.602, ["Ŏ"]=0.778, ["ŏ"]=0.602, ["Ő"]=0.778, ["ő"]=0.602, ["Œ"]=0.925,
	["œ"]=0.948, ["Ŕ"]=0.617, ["ŕ"]=0.409, ["Ŗ"]=0.617, ["ŗ"]=0.409, ["Ř"]=0.617, ["ř"]=0.409, ["Ś"]=0.548,
	["ś"]=0.477, ["Ŝ"]=0.548, ["ŝ"]=0.477, ["Ş"]=0.548, ["ş"]=0.477, ["Š"]=0.548, ["š"]=0.477, ["Ţ"]=0.551,
	["ţ"]=0.356, ["Ť"]=0.551, ["ť"]=0.356, ["Ŧ"]=0.551, ["ŧ"]=0.356, ["Ũ"]=0.729, ["ũ"]=0.613, ["Ū"]=0.729,
	["ū"]=0.613, ["Ŭ"]=0.729, ["ŭ"]=0.613, ["Ů"]=0.729, ["ů"]=0.613, ["Ű"]=0.729, ["ű"]=0.613, ["Ų"]=0.729,
	["ų"]=0.613, ["Ŵ"]=0.923, ["ŵ"]=0.775, ["Ŷ"]=0.559, ["ŷ"]=0.501, ["Ÿ"]=0.559, ["Ź"]=0.572, ["ź"]=0.469,
	["Ż"]=0.572, ["ż"]=0.469, ["Ž"]=0.572, ["ž"]=0.469, ["ſ"]=0.323, ["Ѐ"]=0.556, ["Ё"]=0.556, ["Ђ"]=0.733,
	["Ѓ"]=0.520, ["Є"]=0.639, ["Ѕ"]=0.548, ["І"]=0.279, ["Ї"]=0.279, ["Ј"]=0.269, ["Љ"]=0.935, ["Њ"]=0.958,
	["Ћ"]=0.733, ["Ќ"]=0.610, ["Ѝ"]=0.760, ["Ў"]=0.618, ["Џ"]=0.728, ["А"]=0.632, ["Б"]=0.612, ["В"]=0.646,
	["Г"]=0.520, ["Д"]=0.684, ["Е"]=0.556, ["Ж"]=0.841, ["З"]=0.583, ["И"]=0.760, ["Й"]=0.760, ["К"]=0.610,
	["Л"]=0.703, ["М"]=0.899, ["Н"]=0.737, ["О"]=0.778, ["П"]=0.728, ["Р"]=0.602, ["С"]=0.630, ["Т"]=0.551,
	["У"]=0.618, ["Ф"]=0.797, ["Х"]=0.578, ["Ц"]=0.737, ["Ч"]=0.693, ["Ш"]=1.032, ["Щ"]=1.037, ["Ъ"]=0.686,
	["Ы"]=0.848, ["Ь"]=0.634, ["Э"]=0.630, ["Ю"]=1.048, ["Я"]=0.632, ["а"]=0.556, ["б"]=0.595, ["в"]=0.574,
	["г"]=0.431, ["д"]=0.575, ["е"]=0.562, ["ж"]=0.735, ["з"]=0.483, ["и"]=0.636, ["й"]=0.636, ["к"]=0.517,
	["л"]=0.572, ["м"]=0.733, ["н"]=0.633, ["о"]=0.602, ["п"]=0.620, ["р"]=0.612, ["с"]=0.479, ["т"]=0.471,
	["у"]=0.501, ["ф"]=0.715, ["х"]=0.523, ["ц"]=0.626, ["ч"]=0.607, ["ш"]=0.890, ["щ"]=0.897, ["ъ"]=0.687,
	["ы"]=0.768, ["ь"]=0.590, ["э"]=0.494, ["ю"]=0.829, ["я"]=0.554, ["ѐ"]=0.562, ["ё"]=0.562, ["ђ"]=0.613,
	["ѓ"]=0.431, ["є"]=0.493, ["ѕ"]=0.477, ["і"]=0.252, ["ї"]=0.252, ["ј"]=0.252, ["љ"]=0.839, ["њ"]=0.889,
	["ћ"]=0.613, ["ќ"]=0.517, ["ѝ"]=0.636, ["ў"]=0.501, ["џ"]=0.622, ["–"]=0.500, ["—"]=1.000, ["―"]=1.000,
	["‘"]=0.169, ["’"]=0.169, ["‚"]=0.245, ["‛"]=0.169, ["“"]=0.349, ["”"]=0.349, ["…"]=0.778, ["€"]=0.572,
}

-- Characters in neither face (CJK, rare symbols).
local CJK_WIDTH = 2.0
local FALLBACK  = 0.55

local function charWidth(ch)
	local w = WIDTHS[ch]
	if w then return w end

	w = FALLBACK_WIDTHS[ch]
	if w then return w * SYNTH_BOLD end

	local cp = ustring.codepoint(ch) or 0
	if (cp >= 0x1100 and cp <= 0x115F) or (cp >= 0x2E80 and cp <= 0xA4CF)
		or (cp >= 0xAC00 and cp <= 0xD7A3) or (cp >= 0xF900 and cp <= 0xFAFF)
		or (cp >= 0xFF00 and cp <= 0xFF60) or (cp >= 0xFFE0 and cp <= 0xFFE6) then
		return CJK_WIDTH
	end
	return FALLBACK
end

local function wordWidth(word)
	local total = 0
	for ch in ustring.gmatch(word, ".") do
		total = total + charWidth(ch)
	end
	return total
end

-- Strip wiki markup that carries no visible text of its own.
local function stripMarkup(src)
	src = mw.text.killMarkers(src or "")
	src = src:gsub("%[%[[Ff]ile:[^%[%]]*%]%]", "")
	src = src:gsub("%[%[[^%[%]|]*|([^%[%]]*)%]%]", "%1")
	src = src:gsub("%[%[([^%[%]]*)%]%]", "%1")
	-- Quote markup survives frame:preprocess; strip it so it is not measured.
	return (src:gsub("'''''", ""):gsub("'''", ""):gsub("''", ""))
end

-- Reduce expanded wikitext/HTML to the text a reader actually sees: a list of
-- lines, each a list of words.
-- Tags are deleted rather than replaced, so a word broken across a tag boundary
-- stays one word. Splitting on ASCII whitespace only leaves NBSP joining its
-- neighbours, which is how the browser wraps.
function p.parse(src)
	src = stripMarkup(src)
	src = src:gsub("<%s*[bB][rR]%s*/?%s*>", "\n")
	src = src:gsub("<[^<>]*>", "")
	-- Decode after the tags are gone, so an escaped &lt; cannot look like one.
	src = mw.text.decode(src, true)

	local lines = {}
	for line in (src .. "\n"):gmatch("([^\n]*)\n") do
		local words = {}
		for word in line:gmatch("%S+") do
			words[#words + 1] = word
		end
		lines[#lines + 1] = words
	end
	return lines
end

-- Greedy wrap with the usable width normalised to 1, so `r` is the font size
-- expressed as a fraction of that width. Takes lines of word WIDTHS, already
-- measured by fit(). Returns the number of rendered lines.
local function lineCount(lines, r)
	local space = charWidth(" ") * r
	local total = 0
	for _, widths in ipairs(lines) do
		local cur = 0
		total = total + 1
		for _, ww in ipairs(widths) do
			ww = ww * r
			if cur == 0 then
				cur = ww
			elseif cur + space + ww <= 1 then
				cur = cur + space + ww
			else
				total = total + 1
				cur = ww
			end
		end
	end
	return total
end

-- Largest font size at which `text` fits its box, returned as a PERCENTAGE of
-- the box's usable text width. Emit it as a cqw value against a query
-- container whose content box is exactly that width; no pixel geometry is
-- needed here or in the caller.
--
-- opts:
--   aspect      max height of the text block, as a multiple of its width
--   lineHeight  line-height multiplier used when rendering
--   max, min    ceiling and floor, also as a percentage of the width
--   step        search granularity
function p.fit(text, opts)
	local aspect = opts.aspect
	local lineH  = opts.lineHeight or 1.2
	local maxPct = opts.max or 100
	local minPct = opts.min or 1
	local step   = opts.step or 0.1

	-- Measure every word once; the shrink loop below only scales the results.
	local lines, widest = {}, 0
	for _, words in ipairs(p.parse(text)) do
		local widths = {}
		for i, word in ipairs(words) do
			widths[i] = wordWidth(word)
			if widths[i] > widest then widest = widths[i] end
		end
		lines[#lines + 1] = widths
	end
	if widest == 0 then return maxPct end

	-- The longest single word must not overflow horizontally
	local pct = math.min(100 / widest, maxPct)

	-- Then shrink until the wrapped block fits vertically
	while pct > minPct and lineCount(lines, pct / 100) * lineH * (pct / 100) > aspect do
		pct = pct - step
	end

	return math.max(pct, minPct)
end

return p