Module:TextFit: Difference between revisions

From The Deadlock Wiki
Jump to navigation Jump to search
Add shared text-fitting helper: per-glyph advance widths for Retail Demo and Open Sans, with a scale-invariant fitter for sizing text into a box (with help from vergir-bot LLM)
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)
 
(2 intermediate revisions by the same user not shown)
Line 1: Line 1:
--[==[
--[==[
Shared text-fitting helper.


Estimates the largest font size at which a string will wrap into a box of a
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
given width and height, using real per-glyph advance widths rather than a
character count. Used by [[Module:Abilities/card]] to size ability upgrade
character count.
descriptions; [[Module:ItemBox]] can migrate onto it.


Widths are in em. Sources, all extracted with fonttools:
Everything is measured at bold weight. Retail Demo's bold advances run about 4%
  Retail Demo regular [[:File:Retaildemo-regular.woff2]]  unitsPerEm 1000
wider than its regular ones, so measuring regular text against the bold table
  Retail Demo bold    [[:File:Retaildemo-bold.woff2]]    unitsPerEm 1000
can only make the estimate slightly conservative -- text comes out a hair small
  Open Sans            [[:File:Open_Sans.woff2]]          unitsPerEm 2048
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.


Retail Demo is subset to 65 codepoints (space, comma, period, 0-9, A-Z, a-z).
Widths are in em: each font's raw advances divided by its own unitsPerEm, so
MediaWiki:Common.css declares the card font stack as
the tables are directly comparable despite the differing internal grids.
'Retail Demo', 'Open Sans', 'PT Serif', serif, so the browser falls back per
Extracted with fonttools from:
character; FALLBACK_WIDTHS holds the Open Sans widths actually used for
  Retail Demo bold  [[:File:Retaildemo-bold.woff2]]  unitsPerEm 1000
punctuation, Cyrillic and accented Latin.
  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.
]==]
]==]


Line 22: Line 24:
local ustring = mw.ustring
local ustring = mw.ustring


