跳转到内容

模組:Section sizes

被永久保护的模块
维基百科,自由的百科全书
--- Builds sortable section-size tables for wiki pages.
---
--- Public functions:
--- * size(frame): renders byte and prose-size heat maps.
--- * section_size_get(frame): returns one section-size value.
---
--- Internal helpers parse headings, count prose, select byte heat-map
--- values, compute hierarchical totals, format section numbers, and render
--- the final table with mw.html.

require("strict")

local lang_obj = mw.language.getContentLanguage()
local strf = mw.ustring.format

local yesno = require("Module:Yesno")


local appendix_patterns = {
	
    "Notes",
    "[腳脚][註注]",
    "[註注][釋释]",
    "[備备][註注]",
    "[註注]解",
    "附[註注]",
	
    "References",
    "[參参]考",
    "[參参]考[資资]料",
    "[參参]考文[獻献]",
    "[參参]考[來来]源",
    "文[獻献]",
    "引用",
    "[來来]源",
	
    "See also",
    "[參参][見见]",
	"[參参][閱阅]",
    "另[見见]",
	
    "External links",
    "外部[連连][結结]",
    "外部[鏈链]接",
	
    "Further reading",
    "[擴扩]展[閱阅][讀读]",
    "延伸[閱阅][讀读]",
    "[進进]一步[閱阅][讀读]",
	
}

local i18n_t = {
    ["byte_count"] = "字节数",
    ["appendix_ignored"] = "附錄章節原始碼不計入比例",
    ["byte_title"] = "占原始碼總長度的%.1f%%",
    ["appendix_title"] = "占原始碼總長度的%.1f%%(不計附錄章節)",
    ["ldr_title"] = "占原始碼總長度的%.1f%%(不計參考列表)",
    ["error_category"] = "[[Category:有$1错误的页面]]",
    ["article_error"] = "错误:页面$1未創建",
    ["sections_error"] = "错误:页面$1未分章节",
    ["redirect_error"] = "错误:页面$1是重定向",
    ["help_link"] = "([[$1|帮助]])",
    ["markup_removed"] = "标记已移除,无法链接",
    ["prose_title"] = "占总字数的%.1f%%",
    ["prose_size"] = table.concat({
        '<abbr title="不含列表、模板中的文字,',
        '西文单词以两字计,粗略值">正文字数</abbr>',
    }),
    ["section_header"] = table.concat({
        '<abbr title="不包括該章節下的子章節">',
        "本章</abbr>",
    }),
    ["section_name"] = "章节标题",
    ["section_total"] = "合计",
    ["table_caption"] = "「[[$1]]」各章节大小(共含$2节)",
    ["top"] = nil,
    ["total"] = "总计",
}

local section_top = i18n_t.top or mw.message
    .new("vector-toc-beginning")
    :inLanguage(lang_obj:getCode())
    :plain()

--- Returns one invoke argument or its parent-template fallback.
local function frame_arg_get(frame, name)
    local value = frame.args[name]
    if nil ~= value and "" ~= value then
        return value
    end
    local parent = frame:getParent()
    if not parent then
        return nil
    end
    value = parent.args[name]
    if "" == value then
        return nil
    end
    return value
end

--- Returns a parsed yes/no argument with an explicit default.
local function yesno_arg_get(frame, name, default)
    local value = yesno(frame_arg_get(frame, name))
    if nil == value then
        return default
    end
    return value
end

local bytes_heatmap_modes = {
    ["all"] = true,
    ["ignore_appendices"] = true,
    ["ignore_ldr"] = true,
}

--- Returns the selected byte heat-map mode.
local function bytes_heatmap_mode_get(frame)
    local value = frame_arg_get(frame, "bytes_heatmap")
    value = value and string.lower(mw.text.trim(value)) or "all"
    if bytes_heatmap_modes[value] then
        return value
    end
    return "all"
end

local protected_tags = {
    "math",
    "nowiki",
    "pre",
    "ref",
    "source",
    "syntaxhighlight",
}

local heatmap_base = {0xF8, 0xF9, 0xFA}
local byte_heatmap_target = {0xD7, 0x7C, 0x7C}
local byte_heatmap_max = {0x9B, 0x00, 0x00}
local prose_heatmap_target = {0x73, 0xA2, 0x73}
local prose_heatmap_max = {0x00, 0x64, 0x00}
local max_heading_level = 7

local err_cat_added
local stripmarker_pattern =
    "\127[^\127]*UNIQ%-%-%a+%-[%x]+%-QINU[^\127]*\127"

--- Returns a Unicode character-range fragment.
local function u_range(first_code, last_code)
    return mw.ustring.char(first_code) .. "-" ..
        mw.ustring.char(last_code)
end

local han_class = "[" ..
    u_range(0x3400, 0x4DBF) ..
    u_range(0x4E00, 0x9FFF) ..
    u_range(0xF900, 0xFAFF) ..
    "]"

local kana_class = "[" ..
    u_range(0x3040, 0x309F) ..
    u_range(0x30A0, 0x30FF) ..
    "]"

local punct_class = "[" ..
    "%p" ..
    u_range(0x2000, 0x206F) ..
    u_range(0x2E00, 0x2E7F) ..
    u_range(0x3000, 0x303F) ..
    u_range(0xFF00, 0xFF65) ..
    u_range(0xFE10, 0xFE6F) ..
    mw.ustring.char(0x00B7) ..
    mw.ustring.char(0x30FB) ..
    "]"

--- Returns true when a heading is excluded from prose counting.
local function section_noprose(section_name)
    local trimmed = mw.text.trim(section_name)
    for _, pattern in ipairs(appendix_patterns) do
        if mw.ustring.match(trimmed, pattern) == trimmed then
            return true
        end
    end
    return false
