Module:TextFit: Difference between revisionsGive feedback
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) |
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: | ||
--[==[ | --[==[ | ||
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. | ||
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. | |||
]==] | ]==] | ||
| Line 22: | Line 24: | ||
local ustring = mw.ustring | local ustring = mw.ustring | ||
-- Retail Demo, | -- Retail Demo, bold weight. Invisible characters are spelled with | ||
-- ustring.char so they cannot be mistaken for a plain space when edited. | |||
local WIDTHS = { | |||
local | |||
[" "]=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, | ||
[ | [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.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, | ||
} | } | ||
-- 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 function charWidth(ch) | |||
local w = WIDTHS[ch] | |||
local function charWidth(ch | |||
local w = | |||
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 | ||
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 ch in ustring.gmatch(word, ".") do | |||
total = total + charWidth(ch) | |||
end | end | ||
return total | return total | ||
end | end | ||
-- | -- Strip wiki markup that carries no visible text of its own. | ||
local function stripMarkup(src) | |||
function | |||
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. | ||
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 | 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 | end | ||
lines[#lines + 1] = words | |||
end | end | ||
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(" " | local space = charWidth(" ") * r | ||
local total = 0 | local total = 0 | ||
for _, | for _, widths in ipairs(lines) do | ||
local cur = 0 | local cur = 0 | ||
total = total + 1 | total = total + 1 | ||
for _, | for _, ww in ipairs(widths) do | ||
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. | ||
-- | -- | ||
-- opts: | -- opts: | ||
| Line 263: | Line 195: | ||
local step = opts.step or 0.1 | local step = opts.step or 0.1 | ||
-- 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( | local widths = {} | ||
for | for i, word in ipairs(words) do | ||
widths[i] = wordWidth(word) | |||
if | 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 | ||