| Latest revision |
Your text |
| 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
| |