end

--- Counts readable characters in mixed CJK and non-CJK prose.
local function charcount_mixed(text)
    local value = mw.ustring.gsub(text, punct_class, " ")
    value = mw.ustring.gsub(value, "%s+", " ")
    value = mw.text.trim(value)
    local _, han_count = mw.ustring.gsub(value, han_class, "")
    local _, kana_count = mw.ustring.gsub(value, kana_class, "")
    value = mw.ustring.gsub(value, han_class, " ")
    value = mw.ustring.gsub(value, kana_class, " ")
    value = mw.ustring.gsub(value, "%s+", " ")
    value = mw.text.trim(value)
    local _, word_count = value:gsub("%S+", "")
    return han_count + kana_count * 0.5 + word_count * 2
end

--- Removes nested templates from prose-counting text.
local function templates_remove(text)
    text = text:gsub("([^{]){([^{])", "%1%2")
    text = text:gsub("([^}])}([^}])", "%1%2")
    local removed = 1
    while 0 < removed do
        text, removed = text:gsub("{{[^{}]-}}", "")
    end
    return text
end

--- Removes list lines from prose-counting text.
local function list_lines_remove(text)
    local lines = {}
    for line in text:gmatch("[^\n]+") do
        if not line:match("^%s*[%*#;:]") then
            table.insert(lines, line)
        end
    end
    return table.concat(lines, "\n")
end

--- Cleans wikitext before prose-character counting.
local function prose_text_clean(wikitext)
    local text = wikitext:gsub("<!%-%-.-%-%->", "")
    text = text:gsub("<ref[^>]-/>", "")
    text = text:gsub("<ref.->.-</ref>", "")
    text = text:gsub("{|.-|}", "")
    text = templates_remove(text)
    text = text:gsub("%[%[(File|Image):.-%]%]", "")
    text = text:gsub("%[%[[^|%]]-|", "[[")
    text = text:gsub("%[%[(.-)%]%]", "%1")
    text = text:gsub("<.->", "")
    text = text:gsub("=+.-=+", "")
    text = text:gsub("''+", "")
    text = list_lines_remove(text)
    text = text:gsub("%s+", " ")
    return text:match("^%s*(.-)%s*$")
end

--- Returns cleaned byte length and readable-character count.
local function prose_size_get(wikitext)
    local text = prose_text_clean(wikitext)
    return #text, charcount_mixed(text)
end

--- Finds the closing angle bracket of one HTML tag.
local function tag_end_find(text, start_pos)
    local quote
    for index = start_pos, #text do
        local char = text:sub(index, index)
        if quote and char == quote then
            quote = nil
        elseif not quote and ('"' == char or "'" == char) then
            quote = char
        elseif not quote and ">" == char then
            return index
        end
    end
    return nil
end

--- Tests whether one tag name begins at a byte position.
local function tag_name_at(lower_text, start_pos, tag_name)
    local prefix = "<" .. tag_name
    local last_pos = start_pos + #prefix - 1
    if lower_text:sub(start_pos, last_pos) ~= prefix then
        return false
    end
    local following = lower_text:sub(last_pos + 1, last_pos + 1)
    return "" == following or following:match("[%s/>]") ~= nil
end

--- Finds the end of one matching closing tag.
local function closing_tag_end_find(text, lower_text, start_pos, tag_name)
    local prefix = "</" .. tag_name
    local search_pos = start_pos
    while true do
        local open_pos = lower_text:find(prefix, search_pos, true)
        if not open_pos then
            return nil
        end
        local following = lower_text:sub(
            open_pos + #prefix,
            open_pos + #prefix
        )
        if "" == following or following:match("[%s>]") then
            return tag_end_find(text, open_pos)
        end
        search_pos = open_pos + 1
    end
end

--- Finds the end of a protected HTML-like block.
local function protected_block_end_find(text, lower_text, start_pos)
    for _, tag_name in ipairs(protected_tags) do
        if tag_name_at(lower_text, start_pos, tag_name) then
            local open_end = tag_end_find(text, start_pos)
            if not open_end then
                return #text
            end
            local opening = text:sub(start_pos, open_end - 1)
            if opening:match("/%s*$") then
                return open_end
            end
            return closing_tag_end_find(
                text,
                lower_text,
                open_end + 1,
                tag_name
            ) or #text
        end
    end
    return nil
end

--- Replaces protected ranges with spaces of equal byte length.
local function mask_range_append(chunks, text, first_pos, last_pos)
    table.insert(chunks, text:sub(first_pos[1], first_pos[2]))
    table.insert(chunks, string.rep(" ", last_pos[2] - last_pos[1] + 1))
end

--- Returns masked wikitext for safe template scanning.
local function wikitext_mask_get(text)
    local lower_text = string.lower(text)
    local chunks = {}
    local chunk_start = 1
    local index = 1
    while index <= #text do
        local range_end
        if "<!--" == text:sub(index, index + 3) then
            local close_pos = text:find("-->", index + 4, true)
            range_end = close_pos and close_pos + 2 or #text
        elseif "<" == text:sub(index, index) then
            range_end = protected_block_end_find(text, lower_text, index)
        end
        if range_end then
            mask_range_append(
                chunks,
                text,
                {chunk_start, index - 1},
                {index, range_end}
            )
            index = range_end + 1
            chunk_start = index
        else
            index = index + 1
        end
    end
    table.insert(chunks, text:sub(chunk_start))
    return table.concat(chunks)
end