-- Retail Demo, normal weight
-- Retail Demo, bold weight. Invisible characters are spelled with
local RD_REGULAR = {
-- ustring.char so they cannot be mistaken for a plain space when edited.
[" "]=0.240, [","]=0.221, ["."]=0.221, ["0"]=0.645, ["1"]=0.268, ["2"]=0.549, ["3"]=0.557, ["4"]=0.601,
local WIDTHS = {
["5"]=0.538, ["6"]=0.583, ["7"]=0.487, ["8"]=0.555, ["9"]=0.580, A=0.624, B=0.585, C=0.641,
D=0.696, E=0.554, F=0.525, G=0.695, H=0.703, I=0.237, J=0.268, K=0.626,
L=0.494, M=0.927, N=0.700, O=0.730, P=0.558, Q=0.734, R=0.614, S=0.527,
T=0.550, U=0.679, V=0.624, W=0.939, X=0.665, Y=0.603, Z=0.647, a=0.553,
b=0.587, c=0.530, d=0.588, e=0.551, f=0.394, g=0.544, h=0.600, i=0.238,
j=0.235, k=0.552, l=0.238, m=0.923, n=0.600, o=0.603, p=0.583, q=0.587,
r=0.369, s=0.477, t=0.413, u=0.600, v=0.509, w=0.810, x=0.540, y=0.513,
z=0.554,
[" "]=0.240, -- U+00A0 no-break space
}
 
-- Retail Demo, bold weight
local RD_BOLD = {
[" "]=0.240, [","]=0.281, ["."]=0.281, ["0"]=0.662, ["1"]=0.312, ["2"]=0.560, ["3"]=0.566, ["4"]=0.612,
[" "]=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,
["5"]=0.562, ["6"]=0.601, ["7"]=0.520, ["8"]=0.583, ["9"]=0.598, A=0.677, B=0.590, C=0.621,
Line 47: Line 36:
r=0.392, s=0.496, t=0.404, u=0.602, v=0.549, w=0.776, x=0.571, y=0.550,
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,
z=0.561,
[" "]=0.240, -- U+00A0 no-break space
[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
-- Open Sans, for the characters Retail Demo does not contain
Line 57: Line 50:
["_"]=0.438, ["`"]=0.277, ["{"]=0.375, ["|"]=0.549, ["}"]=0.375, ["~"]=0.572, ["¡"]=0.264, ["¢"]=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.572, ["¤"]=0.572, ["¥"]=0.572, ["¦"]=0.549, ["§"]=0.514, ["¨"]=0.580, ["©"]=0.832, ["ª"]=0.353,
["«"]=0.496, ["¬"]=0.572, ["­"]=0.322, ["®"]=0.832, ["¯"]=0.500, ["°"]=0.428, ["±"]=0.572, ["²"]=0.348,
["«"]=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.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.496, ["¼"]=0.740, ["½"]=0.768, ["¾"]=0.778, ["¿"]=0.432, ["À"]=0.632, ["Á"]=0.632, ["Â"]=0.632,
Line 98: Line 92:
["‘"]=0.169, ["’"]=0.169, ["‚"]=0.245, ["‛"]=0.169, ["“"]=0.349, ["”"]=0.349, ["…"]=0.778, ["€"]=0.572,
["‘"]=0.169, ["’"]=0.169, ["‚"]=0.245, ["‛"]=0.169, ["“"]=0.349, ["”"]=0.349, ["…"]=0.778, ["€"]=0.572,
}
}
-- No bold Open Sans is loaded, so browsers synthesise it, which widens
-- advances slightly. Retail Demo's own measured bold/regular mean is 1.037.
local SYNTH_BOLD = 1.03


-- Characters in neither face (CJK, rare symbols).
-- Characters in neither face (CJK, rare symbols).
Line 107: Line 97:
local FALLBACK  = 0.55
local FALLBACK  = 0.55


local VOID_TAGS = { br = true, img = true, hr = true, input = true,
local function charWidth(ch)
                    meta = true, link = true, wbr = true }
local w = WIDTHS[ch]
local BOLD_TAGS = { b = true, strong = true }
 
local function charWidth(ch, bold)
local w = (bold and RD_BOLD or RD_REGULAR)[ch]
if w then return w end
if w then return w end


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


local cp = ustring.codepoint(ch) or 0
local cp = ustring.codepoint(ch) or 0
Line 131: Line 115:
local function wordWidth(word)
local function wordWidth(word)
local total = 0
local total = 0
for _, part in ipairs(word) do
for ch in ustring.gmatch(word, ".") do
for ch in ustring.gmatch(part.text, ".") do
total = total + charWidth(ch)
total = total + charWidth(ch, part.bold)
end
end
end
return total
return total
end
end


-- Reduce expanded wikitext/HTML to the text a reader actually sees, keeping
-- Strip wiki markup that carries no visible text of its own.
-- track of which runs are bold. Returns a list of lines; each line is a list
local function stripMarkup(src)
-- of words; each word is a list of {text=, bold=} parts.
-- Words break only on real whitespace: NBSP and tag boundaries do not split
-- them, matching how the browser wraps.
function p.parse(src)
src = mw.text.killMarkers(src or "")
src = mw.text.killMarkers(src or "")
src = src:gsub("%[%[[Ff]ile:[^%[%]]*%]%]", "")
src = src:gsub("%[%[[Ff]ile:[^%[%]]*%]%]", "")
Line 150: Line 128:
src = src:gsub("%[%[([^%[%]]*)%]%]", "%1")
src = src:gsub("%[%[([^%[%]]*)%]%]", "%1")
-- Quote markup survives frame:preprocess; strip it so it is not measured.
-- Quote markup survives frame:preprocess; strip it so it is not measured.
src = src:gsub("'''''", ""):gsub("'''", ""):gsub("''", "")
return (src:gsub("'''''", ""):gsub("'''", ""):gsub("''", ""))
end


local lines, line, word, part = {}, {}, {}, nil
-- Reduce expanded wikitext/HTML to the text a reader actually sees: a list of
local stack = {}
-- 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 function boldNow()
local lines = {}
for k = #stack, 1, -1 do
for line in (src .. "\n"):gmatch("([^\n]*)\n") do
if stack[k] then return true end
local words = {}
end
for word in line:gmatch("%S+") do
return false
words[#words + 1] = word
end
local function endWord()
if part then table.insert(word, part); part = nil end
if #word > 0 then table.insert(line, word); word = {} end
end
local function endLine()
endWord()
table.insert(lines, line); line = {}
end
local function push(ch, bold)
if part == nil or part.bold ~= bold then
if part then table.insert(word, part) end
part = { text = ch, bold = bold }
else
part.text = part.text .. ch
end
end
lines[#lines + 1] = words
end
end
local i, n = 1, #src
while i <= n do
if src:sub(i, i) == "<" then
local j = src:find(">", i, true)
if not j then break end
local tag = src:sub(i + 1, j - 1)
local closing, name = tag:match("^%s*(/?)%s*(%a+)")
if name then
name = name:lower()
if name == "br" then
endLine()
elseif VOID_TAGS[name] then -- no effect on weight
elseif closing == "/" then
table.remove(stack)
elseif not tag:match("/%s*$") then
table.insert(stack, BOLD_TAGS[name] == true
or tag:lower():match("font%-weight%s*:%s*bold") ~= nil
or tag:lower():match("font%-weight%s*:%s*[6-9]00") ~= nil)
end
end
i = j + 1
else
local k = src:find("<", i, true) or (n + 1)
local chunk = mw.text.decode(src:sub(i, k - 1), true)
local bold = boldNow()
for ch in ustring.gmatch(chunk, ".") do
if ch == "\n" then
endLine()
elseif ch == " " or ch == "\t" or ch == "\r" then
endWord()
else
push(ch, bold)      -- NBSP falls here: joins, never breaks
end
end
i = k
end
end
endLine()
return lines
return lines
end
end


-- Greedy wrap with the usable width normalised to 1, so `r` is the font size
-- Greedy wrap with the usable width normalised to 1, so `r` is the font size
-- expressed as a fraction of that width. Returns the number of rendered lines.
-- 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 function lineCount(lines, r)
local space = charWidth(" ", false) * r
local space = charWidth(" ") * r
local total = 0
local total = 0
for _, words in ipairs(lines) do
for _, widths in ipairs(lines) do
local cur = 0
local cur = 0
total = total + 1
total = total + 1
for _, word in ipairs(words) do
for _, ww in ipairs(widths) do
local ww = wordWidth(word) * r
ww = ww * r
if cur == 0 then
if cur == 0 then
cur = ww
cur = ww
Line 246: Line 182:
-- container whose content box is exactly that width; no pixel geometry is
-- container whose content box is exactly that width; no pixel geometry is
-- needed here or in the caller.
-- needed here or in the caller.
--
-- Line counting is scale-invariant in width -- every term scales with the font
-- size -- so the ratio is all this needs. Only the height constraint carries a
-- shape, and that is given as an aspect ratio.
--
--
-- opts:
-- opts:
Line 263: Line 195:
local step  = opts.step or 0.1
local step  = opts.step or 0.1


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

Latest revision as of 19:14, 1 August 2026

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