Module:TextFit: Difference between revisionsGive feedback
Created blank page |
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) |
||
| (3 intermediate revisions by 2 users not shown) | |||
| Line 1: | Line 1: | ||
--[==[ | |||
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 < 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 | |||