--- Normalizes a template name for comparison.
local function template_name_normalize(name)
    local namespace_name = mw.site.namespaces[10].name or "Template"
    name = name:gsub("_", " ")
    name = mw.text.trim(name)
    name = name:gsub("%s+", " ")
    name = name:gsub("^:%s*", "")
    name = string.lower(name)
    local prefixes = {
        "template:",
        string.lower(namespace_name) .. ":",
    }
    for _, prefix in ipairs(prefixes) do
        if prefix == name:sub(1, #prefix) then
            return mw.text.trim(name:sub(#prefix + 1))
        end
    end
    return name
end

--- Reads the name of a template call.
local function template_name_get(text, start_pos)
    local index = start_pos + 2
    while index <= #text do
        local char = text:sub(index, index)
        local pair = text:sub(index, index + 1)
        if "|" == char or "}" == char or "{{" == pair then
            break
        end
        index = index + 1
    end
    return template_name_normalize(
        text:sub(start_pos + 2, index - 1)
    )
end

--- Pushes one template or parameter frame onto a scanner stack.
local function template_stack_push(stack, text, index, kind)
    local item = {
        kind = kind,
        start = index,
    }
    if "template" == kind then
        item.is_reflist = "reflist" == template_name_get(text, index)
    end
    table.insert(stack, item)
end

--- Closes one template frame and records Reflist ranges.
local function template_stack_close(stack, ranges, index)
    local item = table.remove(stack)
    if item and item.is_reflist then
        table.insert(ranges, {item.start, index + 1})
    end
end

--- Returns balanced ranges occupied by Reflist templates.
local function reflist_ranges_get(text)
    local ranges = {}
    local stack = {}
    local index = 1
    while index <= #text do
        local triple = text:sub(index, index + 2)
        local pair = text:sub(index, index + 1)
        local top = stack[#stack]
        if "{{{" == triple then
            template_stack_push(stack, text, index, "parameter")
            index = index + 3
        elseif "{{" == pair then
            template_stack_push(stack, text, index, "template")
            index = index + 2
        elseif top and "parameter" == top.kind and "}}}" == triple then
            table.remove(stack)
            index = index + 3
        elseif top and "template" == top.kind and "}}" == pair then
            template_stack_close(stack, ranges, index)
            index = index + 2
        else
            index = index + 1
        end
    end
    return ranges
end

--- Records one references-tag range and returns the next position.
local function references_range_add(text, lower_text, ranges, start_pos)
    local open_end = tag_end_find(text, start_pos)
    if not open_end then
        return #text + 1
    end
    local opening = text:sub(start_pos, open_end - 1)
    if opening:match("/%s*$") then
        table.insert(ranges, {start_pos, open_end})
        return open_end + 1
    end
    local close_end = closing_tag_end_find(
        text,
        lower_text,
        open_end + 1,
        "references"
    )
    if not close_end then
        return open_end + 1
    end
    table.insert(ranges, {start_pos, close_end})
    return close_end + 1
end

--- Returns ranges occupied by references tags.
local function references_ranges_get(text)
    local ranges = {}
    local lower_text = string.lower(text)
    local search_pos = 1
    while true do
        local start_pos = lower_text:find(
            "<references",
            search_pos,
            true
        )
        if not start_pos then
            return ranges
        end
        if tag_name_at(lower_text, start_pos, "references") then
            search_pos = references_range_add(
                text,
                lower_text,
                ranges,
                start_pos
            )
        else
            search_pos = start_pos + 1
        end
    end
end

--- Sorts ranges by start and then end position.
local function ranges_sort(ranges)
    table.sort(ranges, function(left, right)
        if left[1] == right[1] then
            return left[2] < right[2]
        end
        return left[1] < right[1]
    end)
end

--- Returns the byte length covered by merged ranges.
local function ranges_size_get(ranges)
    if 0 == #ranges then
        return 0
    end
    ranges_sort(ranges)
    local total = 0
    local first_pos = ranges[1][1]
    local last_pos = ranges[1][2]
    for index = 2, #ranges do
        local current = ranges[index]
        if current[1] <= last_pos + 1 then
            last_pos = math.max(last_pos, current[2])
        else
            total = total + last_pos - first_pos + 1
            first_pos = current[1]
            last_pos = current[2]
        end
    end
    return total + last_pos - first_pos + 1
end

--- Returns the byte length of all LDR containers.
local function ldr_size_get(wikitext)
    local masked = wikitext_mask_get(wikitext)
    local ranges = reflist_ranges_get(masked)
    for _, range in ipairs(references_ranges_get(masked)) do
        table.insert(ranges, range)
    end
    return ranges_size_get(ranges)
end

--- Returns the byte value used by the selected heat-map mode.
local function heatmap_size_get(wikitext, mode)
    if "ignore_ldr" ~= mode then
        return #wikitext
    end
    return math.max(0, #wikitext - ldr_size_get(wikitext))
end

--- Converts one sRGB channel to relative luminance.
local function luminance_channel(value)
    value = value / 255
    if value <= 0.04045 then
        return value / 12.92
    end
    return ((value + 0.055) / 1.055) ^ 2.4
end

--- Returns contrast against white for one RGB triplet.
local function white_contrast_get(rgb)
    local luminance = 0.2126 * luminance_channel(rgb[1])
    luminance = luminance + 0.7152 * luminance_channel(rgb[2])
    luminance = luminance + 0.0722 * luminance_channel(rgb[3])
    return 1.05 / (luminance + 0.05)
end

--- Interpolates one heat-map RGB color.
local function heatmap_rgb_get(proportion, target)
    local intensity = 0
    if 0 < proportion then
        intensity = math.log(1 + 99 * proportion) / math.log(100)
    end
    local rgb = {}
    for index = 1, 3 do
        local difference = target[index] - heatmap_base[index]
        rgb[index] = math.floor(
            heatmap_base[index] + difference * intensity + 0.5
        )
    end
    return rgb
end

--- Returns a CSS rgb() color string.
local function css_rgb_get(rgb)
    return strf("rgb(%d, %d, %d)", rgb[1], rgb[2], rgb[3])
end

--- Returns one heat-map cell color pair.
local function heatmap_colors_get(value, total, palette, threshold)
    local proportion = 0 < total and value / total or 0
    local is_long = proportion > threshold
    local rgb = is_long and palette.maximum or
        heatmap_rgb_get(proportion, palette.target)
    local text_color = "rgb(0, 0, 0)"
    if is_long and 7 <= white_contrast_get(rgb) then
        text_color = "rgb(255, 255, 255)"
    end
    return {
        background = css_rgb_get(rgb),
        text = text_color,
    }
end

local byte_palette = {
    maximum = byte_heatmap_max,
    target = byte_heatmap_target,
}

local prose_palette = {
    maximum = prose_heatmap_max,
    target = prose_heatmap_target,
}

--- Returns one percentage tooltip or nil.
local function heatmap_title_get(value, total, format_text)
    if 0 >= value or 0 >= total then
        return nil
    end
    return strf(format_text, 100 * value / total)
end

--- Returns one byte heat-map cell tooltip.
local function byte_heatmap_title_get(value, total, mode, ignored)
    if ignored then
        return i18n_t["appendix_ignored"]
    end
    local keys = {
        ["all"] = "byte_title",
        ["ignore_appendices"] = "appendix_title",
        ["ignore_ldr"] = "ldr_title",
    }
    return heatmap_title_get(value, total, i18n_t[keys[mode]])
end

--- Returns a localized message with parameters substituted.
local function raw_message_get(message, args)
    return mw.message.newRawMessage(message, args):plain()
end

--- Returns the error-category link when it should be emitted.
local function error_category_get(template_name)
    if err_cat_added or not i18n_t["error_category"] then
        return ""
    end
    if 1 ~= mw.title.getCurrentTitle().namespace then
        return ""
    end
    err_cat_added = true
    return raw_message_get(
        i18n_t["error_category"],
        {template_name}
    )
end

--- Builds one fatal or nonfatal error message.
local function error_msg_make(template_name, message, args, fatal)
    local node = mw.html.create("span")
    if fatal then
        node:addClass("error")
        node:css("font-size", "100%")
    else
        node:css("color", "#d33")
    end
    node:wikitext(raw_message_get(message, args))
    node:wikitext(" ")
    node:wikitext(raw_message_get(
        i18n_t["help_link"],
        {template_name}
    ))
    local prefix = fatal and "" or " "
    return prefix .. tostring(node) ..
        error_category_get(template_name)
end

--- Returns article content or a localized fatal error.
local function article_content_get(article, template_name)
    local title_obj = mw.title.new(article)
    if not title_obj then
        return nil, error_msg_make(
            template_name,
            i18n_t["article_error"],
            {article},
            true
        )
    end
    local content = title_obj:getContent()
    if not content then
        return nil, error_msg_make(
            template_name,
            i18n_t["article_error"],
            {article},
            true
        )
    end
    if title_obj.isRedirect then
        return nil, error_msg_make(
            template_name,
            i18n_t["redirect_error"],
            {article},
            true
        )
    end
    return content, nil
end

--- Returns heading level from one heading start.
local function heading_level_get(content, start_pos)
    local marks = content:sub(start_pos):match("^(=+)")
    return marks and #marks or 2
end

--- Finds all heading positions and names.
local function headings_find(content)
    local headings = {}
    local search_pos = 1
    while true do
        local open_pos, end_pos, name = content:find(
            "\n==+ *(.-) *==+",
            search_pos
        )
        if not open_pos then
            return headings
        end
        local start_pos = open_pos + 1
        table.insert(headings, {
            level = heading_level_get(content, start_pos),
            name = name,
            start = start_pos,
        })
        search_pos = end_pos + 1
    end
end

--- Returns one section body by heading index.
local function section_content_get(content, headings, index)
    local current = headings[index]
    local following = headings[index + 1]
    if following then
        return content:sub(current.start, following.start - 1)
    end
    return content:sub(current.start)
end

--- Creates the lead-section record.
local function lead_section_make(
    content,
    first_heading,
    get_prose,
    bytes_heatmap
)
    local lead_content = content:sub(1, first_heading.start - 1)
    local prose_size = 0
    if get_prose then
        prose_size = select(2, prose_size_get(lead_content))
    end
    return {
        content = lead_content,
        heat_size = heatmap_size_get(lead_content, bytes_heatmap),
        level = 2,
        name = section_top,
        no_prose = false,
        prose_size = prose_size,
        size = #lead_content,
        start = 1,
    }
end

--- Creates one named-section record.
local function named_section_make(
    content,
    heading,
    get_prose,
    bytes_heatmap
)
    local no_prose = section_noprose(heading.name)
    local prose_size = 0
    if get_prose and not no_prose then
        prose_size = select(2, prose_size_get(content))
    end
    return {
        content = content,
        heat_size = heatmap_size_get(content, bytes_heatmap),
        level = heading.level,
        name = heading.name,
        no_prose = no_prose,
        prose_size = prose_size,
        size = #content,
        start = heading.start,
    }
end

--- Propagates no-prose status to descendant sections.
local function noprose_inheritance_set(sections)
    local excluded_level
    for index = 2, #sections do
        local section = sections[index]
        if excluded_level and section.level <= excluded_level then
            excluded_level = nil
        end
        if excluded_level then
            section.no_prose = true
            section.prose_size = 0
        elseif section.no_prose then
            excluded_level = section.level
        end
    end
end

--- Excludes appendix sections from byte heat-map colors.
local function appendix_heat_exclusions_set(sections, mode)
    if "ignore_appendices" ~= mode then
        return
    end
    for _, section in ipairs(sections) do
        if section.no_prose then
            section.byte_heat_ignored = true
            section.heat_size = 0
        end
    end
end

--- Builds all section records from article wikitext.
local function sections_build(content, get_prose, bytes_heatmap)
    local headings = headings_find(content)
    if 0 == #headings then
        return nil
    end
    local sections = {
        lead_section_make(
            content,
            headings[1],
            get_prose,
            bytes_heatmap
        ),
    }
    for index, heading in ipairs(headings) do
        local section_content = section_content_get(
            content,
            headings,
            index
        )
        table.insert(sections, named_section_make(
            section_content,
            heading,
            get_prose,
            bytes_heatmap
        ))
    end
    noprose_inheritance_set(sections)
    appendix_heat_exclusions_set(sections, bytes_heatmap)
    return sections
end

--- Adds one numeric field across all sections.
local function sections_field_sum(sections, field)
    local total = 0
    for _, section in ipairs(sections) do
        total = total + (section[field] or 0)
    end
    return total
end

--- Returns the largest value of one numeric section field.
local function sections_field_largest(sections, field)
    local largest_value = 0
    for _, section in ipairs(sections) do
        largest_value = math.max(
            largest_value,
            section[field] or 0
        )
    end
    return largest_value
end

--- Returns true when all sections are level two.
local function sections_are_flat(sections)
    for _, section in ipairs(sections) do
        if 2 < section.level then
            return false
        end
    end
    return true
end

--- Returns section-number text for one hierarchy level.
local function section_number_get(counts, level)
    local parts = {}
    for current = 2, level do
        table.insert(parts, tostring(counts[current] or 0))
    end
    return table.concat(parts, ".")
end

--- Assigns visible and sortable section numbers.
local function section_numbers_set(sections)
    local counts = {}
    sections[1].number = "0"
    sections[1].sort_index = 0
    for index = 2, #sections do
        local level = sections[index].level
        counts[level] = (counts[level] or 0) + 1
        for deeper = level + 1, max_heading_level do
            counts[deeper] = nil
        end
        sections[index].number = section_number_get(counts, level)
        sections[index].sort_index = index - 1
    end
end

--- Creates zero-filled hierarchy accumulators.
local function level_sums_new()
    local sums = {
        heat_size = {},
        prose_size = {},
        size = {},
    }
    for level = 1, max_heading_level do
        sums.heat_size[level] = 0
        sums.prose_size[level] = 0
        sums.size[level] = 0
    end
    return sums
end

--- Clears hierarchy accumulators from one level downward.
local function level_sums_clear(sums, first_level)
    for level = first_level, max_heading_level do
        sums.heat_size[level] = 0
        sums.prose_size[level] = 0
        sums.size[level] = 0
    end
end

--- Sets totals for one section from current accumulators.
local function section_totals_set(section, sums, last_level)
    local level = section.level
    if level < last_level then
        section.total_size = sums.size[level] + section.size
        section.total_heat_size = sums.heat_size[level] +
            section.heat_size
        section.total_prose_size = sums.prose_size[level] +
            section.prose_size
        level_sums_clear(sums, level)
        return
    end
    section.total_size = section.size
    section.total_heat_size = section.heat_size
    section.total_prose_size = section.prose_size
end

--- Adds one section total to its parent-level accumulator.
local function section_totals_accumulate(section, sums)
    if 1 >= section.level then
        return
    end
    local parent = section.level - 1
    sums.size[parent] = sums.size[parent] + section.total_size
    sums.heat_size[parent] = sums.heat_size[parent] +
        section.total_heat_size
    sums.prose_size[parent] = sums.prose_size[parent] +
        section.total_prose_size
end

--- Computes hierarchical totals for every section.
local function section_totals_compute(sections)
    local sums = level_sums_new()
    local last_level = 0
    for index = #sections, 1, -1 do
        local section = sections[index]
        section_totals_set(section, sums, last_level)
        section_totals_accumulate(section, sums)
        last_level = section.level
    end
end

--- Counts level-two sections, optionally excluding no-prose ones.
local function h2_count_get(sections, exclude_no_prose)
    local count = 0
    for _, section in ipairs(sections) do
        local include = 2 == section.level
        if include and exclude_no_prose then
            include = not section.no_prose
        end
        if include then
            count = count + 1
        end
    end
    return count
end

--- Returns the shared white-text threshold for byte cells.
local function byte_threshold_get(sections, mode)
    local exclude_appendices = "ignore_appendices" == mode
    local count = h2_count_get(sections, exclude_appendices)
    if "ignore_ldr" == mode then
        count = math.max(1, count - 1)
    end
    if 0 == count then
        return math.huge
    end
    return 1.5 / count
end

--- Returns the shared white-text threshold for prose cells.
local function prose_threshold_get(sections)
    local count = h2_count_get(sections, true)
    if 0 == count then
        return math.huge
    end
    return 1.5 / count
end

--- Removes red-linked template names from a rendered heading.
local function redlink_template_remove(section_name)
    local pattern = "%[%[:" ..
        mw.site.namespaces[10].name .. ":.-%]%]"
    local count
    section_name, count = section_name:gsub(pattern, "[...]")
    return section_name, 0 < count
end

--- Removes allowed anchor spans from a rendered heading.
local function anchors_remove(section_name)
    local patterns = {
        '<span +[^>]*class *= *"anchor"[^>]*></span>',
        '<span +%f[%a]id *= *".-" *></span>',
    }
    for _, pattern in ipairs(patterns) do
        section_name = section_name:gsub(pattern, "")
    end
    return section_name
end

--- Removes reference markup from one section heading.
local function refs_remove(section_name)
    local name, first_count = section_name:gsub("<ref.-/>", "")
    local second_count
    name, second_count = name:gsub("<ref.->.-</ref>", "")
    return name, 0 < first_count or 0 < second_count
end

--- Removes parser strip markers from one rendered heading.
local function stripmarkers_remove(section_name)
    local count
    section_name, count = section_name:gsub(stripmarker_pattern, "")
    return section_name, 0 < count
end

--- Returns display text from one wikilink.
local function remove_wiki_link(text)
    return text:gsub("%[%[([^%[%]]*)%]%]", function(link)
        return link:gsub("^[^|]*|(.*)$", "%1")
            :gsub("^%s*(.-)%s*$", "%1")
    end)
end

--- Removes HTML-like containers from one rendered heading.
local function remove_container(text)
    local count
    text, count = text:gsub("<([^>]*)>", function(value)
        return value:gsub("^%s*(.-)%s*$", "")
    end)
    return text, 0 < count
end

--- Returns a wikilink or plain display text.
local function make_wikilink(link, display)
    if not link or "" == link then
        return display or ""
    end
    if display and "" ~= display then
        return strf("[[%s|%s]]", link, display)
    end
    return strf("[[%s]]", link)
end

--- Preprocesses one section heading for display and linking.
local function heading_preprocess(frame, section_name)
    local value, modified = refs_remove(section_name)
    value = remove_wiki_link(value)
    value = value:gsub("<nowiki/>", "__sss_nowiki/__")
    value = frame:preprocess(value)
    local changed
    value, changed = redlink_template_remove(value)
    modified = modified or changed
    value = anchors_remove(value)
    value = value:gsub("__sss_nowiki/__", "<nowiki/>")
    value, changed = stripmarkers_remove(value)
    modified = modified or changed
    value = value:gsub("</?i>", "''")
    value = value:gsub("</?b>", "'''")
    value, changed = remove_container(value)
    return value, modified or changed
end

--- Returns a safe section heading display string.
local function heading_display_get(frame, section, template_name, article)
    local value, modified = heading_preprocess(frame, section.name)
    value = value:gsub("[%[%]]", {
        ["["] = "&#91;",
        ["]"] = "&#93;",
    })
    value = mw.text.trim(value)
    if not modified then
        local anchor = value:gsub("''+", "")
        return make_wikilink(article .. "#" .. anchor, value)
    end
    return value .. error_msg_make(
        template_name,
        i18n_t["markup_removed"],
        {},
        false
    )
end

--- Appends the visible section number to a heading cell.
local function section_number_add(parent, section)
    local number = parent:tag("span")
    number:attr("aria-hidden", "true")
    number:css("color", "#72777d")
    number:css("font-size", "86%")
    number:css("-moz-user-select", "none")
    number:css("-webkit-user-select", "none")
    number:css("user-select", "none")
    number:wikitext(strf("§%s", section.number))
    parent:wikitext(" ")
end

--- Appends one rendered heading to a table header or cell.
local function section_heading_add(cell, section, display, flat)
    local parent = cell
    if not flat and 2 < section.level then
        parent = cell:tag("span")
        local indent = (section.level - 2) * 1.6
        parent:css("margin-left", strf("%.1fem", indent))
    end
    section_number_add(parent, section)
    parent:wikitext(display)
end

--- Applies heat-map colors to one cell.
local function heatmap_colors_apply(cell, spec)
    if spec.hidden then
        cell:css("color", "transparent")
        return
    end
    cell:css("background-color", spec.colors.background)
    cell:css("color", spec.colors.text)
end

--- Adds one percentage heat-map cell.
local function heatmap_cell_add(row, spec)
    local cell = row:tag("td")
    cell:css("text-align", "right")
    heatmap_colors_apply(cell, spec)
    if spec.bold then
        cell:css("font-weight", "bold")
    end
    if spec.title then
        cell:attr("title", spec.title)
    end
    if spec.sort_value ~= nil then
        cell:attr("data-sort-value", tostring(spec.sort_value))
    end
    cell:wikitext(spec.display)
    return cell
end

--- Returns one byte heat-map cell specification.
local function byte_cell_spec(
    value,
    total,
    threshold,
    mode,
    ignored
)
    return {
        colors = heatmap_colors_get(value, total, byte_palette, threshold),
        display = lang_obj:formatNum(value),
        title = byte_heatmap_title_get(
            value,
            total,
            mode,
            ignored
        ),
    }
end

--- Returns one prose heat-map cell specification.
local function prose_cell_spec(value, total, threshold, no_prose)
    return {
        colors = heatmap_colors_get(value, total, prose_palette, threshold),
        display = lang_obj:formatNum(value),
        sort_value = no_prose and 0 or nil,
        title = heatmap_title_get(
            value,
            total,
            i18n_t["prose_title"]
        ),
    }
end

--- Returns one hidden total-cell specification.
local function hidden_total_spec(spec)
    spec.hidden = true
    return spec
end

--- Returns whether one total is excluded from byte colors.
local function total_byte_heat_ignored(section, mode)
    if "ignore_appendices" ~= mode then
        return false
    end
    return 0 == section.total_heat_size and 0 < section.total_size
end

--- Returns one section-row rendering context.
local function row_context_get(context, section)
    return {
        bytes_heatmap = context.bytes_heatmap,
        byte_threshold = context.byte_threshold,
        prose_threshold = context.prose_threshold,
        total_heat = context.total_heat,
        total_prose = context.total_prose,
        get_prose = context.get_prose,
        flat = context.flat,
        section = section,
    }
end

--- Adds the section-title cell for one row.
local function section_title_cell_add(row, context, display)
    local cell = row:tag("td")
    cell:css("text-align", "left")
    cell:attr("data-sort-value", context.section.sort_index)
    section_heading_add(
        cell,
        context.section,
        display,
        context.flat
    )
end

--- Adds one byte cell for a flat table row.
local function flat_byte_cell_add(row, context)
    local section = context.section
    local spec = byte_cell_spec(
        section.heat_size,
        context.total_heat,
        context.byte_threshold,
        context.bytes_heatmap,
        section.byte_heat_ignored
    )
    spec.display = lang_obj:formatNum(section.size)
    heatmap_cell_add(row, spec)
end

--- Adds one prose cell for a flat table row.
local function flat_prose_cell_add(row, context)
    if not context.get_prose then
        return
    end
    local section = context.section
    local spec = prose_cell_spec(
        section.prose_size,
        context.total_prose,
        context.prose_threshold,
        section.no_prose
    )
    heatmap_cell_add(row, spec)
end

--- Adds the byte cells for a hierarchical table row.
local function grouped_byte_cells_add(row, context)
    local section = context.section
    local own = byte_cell_spec(
        section.heat_size,
        context.total_heat,
        context.byte_threshold,
        context.bytes_heatmap,
        section.byte_heat_ignored
    )
    own.display = lang_obj:formatNum(section.size)
    heatmap_cell_add(row, own)
    local total = byte_cell_spec(
        section.total_heat_size,
        context.total_heat,
        context.byte_threshold,
        context.bytes_heatmap,
        total_byte_heat_ignored(section, context.bytes_heatmap)
    )
    total.display = lang_obj:formatNum(section.total_size)
    if 2 == section.level then
        total.bold = true
    elseif section.total_size == section.size then
        total = hidden_total_spec(total)
    end
    heatmap_cell_add(row, total)
end

--- Adds the prose cells for a hierarchical table row.
local function grouped_prose_cells_add(row, context)
    if not context.get_prose then
        return
    end
    local section = context.section
    local own = prose_cell_spec(
        section.prose_size,
        context.total_prose,
        context.prose_threshold,
        section.no_prose
    )
    heatmap_cell_add(row, own)
    local total = prose_cell_spec(
        section.total_prose_size,
        context.total_prose,
        context.prose_threshold,
        section.no_prose
    )
    if 2 == section.level then
        total.bold = true
    elseif section.total_prose_size == section.prose_size then
        total = hidden_total_spec(total)
    end
    heatmap_cell_add(row, total)
end

--- Adds one body row to the table.
local function body_row_add(table_node, context, display)
    local row = table_node:tag("tr")
    section_title_cell_add(row, context, display)
    if context.flat then
        flat_byte_cell_add(row, context)
        flat_prose_cell_add(row, context)
        return
    end
    grouped_byte_cells_add(row, context)
    grouped_prose_cells_add(row, context)
end

--- Adds a header cell with optional attributes.
local function header_cell_add(row, text, attrs, plain)
    local cell = row:tag("th")
    if attrs then
        cell:attr(attrs)
    end
    if plain then
        cell:css("font-weight", "normal")
    end
    cell:css("text-align", "center")
    cell:wikitext(text)
    return cell
end

--- Adds the single-row header used for flat articles.
local function flat_header_add(table_node, get_prose)
    local row = table_node:tag("tr")
    header_cell_add(row, i18n_t.section_name)
    header_cell_add(row, i18n_t.byte_count)
    if get_prose then
        header_cell_add(row, i18n_t.prose_size)
    end
end

--- Adds the first grouped-header row.
local function grouped_header_top_add(table_node, get_prose)
    local row = table_node:tag("tr")
    header_cell_add(row, i18n_t.section_name, {
        rowspan = "2",
    })
    header_cell_add(row, i18n_t.byte_count, {
        colspan = "2",
    })
    if get_prose then
        header_cell_add(row, i18n_t.prose_size, {
            colspan = "2",
        })
    end
end

--- Adds the second grouped-header row.
local function grouped_header_bottom_add(table_node, get_prose)
    local row = table_node:tag("tr")
    header_cell_add(row, i18n_t.section_header, nil, true)
    header_cell_add(row, i18n_t.section_total, nil, true)
    if get_prose then
        header_cell_add(row, i18n_t.section_header, nil, true)
        header_cell_add(row, i18n_t.section_total, nil, true)
    end
end

--- Adds the two-row header used for hierarchical articles.
local function grouped_header_add(table_node, get_prose)
    grouped_header_top_add(table_node, get_prose)
    grouped_header_bottom_add(table_node, get_prose)
end

--- Adds the table caption.
local function caption_add(table_node, article, section_count)
    local caption = table_node:tag("caption")
    caption:wikitext(raw_message_get(
        i18n_t["table_caption"],
        {article, section_count}
    ))
end

--- Adds one footer numeric cell.
local function footer_value_add(row, value, colspan)
    local cell = row:tag("th")
    cell:css("text-align", "right")
    if colspan then
        cell:attr("colspan", tostring(colspan))
    end
    cell:wikitext(lang_obj:formatNum(value))
end

--- Adds the table footer.
local function footer_add(table_node, context)
    local row = table_node:tag("tr")
    local label = row:tag("th")
    label:attr("scope", "row")
    label:wikitext(i18n_t.total)
    local span
    if not context.flat then
        span = 2
    end
    footer_value_add(row, context.total_size, span)
    if context.get_prose then
        footer_value_add(row, context.total_prose, span)
    end
end

--- Creates the root sortable table node.
local function table_node_make(style)
    local node = mw.html.create("table")
    node:addClass("wikitable")
    node:addClass("sortable")
    if style and "" ~= style then
        node:cssText(style)
    end
    return node
end

--- Builds the complete table from sections and context.
local function table_render(frame, sections, context)
    local article = frame_arg_get(frame, 1)
    local style = frame_arg_get(frame, "style")
    local table_node = table_node_make(style)
	local table_header = frame_arg_get(frame, "table_header")
	if yesno(table_header) ~= false then
	    caption_add(table_node, article, #sections)
	end
    if context.flat then
        flat_header_add(table_node, context.get_prose)
    else
        grouped_header_add(table_node, context.get_prose)
    end
    for _, section in ipairs(sections) do
        local display = heading_display_get(
            frame,
            section,
            context.template_name,
            article
        )
        local row_context = row_context_get(context, section)
        body_row_add(table_node, row_context, display)
    end
    footer_add(table_node, context)
    return tostring(table_node)
end

--- Builds rendering context shared by all rows.
local function render_context_make(
    frame,
    sections,
    get_prose,
    bytes_heatmap
)
    return {
        byte_threshold = byte_threshold_get(
            sections,
            bytes_heatmap
        ),
        bytes_heatmap = bytes_heatmap,
        flat = sections_are_flat(sections),
        get_prose = get_prose,
        prose_threshold = prose_threshold_get(sections),
        template_name = frame:getParent():getTitle(),
        total_heat = sections_field_sum(sections, "heat_size"),
        total_prose = sections_field_sum(sections, "prose_size"),
        total_size = sections_field_sum(sections, "size"),
    }
end

--- Returns parsed sections or a no-sections error.
local function sections_or_error_get(
    content,
    template_name,
    article,
    get_prose,
    bytes_heatmap
)
    local sections = sections_build(
        content,
        get_prose,
        bytes_heatmap
    )
    if sections then
        return sections, nil
    end
    return nil, error_msg_make(
        template_name,
        i18n_t["sections_error"],
        {article},
        true
    )
end

--- Prepares parsed sections and rendering context.
local function table_data_prepare(
    frame,
    content,
    get_prose,
    bytes_heatmap,
    template_name
)
    local sections, message = sections_or_error_get(
        content,
        template_name,
        frame_arg_get(frame, 1),
        get_prose,
        bytes_heatmap
    )
    if message then
        return nil, nil, message
    end
    section_numbers_set(sections)
    section_totals_compute(sections)
    local context = render_context_make(
        frame,
        sections,
        get_prose,
        bytes_heatmap
    )
    return sections, context, nil
end

--- Module entry point that renders the section-size table.
local function size(frame)
    local template_name = frame:getParent():getTitle()
    local get_prose = yesno_arg_get(frame, "getprose", true)
    local bytes_heatmap = bytes_heatmap_mode_get(frame)
    local content, message = article_content_get(
        frame_arg_get(frame, 1),
        template_name
    )
    if message then
        return message
    end
    local sections, context
    sections, context, message = table_data_prepare(
        frame,
        content,
        get_prose,
        bytes_heatmap,
        template_name
    )
    if message then
        return message
    end
    return table_render(frame, sections, context)
end

--- Returns one section size formatted per caller options.
local function section_size_format(value, total, no_separator, percent)
    if percent then
        return strf("%2.2f%%", 100 * value / total)
    end
    return lang_obj:formatNum(value, {
        noCommafy = no_separator,
    })
end

--- Returns the total size of one named section and descendants.
local function named_section_total_get(sections, target_index)
    local target = sections[target_index]
    local total = target.size
    for index = target_index + 1, #sections do
        local current = sections[index]
        if current.level <= target.level then
            return total
        end
        total = total + current.size
    end
    return total
end

--- Finds one named section by case-folded heading text.
local function named_section_index_get(sections, target_name)
    local folded = lang_obj:caseFold(target_name)
    for index, section in ipairs(sections) do
        local current = lang_obj:caseFold(section.name)
        if current == folded then
            return index
        end
    end
    return nil
end

--- Returns the requested raw section-size value.
local function section_value_get(sections, key, include_children)
    if "_lead" == key then
        return sections[1].size
    end
    if "_max" == key then
        return sections_field_largest(sections, "size")
    end
    if "_total" == key then
        return sections_field_sum(sections, "size")
    end
    if not key then
        return nil
    end
    local index = named_section_index_get(sections, key)
    if not index then
        return nil
    end
    if include_children then
        return named_section_total_get(sections, index)
    end
    return sections[index].size
end

--- Resolves a section-size request from parsed arguments.
local function section_request_get(args, template_name)
    local content, message = article_content_get(
        args[1],
        template_name
    )
    if message then
        return nil, nil, message
    end
    local sections
    sections, message = sections_or_error_get(
        content,
        template_name,
        args[1],
        false,
        true
    )
    if message then
        return nil, nil, message
    end
    local value = section_value_get(
        sections,
        args[2],
        "_all" == args[3]
    )
    return value, sections, nil
end

--- Module entry point that returns one section-size value.
local function section_size_get(frame)
    local args = require("Module:Arguments").getArgs(frame)
    local template_name = frame:getParent():getTitle()
    local value, sections, message = section_request_get(
        args,
        template_name
    )
    if message then
        return message
    end
    if value == nil then
        return nil
    end
    return section_size_format(
        value,
        sections_field_sum(sections, "size"),
        yesno(args._nosep, false),
        yesno(args._pct, false)
    )
end

return {
    section_size_get = section_size_get,
    size = size,
}