Editing Module:TextFitGive feedback
The edit can be undone.
Please check the comparison below to verify that this is what you want to do, and then publish the changes below to finish undoing the edit.
| Latest revision | Your text | ||
| 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. | character count. Used by [[Module:Abilities/card]] to size ability upgrade | ||
descriptions; [[Module:ItemBox]] can migrate onto it. | |||
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 | |||
Retail Demo is subset to 65 codepoints (space, comma, period, 0-9, A-Z, a-z). | |||
MediaWiki:Common.css declares the card font stack as | |||
'Retail Demo', 'Open Sans', 'PT Serif', serif, so the browser falls back per | |||
character; FALLBACK_WIDTHS holds the Open Sans widths actually used for | |||
punctuation, Cyrillic and accented Latin. | |||
]==] | ]==] | ||
| Line 24: | Line 22: | ||
local ustring = mw.ustring | local ustring = mw.ustring | ||
-- Retail Demo, | -- Retail Demo, normal weight | ||
-- | local RD_REGULAR = { | ||
local | [" "]=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, | [" "]=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 36: | Line 47: | ||
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 | ||
} | } | ||
-- Open Sans, for the characters Retail Demo does not contain | -- Open Sans, for the characters Retail Demo does not contain | ||
| Line 50: | Line 57: | ||
["_"]=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.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.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 92: | Line 98: | ||
["‘"]=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 97: | Line 107: | ||
local FALLBACK = 0.55 | local FALLBACK = 0.55 | ||
local function charWidth(ch) | local VOID_TAGS = { br = true, img = true, hr = true, input = true, | ||
local w = | 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 | if w then return w end | ||
w = FALLBACK_WIDTHS[ch] | w = FALLBACK_WIDTHS[ch] | ||
if w then return w * SYNTH_BOLD end | if w then | ||
return bold and w * SYNTH_BOLD or w | |||
end | |||
local cp = ustring.codepoint(ch) or 0 | local cp = ustring.codepoint(ch) or 0 | ||
| Line 115: | Line 131: | ||
local function wordWidth(word) | local function wordWidth(word) | ||
local total = 0 | local total = 0 | ||
for ch in ustring.gmatch( | for _, part in ipairs(word) do | ||
for ch in ustring.gmatch(part.text, ".") do | |||
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 | ||
-- 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) | |||
src = mw.text.killMarkers(src or "") | src = mw.text.killMarkers(src or "") | ||
src = src:gsub("%[%[[Ff]ile:[^%[%]]*%]%]", "") | src = src:gsub("%[%[[Ff]ile:[^%[%]]*%]%]", "") | ||
| Line 128: | Line 150: | ||
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("''", "") | |||
local lines, line, word, part = {}, {}, {}, nil | |||
local stack = {} | |||
- | local function boldNow() | ||
for k = #stack, 1, -1 do | |||
if stack[k] then return true end | |||
end | |||
return false | |||
function | 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 | local i, n = 1, #src | ||
while i <= n do | |||
local | 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 | ||
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 | -- expressed as a fraction of that width. Returns the number of rendered lines. | ||
local function lineCount(lines, r) | local function lineCount(lines, r) | ||
local space = charWidth(" ") * r | local space = charWidth(" ", false) * r | ||
local total = 0 | local total = 0 | ||
for _, | for _, words in ipairs(lines) do | ||
local cur = 0 | local cur = 0 | ||
total = total + 1 | total = total + 1 | ||
for _, | for _, word in ipairs(words) do | ||
ww = | local ww = wordWidth(word) * r | ||
if cur == 0 then | if cur == 0 then | ||
cur = ww | cur = ww | ||
| Line 182: | Line 246: | ||
-- 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 195: | Line 263: | ||
local step = opts.step or 0.1 | local step = opts.step or 0.1 | ||
local lines = p.parse(text) | |||
local | |||
for _, words in ipairs( | local widest = 0 | ||
for _, words in ipairs(lines) do | |||
for | for _, word in ipairs(words) do | ||
local ww = wordWidth(word) | |||
if | if ww > widest then widest = ww end | ||
end | end | ||
end | end | ||
if widest == 0 then return maxPct end | if widest == 0 then return maxPct end | ||