Module:TextFit: Difference between revisions

Vergir (talk | contribs)
add bold option to parse/fitText
Vergir (talk | contribs)
Lift the parser's inner closures to top-level functions taking an explicit state table, and note in the header why the comment uses level-2 long brackets (with help from vergir-bot LLM)
Line 5: Line 5:
character count.
character count.


Widths are in em. Sources, all extracted with fonttools:
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 regular  [[:File:Retaildemo-regular.woff2]]  unitsPerEm 1000
   Retail Demo regular  [[:File:Retaildemo-regular.woff2]]  unitsPerEm 1000
   Retail Demo bold    [[:File:Retaildemo-bold.woff2]]    unitsPerEm 1000
   Retail Demo bold    [[:File:Retaildemo-bold.woff2]]    unitsPerEm 1000
   Open Sans            [[:File:Open_Sans.woff2]]          unitsPerEm 2048
   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 131: Line 136:
end
end


-- Reduce expanded wikitext/HTML to the text a reader actually sees, keeping
-- Parser state. `lines` is the finished output; `line`, `word` and `part` are
-- track of which runs are bold. Returns a list of lines; each line is a list
-- the containers currently being filled. A part is a run of one weight inside
-- of words; each word is a list of {text=, bold=} parts.
-- a word, so a word split across tags stays a single word.
-- Words break only on real whitespace: NBSP and tag boundaries do not split
local function newState()
-- them, matching how the browser wraps.
return { lines = {}, line = {}, word = {}, part = nil }
function p.parse(src, baseBold)
end
 
local function endWord(st)
if st.part then
table.insert(st.word, st.part)
st.part = nil
end
if #st.word > 0 then
table.insert(st.line, st.word)
st.word = {}
end
end
 
local function endLine(st)
endWord(st)
table.insert(st.lines, st.line)
st.line = {}
end
 
local function pushChar(st, ch, bold)
if st.part == nil or st.part.bold ~= bold then
if st.part then table.insert(st.word, st.part) end
st.part = { text = ch, bold = bold }
else
st.part.text = st.part.text .. ch
end
end
 
-- True if any open tag on the stack set bold weight.
local function stackIsBold(stack)
for k = #stack, 1, -1 do
if stack[k] then return true end
end
return false
end
 
-- Strip wiki markup that carries no visible text of its own.
local function stripMarkup(src)
src = mw.text.killMarkers(src or "")
src = mw.text.killMarkers(src or "")
src = src:gsub("%[%[[Ff]ile:[^%[%]]*%]%]", "")
src = src:gsub("%[%[[Ff]ile:[^%[%]]*%]%]", "")
Line 142: Line 184:
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("''", "")
return (src:gsub("'''''", ""):gsub("'''", ""):gsub("''", ""))
end


local lines, line, word, part = {}, {}, {}, nil
-- Apply one HTML tag: push or pop the weight stack, or end the line on <br>.
local stack = {}
local function applyTag(st, stack, tag)
local closing, name = tag:match("^%s*(/?)%s*(%a+)")
if not name then return end
name = name:lower()


local function boldNow()
if name == "br" then
if baseBold then return true end
endLine(st)
for k = #stack, 1, -1 do
elseif VOID_TAGS[name] then -- no effect on weight
if stack[k] then return true end
elseif closing == "/" then
end
table.remove(stack)
return false
elseif not tag:match("/%s*$") then
end
local lower = tag:lower()
local function endWord()
table.insert(stack, BOLD_TAGS[name] == true
if part then table.insert(word, part); part = nil end
or lower:match("font%-weight%s*:%s*bold") ~= nil
if #word > 0 then table.insert(line, word); word = {} end
or lower:match("font%-weight%s*:%s*[6-9]00") ~= nil)
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
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.
-- `baseBold` measures every run as bold, for callers whose text is entirely
-- bold by CSS rather than by markup.
function p.parse(src, baseBold)
src = stripMarkup(src)


local st = newState()
local stack = {}
local i, n = 1, #src
local i, n = 1, #src
while i <= n do
while i <= n do
if src:sub(i, i) == "<" then
if src:sub(i, i) == "<" then
local j = src:find(">", i, true)
local j = src:find(">", i, true)
if not j then break end
if not j then break end
local tag = src:sub(i + 1, j - 1)
applyTag(st, stack, 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
i = j + 1
else
else
local k = src:find("<", i, true) or (n + 1)
local k = src:find("<", i, true) or (n + 1)
local chunk = mw.text.decode(src:sub(i, k - 1), true)
local chunk = mw.text.decode(src:sub(i, k - 1), true)
local bold = boldNow()
local bold = baseBold == true or stackIsBold(stack)
for ch in ustring.gmatch(chunk, ".") do
for ch in ustring.gmatch(chunk, ".") do
if ch == "\n" then
if ch == "\n" then
endLine()
endLine(st)
elseif ch == " " or ch == "\t" or ch == "\r" then
elseif ch == " " or ch == "\t" or ch == "\r" then
endWord()
endWord(st)
else
else
push(ch, bold)     -- NBSP falls here: joins, never breaks
pushChar(st, ch, bold) -- NBSP falls here: joins, never breaks
end
end
end
end
Line 208: Line 242:
end
end
end
end
endLine()
 
return lines
endLine(st)
return st.lines
end
end


Line 245: Line 280:
--  max, min    ceiling and floor, also as a percentage of the width
--  max, min    ceiling and floor, also as a percentage of the width
--  step        search granularity
--  step        search granularity
--  bold        measure every run at bold weight, for callers whose text is
--              entirely bold rather than marked up
function p.fit(text, opts)
function p.fit(text, opts)
local aspect = opts.aspect
local aspect = opts.aspect