Module:TextFitGive feedback
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.
Widths are in em. Sources, all extracted with fonttools:
Retail Demo regular [[:File:Retaildemo-regular.woff2]] unitsPerEm 1000
Retail Demo bold [[:File:Retaildemo-bold.woff2]] unitsPerEm 1000
Open Sans [[:File:Open_Sans.woff2]] unitsPerEm 2048
]==]
local p = {}
local ustring = mw.ustring
-- Retail Demo, normal weight
local RD_REGULAR = {
[" "]=0.240, [","]=0.221, ["."]=0.221, ["0"]=0.645, ["1"]=0.268, ["2"]=0.549, ["3"]=0.557, ["4"]=0.601,
["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,
["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,
[" "]=0.240, -- U+00A0 no-break space
}
-- 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, [""]=0.322, ["®"]=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,
}
-- No bold Open Sans is loaded, so browsers synthesise it
-- Estimated from Retail Demo's measured bold/regular of 1.037.
local SYNTH_BOLD = 1.04
-- Characters in neither face (CJK, rare symbols).
local CJK_WIDTH = 2.0
local FALLBACK = 0.55
local VOID_TAGS = { br = true, img = true, hr = true, input = true,
meta = true, link = true, wbr = true }
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
w = FALLBACK_WIDTHS[ch]
if w then
return bold and w * SYNTH_BOLD or w
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 _, part in ipairs(word) do
for ch in ustring.gmatch(part.text, ".") do
total = total + charWidth(ch, part.bold)
end
end
return total
end
-- Reduce expanded wikitext/HTML to the text a reader actually sees, keeping
-- track of which runs are bold. Returns a list of lines; each line is a list
-- 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, baseBold)
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.
src = src:gsub("'''''", ""):gsub("'''", ""):gsub("''", "")
local lines, line, word, part = {}, {}, {}, nil
local stack = {}
local function boldNow()
if baseBold then return true end
for k = #stack, 1, -1 do
if stack[k] then return true end
end
return false
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
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
end
-- 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.
local function lineCount(lines, r, bold)
local space = charWidth(" ", bold == true) * r
local total = 0
for _, words in ipairs(lines) do
local cur = 0
total = total + 1
for _, word in ipairs(words) do
local ww = wordWidth(word) * 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
local bold = opts.bold == true
local lines = p.parse(text, bold)
local widest = 0
for _, words in ipairs(lines) do
for _, word in ipairs(words) do
local ww = wordWidth(word)
if ww > widest then widest = ww end
end
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, bold) * lineH * (pct / 100) > aspect do
pct = pct - step
end
return math.max(pct, minPct)
end
return p