Module:PhysicsQC: Difference between revisions

From HandWiki Stage
Jump to navigation Jump to search
Created page with "local p = {} -- ========================= -- Styling configuration -- ========================= local STYLE = {} STYLE.sectionHeader = "font-family:'Segoe UI','Helvetica Neue',Arial,sans-serif;" .. "font-weight:400;" .. "font-size:100%;" .. "color:#000;" .. "display:inline;" .. "line-height:1.2em;" STYLE.sectionBox = "font-family:'Segoe UI','Helvetica Neue',Arial,sans-serif;" .. "font-size:90%;" .. "color:#000;" .. "border:0;"..."
 
Fix gallery thumbnail sizing
 
(2 intermediate revisions by one other user not shown)
Line 1: Line 1:
local p = {}
local p = {}


-- =========================
-- ============================================================
-- Styling configuration
-- Module:PhysicsQC
-- =========================
-- Quantum Collection table-of-contents and gallery renderer.
 
--
local STYLE = {}
-- Restored rules:
 
--   * Index main groups: 2 columns.
STYLE.sectionHeader =
--   * Book II / Book III / Book IV: one horizontal row below Index.
    "font-family:'Segoe UI','Helvetica Neue',Arial,sans-serif;" ..
--   * Full contents: ONE column.
    "font-weight:400;" ..
--   * Full contents preserves raw body, including images.
    "font-size:100%;" ..
--   * Counts exclude File/Image/Book/etc.
    "color:#000;" ..
--   * Gallery uses compact cards.
    "display:inline;" ..
-- ============================================================
    "line-height:1.2em;"
 
STYLE.sectionBox =
    "font-family:'Segoe UI','Helvetica Neue',Arial,sans-serif;" ..
    "font-size:90%;" ..
    "color:#000;" ..
    "border:0;" ..
    "background:transparent;" ..
    "padding:0;" ..
    "margin:0.05em 0;"
 
STYLE.backLink =
    "font-family:'Segoe UI','Helvetica Neue',Arial,sans-serif;" ..
    "font-size:100%;" ..
    "font-weight:400;" ..
    "float:right;" ..
    "margin-left:1em;" ..
    "color:#3366cc;"
 
STYLE.indexSubheading =
    "font-weight:700;" ..
    "margin:0.45em 0 0.15em 0;"
 
STYLE.indexSubbook =
    "margin:0.15em 0 0.55em 1.4em;" ..
    "font-size:100%;"
 
STYLE.galleryWrap =
    "display:flex;" ..
    "flex-wrap:wrap;" ..
    "gap:12px;" ..
    "align-items:flex-start;"
 
STYLE.galleryCard =
    "text-align:center;" ..
    "vertical-align:top;" ..
    "border:1px solid #c8ccd1;" ..
    "background:#fff8dc;" ..
    "padding:6px;" ..
    "box-sizing:border-box;"
 
STYLE.galleryCaption =
    "font-size:90%;" ..
    "display:block;" ..
    "margin-top:4px;" ..
    "line-height:1.25em;"
 
-- =========================
-- Helpers
-- =========================


local function trim(s)
local function trim(s)
    return (s or ""):gsub("^%s+", ""):gsub("%s+$", "")
if not s then
return ''
end
return mw.text.trim(s)
end
end


local function makeAnchorId(s)
local function escapeHtml(s)
    s = trim(s)
return mw.text.encode(s or '')
    s = s:gsub("%s+", "_")
    s = s:gsub("[^%w_%-]", "")
    return s
end
end


local function getContent(page)
local function getPageContent(pageName)
    if not page or page == "" then
local title = mw.title.new(pageName or '')
        return ""
if not title then
    end
return ''
end


    local title = mw.title.new(page)
local text = title:getContent() or ''
    if not title then
        return ""
    end


    return title:getContent() or ""
-- Prevent visible <includeonly> / </includeonly> leaks from data/template pages.
text = text:gsub('<%s*/?%s*includeonly%s*>', '')
 
return text
end
end


local function getCurrentPageName()
local function getCurrentPageName()
    local title = mw.title.getCurrentTitle()
local t = mw.title.getCurrentTitle()
    if not title then
if not t then
        return ""
return ''
    end
end
    return title.prefixedText or ""
return t.prefixedText or ''
end
end


local function isBookPage()
local function isCurrentBookPage()
    local page = getCurrentPageName()
return getCurrentPageName():match('^Book:Quantum Collection') ~= nil
 
    return page == "Book:Quantum Collection"
        or page == "Book:Quantum Collection/Matter (by scale)"
        or page == "Book:Quantum Collection/Methods and tools"
end
end


local function shouldCollapseSections()
local function stripNamespaceForLabel(page)
    return not isBookPage()
page = page or ''
page = page:gsub('^Physics:Quantum data analysis/', '')
page = page:gsub('^Physics:Quantum methods/', '')
page = page:gsub('^Physics:Quantum materials/', '')
page = page:gsub('^Physics:Quantum matter/', '')
page = page:gsub('^Physics:Quantum atoms/', '')
page = page:gsub('^Physics:Quantum basics/', '')
page = page:gsub('^Physics:Quantum ', '')
page = page:gsub('^Physics:', '')
page = page:gsub('^Book:Quantum Collection/', '')
page = page:gsub('_', ' ')
return page
end
end


local function shouldUseTwoColumnSections()
local function makeAnchorId(s)
    return not isBookPage()
s = s or ''
end
s = mw.text.decode(s, true)
 
s = s:gsub('<[^>]+>', '')
local function isIgnoredIndexTarget(target)
s = s:gsub('&nbsp;', ' ')
    return target:match("^(File|Image|Category|Help|Special):")
s = trim(s)
end
s = s:gsub('%s+', '_')
 
return mw.uri.anchorEncode(s)
local function isRealPageTarget(target)
    return target:match("^Book:")
        or target:match("^Physics:")
        or target:match("^Template:")
        or target:match("^Module:")
        or target:find("/")
end
 
local function isSubbookLine(line)
    return line and line:find("QC:subbook", 1, true) ~= nil
end
end


local function displayText(target, display)
local function isSkippedLink(link)
    if display and display ~= "" then
if not link or link == '' then
        return display
return true
    end
end


    if target:match("^Book:Quantum Collection/") then
link = trim(link)
        return target:gsub("^Book:Quantum Collection/", "")
    end


    if target:match("^Physics:Quantum basics/See also/") then
if link:match('^#') then return true end
        return target:gsub("^Physics:Quantum basics/See also/", "")
if link:match('^File:') then return true end
    end
if link:match('^Image:') then return true end
if link:match('^Category:') then return true end
if link:match('^Help:') then return true end
if link:match('^Special:') then return true end
if link:match('^Template:') then return true end
if link:match('^Module:') then return true end


    if target:match("^Physics:") then
return false
        return target:gsub("^Physics:", "")
    end
 
    return target
end
end


function p.test(frame)
local function isSkippedArticleCountLink(link)
    return "Physics QC module working"
if isSkippedLink(link) then return true end
end
if link:match('^Book:') then return true end
 
return false
-- =========================
-- Content extraction
-- =========================
 
local function extractIndexBlock(content)
    local indexBlock = content:match("==%s*Index%s*==%s*(.-)\n==%s*Full contents%s*==")
    if not indexBlock then
        indexBlock = content:match("==%s*Index%s*==%s*(.-)</includeonly>")
    end
    if not indexBlock then
        indexBlock = content:match("==%s*Index%s*==%s*(.*)")
    end
    return indexBlock or ""
end
end


local function extractFullContentsBlock(content)
local function parseWikiLink(raw)
    local fullBlock = content:match("==%s*Full contents%s*==%s*(.-)</includeonly>")
raw = raw or ''
    if not fullBlock then
        fullBlock = content:match("==%s*Full contents%s*==%s*(.*)")
    end
    return fullBlock or ""
end


local function countFullContentsItems(content)
local target, label = raw:match('^([^|]+)|(.+)$')
    local seen = {}
    local count = 0
    local fullBlock = extractFullContentsBlock(content)


    for target in fullBlock:gmatch("<li>%s*%[%[([^%]|#]+)") do
if not target then
        target = trim(target)
target = raw
label = stripNamespaceForLabel(raw)
end


        if not target:match("^(File|Image|Category|Help|Special|Template):") then
target = trim(target)
            local key = mw.ustring.lower(target)
label = trim(label)
            if not seen[key] then
                seen[key] = true
                count = count + 1
            end
        end
    end


    return count
return target, label
end
end


-- =========================
local function countArticleLinks(text)
-- Section parsing
local count = 0
-- =========================
local seen = {}


local function parseSections(fullBlock)
for raw in (text or ''):gmatch('%[%[([^%]]+)%]%]') do
    local sections = {}
local target = raw:match('^([^|#]+)') or raw
    local currentTitle = nil
target = trim(target)
    local currentLines = {}


    local function flushSection()
if not isSkippedArticleCountLink(target) and not seen[target] then
        if currentTitle then
seen[target] = true
            table.insert(sections, {
count = count + 1
                title = currentTitle,
end
                body = table.concat(currentLines, "\n")
end
            })
        end
        currentLines = {}
    end


    for line in mw.text.gsplit(fullBlock, "\n", true) do
return count
        local heading = line:match("^===%s*(.-)%s*===%s*$")
        if heading then
            flushSection()
            currentTitle = trim(heading)
        else
            if currentTitle then
                table.insert(currentLines, line)
            end
        end
    end
 
    flushSection()
    return sections
end
end


local function countRenderableItems(sectionBody)
-- ============================================================
    local count = 0
-- Parse == Index ==
    local seen = {}
-- ============================================================


    for target in sectionBody:gmatch("<li>%s*%[%[([^%]|#]+)") do
local function parseIndex(content)
        target = trim(target)
local indexText = content:match('==%s*Index%s*==%s*(.-)%s*==%s*Full contents%s*==')


        if not target:match("^(File|Image|Category|Help|Special|Template):") then
if not indexText then
            local key = mw.ustring.lower(target)
indexText = content:match('==%s*Index%s*==%s*(.*)') or ''
            if not seen[key] then
end
                seen[key] = true
                count = count + 1
            end
        end
    end


    return count
local groups = {}
end
local currentGroup = nil


local function normalizeSectionBody(sectionBody)
for line in indexText:gmatch('[^\r\n]+') do
    local lines = {}
line = trim(line)


    for line in mw.text.gsplit(sectionBody, "\n", true) do
if line ~= '' then
        local trimmed = trim(line)
local boldContent = line:match("^'''%s*(.-)%s*'''%s*$")


        if trimmed ~= "<ol>"
if boldContent then
            and trimmed ~= "</ol>"
currentGroup = {
            and trimmed ~= '<div class="mw-collapsible mw-collapsed" style="clear:both; border:1px solid #c8ccd1; background:#f8f9fa; padding:0.6em; margin:0.5em 0 1em 0;">'
name = trim(boldContent),
            and trimmed ~= "</div>" then
links = {}
}
table.insert(groups, currentGroup)
else
for raw in line:gmatch('%[%[([^%]]+)%]%]') do
local target, label = parseWikiLink(raw)
target = target:match('^([^#]+)') or target
target = trim(target)


            local templateTarget = trimmed:match("^<li>%s*%[%[(Template:[^%]]+)%]%]%s*</li>%s*$")
if currentGroup and not isSkippedLink(target) then
            if templateTarget then
table.insert(currentGroup.links, {
                table.insert(lines, "[[" .. templateTarget .. "]]")
target = target,
            else
label = label
                table.insert(lines, line)
})
            end
end
        end
end
    end
end
end
end


    return table.concat(lines, "\n")
return groups
end
end


local function adjustSectionImageMarkup(sectionBody)
local function isBookGroup(group)
    if not sectionBody or sectionBody == "" then
if not group or not group.name then
        return sectionBody
return false
    end
end


    local function adjustOne(markup)
local name = group.name
        local out = markup
        out = out:gsub("|%s*right%s*", "")
        out = out:gsub("|%s*left%s*", "")
        out = out:gsub("|%s*center%s*", "")
        out = out:gsub("|%s*%d+px%s*", "|180px")


        if not out:match("|%s*thumb%s*") then
if name == 'Book II' then return true end
            out = out:gsub("^(%[%[%s*[Ff][Ii][Ll][Ee]:[^%]|%]]+)", "%1|thumb", 1)
if name == 'Book III' then return true end
            out = out:gsub("^(%[%[%s*[Ii][Mm][Aa][Gg][Ee]:[^%]|%]]+)", "%1|thumb", 1)
if name == 'Book IV' then return true end
        end


        out = out:gsub("|%s*thumb%s*", "|thumb|center", 1)
if name == 'Quantum Book II' then return true end
        return out
if name == 'Quantum Book III' then return true end
    end
if name == 'Quantum Book IV' then return true end


    sectionBody = sectionBody:gsub("(%[%[%s*[Ff][Ii][Ll][Ee]:.-%]%])", adjustOne, 1)
return false
    sectionBody = sectionBody:gsub("(%[%[%s*[Ii][Mm][Aa][Gg][Ee]:.-%]%])", adjustOne, 1)
end


    return sectionBody
local function displayBookGroupName(name)
name = name or ''
name = name:gsub('^Quantum%s+', '')
return name
end
end


-- =========================
local function renderOrdinaryIndexGroup(group, startNumber)
-- Index parsing/rendering
local out = {}
-- =========================
local n = startNumber or 1


local function parseExplicitIndex(indexBlock)
table.insert(out, '<div style="break-inside:avoid; margin-bottom:0.8em;">')
    local entries = {}
table.insert(out, '<div style="font-weight:bold; margin-bottom:0.25em;">' .. escapeHtml(group.name) .. '</div>')


    for line in mw.text.gsplit(indexBlock, "\n", true) do
if #group.links > 0 then
        local trimmed = trim(line)
table.insert(out, '<div style="margin-top:0; margin-left:1.2em;">')


        if trimmed ~= "" then
for _, link in ipairs(group.links) do
            local boldHeading = trimmed:match("^'''(.-)'''$")
local anchor = makeAnchorId(link.label)
            local isSubbook = isSubbookLine(trimmed)


            -- Match links anywhere in the line, so <li><!--QC:subbook-->[[...]]</li> works.
if link.target:match('^Book:') then
            local target, display = trimmed:match("%[%[([^%]|%]]+)|([^%]]+)%]%]")
table.insert(out, '<div>' .. n .. '. [[' .. link.target .. '|' .. escapeHtml(link.label) .. ']]</div>')
            local plainTarget = nil
n = n + 1
else
table.insert(out, '<div>' .. n .. '. [[#' .. anchor .. '|' .. escapeHtml(link.label) .. ']]</div>')
n = n + 1
end
end


            if not target then
table.insert(out, '</div>')
                plainTarget = trimmed:match("%[%[([^%]|%]]+)%]%]")
end
            end


            if boldHeading then
table.insert(out, '</div>')
                table.insert(entries, {
                    kind = "heading",
                    text = trim(boldHeading)
                })
            elseif target then
                target = trim(target)
                display = trim(display)


                if not isIgnoredIndexTarget(target)
return table.concat(out, '\n'), n
                    and not display:match("^%d+px")
                    and display ~= "right"
                    and display ~= "left"
                    and display ~= "thumb" then
 
                    table.insert(entries, {
                        kind = "item",
                        target = target,
                        display = display,
                        isSubbook = isSubbook
                    })
                end
 
            elseif plainTarget then
                plainTarget = trim(plainTarget)
 
                if not isIgnoredIndexTarget(plainTarget)
                    and not plainTarget:match("^%d+px")
                    and plainTarget ~= "right"
                    and plainTarget ~= "left"
                    and plainTarget ~= "thumb" then
 
                    table.insert(entries, {
                        kind = "item",
                        target = plainTarget,
                        display = nil,
                        isSubbook = isSubbook
                    })
                end
            end
        end
    end
 
    return entries
end
end


local function renderIndexFromExplicitBlock(indexBlock)
local function renderBookRow(bookGroups)
    local entries = parseExplicitIndex(indexBlock)
local out = {}
    if #entries == 0 then
        return ""
    end


    local groups = {}
table.insert(out, '<div class="noexcerpt" style="margin:1em 0 0.8em 0;">')
    local currentGroup = nil
table.insert(out, '<div style="display:flex; gap:2em; align-items:flex-start;">')


    for _, entry in ipairs(entries) do
for _, group in ipairs(bookGroups) do
        if entry.kind == "heading" then
table.insert(out, '<div style="flex:1; min-width:0;">')
            currentGroup = {
table.insert(out, '<div style="font-weight:bold; margin-bottom:0.25em;">' .. escapeHtml(displayBookGroupName(group.name)) .. '</div>')
                heading = entry.text,
                items = {}
            }
            table.insert(groups, currentGroup)
        elseif entry.kind == "item" then
            if not currentGroup then
                currentGroup = {
                    heading = "",
                    items = {}
                }
                table.insert(groups, currentGroup)
            end


            table.insert(currentGroup.items, {
if #group.links > 0 then
                target = entry.target,
table.insert(out, '<ul style="margin-top:0;">')
                display = entry.display,
                isSubbook = entry.isSubbook
            })
        end
    end


    if #groups == 0 then
for _, link in ipairs(group.links) do
        return ""
if link.target:match('^Book:') then
    end
table.insert(out, '<li>[[' .. link.target .. '|' .. escapeHtml(link.label) .. ']]</li>')
else
local anchor = makeAnchorId(link.label)
table.insert(out, '<li>[[#' .. anchor .. '|' .. escapeHtml(link.label) .. ']]</li>')
end
end


    local function countNumberedItems(groupList)
table.insert(out, '</ul>')
        local n = 0
end
        for _, g in ipairs(groupList) do
            for _, item in ipairs(g.items) do
                if not item.isSubbook then
                    n = n + 1
                end
            end
        end
        return n
    end


    local leftGroups = {}
table.insert(out, '</div>')
    local rightGroups = {}
end


    local split = math.ceil(#groups / 2)
table.insert(out, '</div>')
table.insert(out, '</div>')


    for i, group in ipairs(groups) do
return table.concat(out, '\n')
        if i <= split then
end
            table.insert(leftGroups, group)
        else
            table.insert(rightGroups, group)
        end
    end


    if #rightGroups == 0 and #leftGroups > 1 then
local function renderIndex(groups)
        table.insert(rightGroups, table.remove(leftGroups))
if #groups == 0 then
    end
return ''
end


    local function renderItemLink(item)
local normalGroups = {}
        local target = item.target
local bookGroups = {}
        local label = displayText(target, item.display)


        if isRealPageTarget(target) then
for _, group in ipairs(groups) do
            return '[[' .. target .. '|' .. label .. ']]'
if isBookGroup(group) then
        else
table.insert(bookGroups, group)
            local anchorId = makeAnchorId(target)
else
            return '[[#' .. anchorId .. '|' .. label .. ']]'
table.insert(normalGroups, group)
        end
end
    end
end


    local function renderColumn(groupList, startNumber)
local out = {}
        local out = {}
        local itemNumber = startNumber


        for _, group in ipairs(groupList) do
table.insert(out, '= Index =')
            if trim(group.heading) ~= "" then
                table.insert(out,
                    '<div style="' .. STYLE.indexSubheading .. '">' ..
                    mw.text.encode(group.heading) ..
                    '</div>'
                )
            end


            if #group.items > 0 then
table.insert(out, '<div class="noexcerpt" style="column-count:2; column-gap:2em;">')
                local olOpen = false


                for _, item in ipairs(group.items) do
local indexNumber = 1
                    if item.isSubbook then
                        if olOpen then
                            table.insert(out, '</ol>')
                            olOpen = false
                        end


                        table.insert(out,
for _, group in ipairs(normalGroups) do
                            '<div style="' .. STYLE.indexSubbook .. '">→ ' ..
local rendered
                            renderItemLink(item) ..
rendered, indexNumber = renderOrdinaryIndexGroup(group, indexNumber)
                            '</div>'
table.insert(out, rendered)
                        )
end
                    else
                        if not olOpen then
                            table.insert(out, '<ol start="' .. tostring(itemNumber) .. '">')
                            olOpen = true
                        end


                        table.insert(out, '<li>' .. renderItemLink(item) .. '</li>')
table.insert(out, '</div>')
                        itemNumber = itemNumber + 1
                    end
                end


                if olOpen then
if #bookGroups > 0 then
                    table.insert(out, '</ol>')
table.insert(out, renderBookRow(bookGroups))
                end
end
            end
        end


        return table.concat(out, "\n"), itemNumber
return table.concat(out, '\n')
    end
end
 
    local leftCount = countNumberedItems(leftGroups)
    local leftHtml = select(1, renderColumn(leftGroups, 1))
    local rightHtml = ""
 
    if #rightGroups > 0 then
        rightHtml = select(1, renderColumn(rightGroups, leftCount + 1))
    end
 
    local out = {}
    table.insert(out, '<div style="display:flex; gap:30px; align-items:flex-start;">')
    table.insert(out, '<div style="flex:1; min-width:0;">')
    table.insert(out, leftHtml)
    table.insert(out, '</div>')
    table.insert(out, '<div style="flex:1; min-width:0;">')
    table.insert(out, rightHtml)
    table.insert(out, '</div>')
    table.insert(out, '</div>')


    return table.concat(out, "\n")
-- ============================================================
end
-- Parse == Full contents ==
-- ============================================================


local function renderIndexFromSections(sections)
local function parseFullContents(content)
    if #sections == 0 then
local full = content:match('==%s*Full contents%s*==%s*(.*)') or ''
        return ""
local sections = {}
    end
local current = nil


    local out = {}
for line in full:gmatch('[^\r\n]+') do
local heading = line:match('^===%s*(.-)%s*===%s*$')


    for _, section in ipairs(sections) do
if heading then
        local anchorId = makeAnchorId(section.title)
current = {
        table.insert(out, "# [[#" .. anchorId .. "|" .. section.title .. "]]")
name = trim(heading),
    end
lines = {}
}
table.insert(sections, current)
elseif current then
table.insert(current.lines, line)
end
end


    return table.concat(out, "\n")
return sections
end
end


-- =========================
local function forceOrderedList(body, startNumber)
-- Rendering full contents
body = body or ''
-- =========================
startNumber = startNumber or 1


local function renderSingleSectionBox(section, sectionIndex, startIndex, collapseClass, adjustImagesForColumns)
-- Extra safety in case includeonly tags survive from copied content.
    local out = {}
body = body:gsub('<%s*/?%s*includeonly%s*>', '')
    local body = normalizeSectionBody(section.body)


    if adjustImagesForColumns then
-- If the data page already contains an ordered list, preserve it.
        body = adjustSectionImageMarkup(body)
if body:match('<%s*ol[%s>]') then
    end
return body, startNumber
end


    local itemCount = countRenderableItems(body)
local out = {}
    local anchorId = makeAnchorId(section.title)
local inList = false
local n = startNumber


    table.insert(out, '<div class="' .. collapseClass .. '" style="' .. STYLE.sectionBox .. '">')
for line in body:gmatch('([^\r\n]*)\r?\n?') do
if line ~= '' then
local liContent = line:match('^%s*<%s*li%s*>(.-)<%s*/%s*li%s*>%s*$')


    table.insert(out,
if liContent then
        '<div id="' .. anchorId .. '" style="' .. STYLE.sectionHeader .. '">' ..
if not inList then
        '<span>' ..
table.insert(out, '<div style="margin-top:0; margin-left:1.6em;">')
        tostring(sectionIndex) .. '. ' ..
inList = true
        mw.text.encode(section.title) ..
end
        ' (' .. tostring(itemCount) .. ')' ..
        '</span>' ..
        '<span style="' .. STYLE.backLink .. '">[[#QCIndex|↑]]</span>' ..
        '</div>'
    )


    table.insert(out, '<div class="mw-collapsible-content" style="margin-top:0;">')
table.insert(out, '<div>' .. n .. '. ' .. liContent .. '</div>')
n = n + 1
else
if inList then
table.insert(out, '</div>')
inList = false
end


    if itemCount > 0 then
table.insert(out, line)
        table.insert(out, '<ol start="' .. tostring(startIndex) .. '">')
end
        table.insert(out, body)
end
        table.insert(out, '</ol>')
end
    else
        table.insert(out, body)
    end


    table.insert(out, '</div>')
if inList then
    table.insert(out, '</div>')
table.insert(out, '</div>')
end


    return table.concat(out, "\n"), itemCount
return table.concat(out, '\n'), n
end
end


local function renderCollapsibleSections(fullBlock)
local function renderSectionList(section, collapse, sectionNumber, articleStartNumber)
    local sections = parseSections(fullBlock)
local body, nextArticleNumber = forceOrderedList(table.concat(section.lines, '\n'), articleStartNumber)
    if #sections == 0 then
local count = countArticleLinks(body)
        return fullBlock
local anchor = makeAnchorId(section.name)
    end
local collapsedClass = ''
 
    local collapseClass = "mw-collapsible"
    if shouldCollapseSections() then
        collapseClass = "mw-collapsible mw-collapsed"
    end
 
    if not shouldUseTwoColumnSections() then
        local out = {}
        local startIndex = 1
        local sectionIndex = 1
 
        for _, section in ipairs(sections) do
            local html, itemCount = renderSingleSectionBox(section, sectionIndex, startIndex, collapseClass, false)
            table.insert(out, html)
            startIndex = startIndex + itemCount
            sectionIndex = sectionIndex + 1
        end
 
        return table.concat(out, "\n")
    end
 
    local leftSections = {}
    local rightSections = {}
    local halfway = math.ceil(#sections / 2)


    for i, section in ipairs(sections) do
if collapse then
        if i <= halfway then
collapsedClass = ' mw-collapsed'
            table.insert(leftSections, section)
end
        else
            table.insert(rightSections, section)
        end
    end


    local function renderSectionColumn(sectionList, startSectionIndex, startItemIndex)
local sectionLabel = section.name
        local out = {}
if sectionNumber then
        local sectionIndex = startSectionIndex
sectionLabel = tostring(sectionNumber) .. '. ' .. sectionLabel
        local itemIndex = startItemIndex
end


        for _, section in ipairs(sectionList) do
local out = {}
            local html, itemCount = renderSingleSectionBox(section, sectionIndex, itemIndex, collapseClass, true)
            table.insert(out, html)
            sectionIndex = sectionIndex + 1
            itemIndex = itemIndex + itemCount
        end


        return table.concat(out, "\n")
table.insert(out, '<div id="' .. anchor .. '" class="mw-collapsible' .. collapsedClass .. '" style="border:1px solid #e0d890; background:#fffdf0; margin:0.8em 0; padding:0.6em;">')
    end
table.insert(out, '<div style="font-weight:bold; margin-bottom:0.4em;">' .. escapeHtml(sectionLabel) .. ' (' .. count .. ') <span style="font-size:85%; font-weight:normal;">[[#Index|↑ Back to index]]</span></div>')


    local leftHtml = renderSectionColumn(leftSections, 1, 1)
-- IMPORTANT:
-- Full contents is intentionally ONE column and raw.
-- This preserves original list formatting and any images.
table.insert(out, '<div>')
table.insert(out, body)
table.insert(out, '</div>')


    local leftItemCount = 0
table.insert(out, '</div>')
    for _, section in ipairs(leftSections) do
        local body = adjustSectionImageMarkup(normalizeSectionBody(section.body))
        leftItemCount = leftItemCount + countRenderableItems(body)
    end


    local rightHtml = renderSectionColumn(rightSections, #leftSections + 1, leftItemCount + 1)
return table.concat(out, '\n'), nextArticleNumber
 
    local out = {}
    table.insert(out, '<div style="display:flex; gap:20px; align-items:flex-start;">')
    table.insert(out, '<div style="flex:1; min-width:0;">')
    table.insert(out, leftHtml)
    table.insert(out, '</div>')
    table.insert(out, '<div style="flex:1; min-width:0;">')
    table.insert(out, rightHtml)
    table.insert(out, '</div>')
    table.insert(out, '</div>')
 
    return table.concat(out, "\n")
end
end


-- =========================
local function renderFullContents(sections)
-- Gallery helpers
if #sections == 0 then
-- =========================
return ''
end


local function extractPhysicsLinks(content)
local out = {}
    local fullBlock = extractFullContentsBlock(content)
local collapse = false
    local pages = {}
    local seenPages = {}


    for target in fullBlock:gmatch("<li>%s*%[%[([^%]|#]+)") do
table.insert(out, '= Full contents =')
        target = trim(target)


        if target:match("^Physics:") then
local articleNumber = 1
            local key = mw.ustring.lower(target)
            if not seenPages[key] then
                seenPages[key] = true
                table.insert(pages, target)
            end
        end
    end


    return pages
for i, section in ipairs(sections) do
end
local rendered
rendered, articleNumber = renderSectionList(section, collapse, i, articleNumber)
table.insert(out, rendered)
end


local function normalizeFileTarget(fileTarget)
return table.concat(out, '\n')
    if not fileTarget or fileTarget == "" then
        return nil
    end
 
    fileTarget = trim(fileTarget)
    fileTarget = fileTarget:gsub("^[Ii][Mm][Aa][Gg][Ee]:", "File:")
    return fileTarget
end
end


local function extractFirstFileFromText(text)
local function totalArticleCount(sections)
    if not text or text == "" then
local n = 0
        return nil
    end


    local fileTarget = text:match("%[%[%s*([Ff][Ii][Ll][Ee]:[^%]|]+)")
for _, section in ipairs(sections) do
    if fileTarget and fileTarget ~= "" then
n = n + countArticleLinks(table.concat(section.lines, '\n'))
        return normalizeFileTarget(fileTarget)
end
    end


    fileTarget = text:match("%[%[%s*([Ii][Mm][Aa][Gg][Ee]:[^%]|]+)")
return n
    if fileTarget and fileTarget ~= "" then
        return normalizeFileTarget(fileTarget)
    end
 
    return nil
end
end


local function extractFirstFileFromPage(pageName)
-- ============================================================
    local content = getContent(pageName)
-- Public TOC functions
    if not content or content == "" then
-- ============================================================
        return nil
    end


    return extractFirstFileFromText(content)
function p.tocHeading(frame)
end
local args = frame.args or {}
local dataPage = args[1] or 'Physics:Quantum basics/See also'
local content = getPageContent(dataPage)
local sections = parseFullContents(content)
local n = totalArticleCount(sections)


local function makeCleanGalleryTitle(targetPage)
return '= Table of contents (' .. n .. ' articles) ='
    local cleanTitle = targetPage or ""
    cleanTitle = cleanTitle:gsub("^Physics:Quantum%s+", "")
    cleanTitle = cleanTitle:gsub("^Physics:", "")
    return cleanTitle
end
end


-- =========================
function p.tocHeadingAndList(frame)
-- Core renderer
local args = frame.args or {}
-- =========================
local dataPage = args[1] or 'Physics:Quantum basics/See also'
local content = getPageContent(dataPage)


local function renderTocPage(pageName, showHeading)
local groups = parseIndex(content)
    local content = getContent(pageName)
local sections = parseFullContents(content)
local n = totalArticleCount(sections)


    if content == "" then
local out = {}
        if showHeading then
            return "= Table of contents (0 articles)=\n"
        end
        return ""
    end


    local count = countFullContentsItems(content)
table.insert(out, '= Table of contents (' .. n .. ' articles) =')
    local indexBlock = extractIndexBlock(content)
table.insert(out, renderIndex(groups))
    local fullBlock = extractFullContentsBlock(content)
table.insert(out, renderFullContents(sections))
    local sections = parseSections(fullBlock)


    local renderedIndex = ""
return table.concat(out, '\n\n')
    if trim(indexBlock) ~= "" then
end
        renderedIndex = renderIndexFromExplicitBlock(indexBlock)
    else
        renderedIndex = renderIndexFromSections(sections)
    end


    local renderedFullBlock = renderCollapsibleSections(fullBlock)
-- ============================================================
-- Child-book wrappers
-- ============================================================


    local result = {}
function p.qm(frame)
 
frame = frame or {}
    if showHeading then
frame.args = frame.args or {}
        table.insert(result, string.format("= Table of contents (%d articles)=", count))
frame.args[1] = frame.args[1] or 'Physics:Quantum basics/See also/Matter'
    end
return p.tocHeadingAndList(frame)
 
    if trim(renderedIndex) ~= "" then
        table.insert(result, '<span id="QCIndex"></span>')
        table.insert(result, "== Index ==")
        table.insert(result, renderedIndex)
    end
 
    table.insert(result, "== Full contents ==")
    table.insert(result, renderedFullBlock)
 
    return table.concat(result, "\n")
end
end


-- =========================
function p.qt(frame)
-- Public functions
frame = frame or {}
-- =========================
frame.args = frame.args or {}
 
frame.args[1] = frame.args[1] or 'Physics:Quantum basics/See also/Methods'
function p.tocHeading(frame)
return p.tocHeadingAndList(frame)
    local pageName = frame.args[1]
    local content = getContent(pageName)
    local count = countFullContentsItems(content)
 
    return string.format("= Table of contents (%d articles)=", count)
end
end


function p.tocHeadingAndList(frame)
function p.qd(frame)
    local pageName = frame.args[1] or "Physics:Quantum basics/See also"
frame = frame or {}
    return renderTocPage(pageName, true)
frame.args = frame.args or {}
frame.args[1] = frame.args[1] or 'Physics:Quantum basics/See also/Data Analysis'
return p.tocHeadingAndList(frame)
end
end


function p.qm(frame)
-- ============================================================
    return renderTocPage("Physics:Quantum basics/See also/Matter", true)
-- Gallery helpers
end
-- ============================================================


function p.qt(frame)
local function extractPagesForGallery(content)
    return renderTocPage("Physics:Quantum basics/See also/Methods", true)
local pages = {}
end
local seen = {}


function p.gallery(frame)
local full = content:match('==%s*Full contents%s*==%s*(.*)') or content
    local pageName = trim(frame.args[1] or "")
    local content = getContent(pageName)


    if content == "" then
for raw in full:gmatch('%[%[([^%]]+)%]%]') do
        return '[[Book:Quantum Collection|← Back to Book:Quantum Collection]]\n\n== Gallery ==\n\nNo content found.'
local target = raw:match('^([^|#]+)') or raw
    end
target = trim(target)


    local thumbWidth = tonumber(frame.args.width or frame.args[2]) or 150
if not isSkippedLink(target) and not target:match('^Book:') and not seen[target] then
    local cardWidth = thumbWidth + 20
seen[target] = true
table.insert(pages, target)
end
end


    local pages = extractPhysicsLinks(content)
return pages
    local totalPages = #pages
end
    local out = {}
    local seenFiles = {}
    local imageCount = 0
    local missingCount = 0
    local items = {}


    for _, targetPage in ipairs(pages) do
local function firstFileInArticle(page)
        local fileName = extractFirstFileFromPage(targetPage)
local text = getPageContent(page)
        local cleanTitle = makeCleanGalleryTitle(targetPage)


        if fileName and fileName ~= "" then
if text == '' then
            local key = mw.ustring.lower(fileName)
return nil
end


            if not seenFiles[key] then
local file = text:match('%[%[%s*[Ff]ile%s*:%s*([^%]|%]]+)')
                seenFiles[key] = true
                imageCount = imageCount + 1


                table.insert(items,
if not file then
                    '<div style="' .. STYLE.galleryCard .. ' width:' .. tostring(cardWidth) .. 'px;">' ..
file = text:match('%[%[%s*[Ii]mage%s*:%s*([^%]|%]]+)')
                    '[[' .. fileName .. '|' .. tostring(thumbWidth) .. 'px]]' ..
end
                    '<span style="' .. STYLE.galleryCaption .. '">[[' .. targetPage .. '|' .. cleanTitle .. ']]</span>' ..
                    '</div>'
                )
            else
                table.insert(items,
                    '<div style="' .. STYLE.galleryCard .. ' width:' .. tostring(cardWidth) .. 'px; min-height:' .. tostring(thumbWidth + 40) .. 'px; display:flex; flex-direction:column; justify-content:center; align-items:center;">' ..
                    '<div style="font-size:90%; line-height:1.3em;">[[' .. targetPage .. '|' .. cleanTitle .. ']]</div>' ..
                    '<div style="font-size:85%; color:#666; margin-top:6px;">Duplicate image</div>' ..
                    '</div>'
                )
            end
        else
            missingCount = missingCount + 1


            table.insert(items,
if file then
                '<div style="' .. STYLE.galleryCard .. ' width:' .. tostring(cardWidth) .. 'px; min-height:' .. tostring(thumbWidth + 40) .. 'px; display:flex; flex-direction:column; justify-content:center; align-items:center;">' ..
file = trim(file)
                '<div style="font-size:90%; line-height:1.3em;">[[' .. targetPage .. '|' .. cleanTitle .. ']]</div>' ..
file = file:gsub('|.*$', '')
                '<div style="font-size:85%; color:#aa0000; margin-top:6px;">Image missing</div>' ..
file = trim(file)
                '</div>'
end
            )
        end
    end


    table.insert(out, '[[Book:Quantum Collection|← Back to Book:Quantum Collection]]')
return file
    table.insert(out, '')
end
    table.insert(out,
        '== Gallery (' ..
        tostring(imageCount) .. ' images, ' ..
        tostring(missingCount) .. ' missing, ' ..
        tostring(totalPages) .. ' pages) =='
    )
    table.insert(out, '')
    table.insert(out, '<div style="' .. STYLE.galleryWrap .. '">')


    for _, item in ipairs(items) do
local function normalizeGalleryWidth(value)
        table.insert(out, item)
local width = tonumber(value) or 150
    end


    table.insert(out, '</div>')
if width < 40 then
width = 40
elseif width > 400 then
width = 400
end


    return table.concat(out, "\n")
return math.floor(width)
end
end


function p.galleryMissing(frame)
local function renderGalleryCard(page, file, width)
    local pageName = trim(frame.args[1] or "")
local label = stripNamespaceForLabel(page)
    local content = getContent(pageName)
local out = {}


    if content == "" then
table.insert(out, '<div style="display:inline-block; vertical-align:top; width:180px; margin:6px; padding:5px; border:1px solid #e0d890; background:#fff8cc; text-align:center;">')
        return "No content found."
if file then
    end
table.insert(out, '[[File:' .. file .. '|' .. width .. 'px]]')
else
table.insert(out, '<div style="height:120px; display:flex; align-items:center; justify-content:center; border:1px dashed #c0a850; background:#fffdf0; color:#900; font-size:90%;">No image found</div>')
end


    local pages = extractPhysicsLinks(content)
table.insert(out, '<div style="font-size:90%; line-height:1.25; margin-top:4px;">[[' .. page .. '|' .. escapeHtml(label) .. ']]</div>')
    local out = {}
table.insert(out, '</div>')


    table.insert(out, "== Pages with missing gallery image ==")
return table.concat(out, '\n')
end


    for _, targetPage in ipairs(pages) do
-- ============================================================
        local fileName = extractFirstFileFromPage(targetPage)
-- Public gallery function
        if not fileName or fileName == "" then
-- ============================================================
            table.insert(out, "* [[" .. targetPage .. "]]")
        end
    end


    return table.concat(out, "\n")
function p.gallery(frame)
end
local args = frame.args or {}
local dataPage = args[1] or 'Physics:Quantum basics/See also'
local width = normalizeGalleryWidth(args.width or args[2])
local content = getPageContent(dataPage)


function p.galleryDuplicates(frame)
local pages = extractPagesForGallery(content)
    local pageName = trim(frame.args[1] or "")
    local content = getContent(pageName)


    if content == "" then
local out = {}
        return "No content found."
local imageCount = 0
    end
local missingCount = 0
local pageCount = #pages


    local pages = extractPhysicsLinks(content)
table.insert(out, '<div class="noexcerpt" style="max-width:100%; clear:both;">')
    local seenFiles = {}
    local out = {}


    table.insert(out, "== Pages with duplicate first gallery image ==")
for _, page in ipairs(pages) do
local file = firstFileInArticle(page)


    for _, targetPage in ipairs(pages) do
if file then
        local fileName = extractFirstFileFromPage(targetPage)
imageCount = imageCount + 1
else
missingCount = missingCount + 1
end


        if fileName and fileName ~= "" then
table.insert(out, renderGalleryCard(page, file, width))
            local key = mw.ustring.lower(fileName)
end


            if seenFiles[key] then
table.insert(out, '</div>')
                table.insert(out, "* [[" .. targetPage .. "]] → [[:"
                    .. fileName .. "]]")
            else
                seenFiles[key] = targetPage
            end
        end
    end


    return table.concat(out, "\n")
local heading = '== Gallery (' .. imageCount .. ' images, ' .. missingCount .. ' missing, ' .. pageCount .. ' pages) =='
end


function p.testwidth(frame)
return heading .. '\n' .. table.concat(out, '\n')
    return 'arg1=' .. tostring(frame.args[1]) ..
        ', width=' .. tostring(frame.args.width) ..
        ', arg2=' .. tostring(frame.args[2])
end
end


return p
return p

Latest revision as of 18:44, 13 May 2026

Module:PhysicsQC

This module renders a structured, collapsible table of contents for the Quantum Collection project.

It separates **content (Q)** from **logic (M)** and is invoked from a book or overview page (B).

Usage

Table of contents (185 articles)

Index

Full contents

9. Quantum optics and experiments (5) ↑ Back to index
14. Plasma and fusion physics (8) ↑ Back to index
Conceptual illustration of plasma physics in a fusion context, showing magnetically confined ionized gas in a tokamak and the collective behavior governed by electromagnetic fields and transport processes.
Conceptual illustration of plasma physics in a fusion context, showing magnetically confined ionized gas in a tokamak and the collective behavior governed by electromagnetic fields and transport processes.

Architecture

  • Q (source page) – contains structured content
  • M (this module) – processes, counts, and renders
  • B (book/page) – invokes the module

Source page (Q) format

The source page must contain:

<includeonly>

== Core pathway ==
# [[Physics:Quantum basics]]
# [[Physics:Quantum mechanics]]

== Full contents ==

=== Foundations ===
<li>[[Physics:Quantum basics]]</li>
<li>[[Physics:Quantum mechanics]]</li>

=== Conceptual and interpretations ===
<li>[[Physics:Quantum Interpretations of quantum mechanics]]</li>

=== Quantum optics and experiments ===
<li>[[Physics:Quantum optics beam splitter experiments]]</li>
[[Template:Quantum optics operators]]

</includeonly>

Features

  • Automatic total article count
  • Automatic per-section counts
  • Continuous numbering across sections
  • Independent collapsible sections
  • Clean rendering (no manual layout required)
  • No JavaScript required

Counting rules

Only items matching:

  • ... are counted. The following namespaces are excluded:
    • Template:
    • File:
    • Image:
    • Category:
    • Help:
    • Special:
    Duplicate links are counted only once in the total.

    Rendering behavior

    Each section:

    • is converted into a collapsible block
    • receives an automatic item count
    • is assigned correct numbering via <ol start="...">

    The module handles:

    • layout
    • numbering
    • counting

    The source page should NOT contain:

    • manual collapsible blocks
    • manual <ol> or start=
    • layout styling

    Important notes

    • Templates should NOT be inside <li> if you want numbering to match totals.
    • Images and descriptive text are preserved inside sections.
    • Section headings must use:
     === Section name ===
    

    Output structure

    The module returns:

    = Table of content (N articles)=
    
    == Core pathway ==
    ...
    
    == Full contents ==
    [collapsible sections]
    

    Maintenance

    To update content:

    • Edit the source page (Q)
    • Do NOT modify the module (M)

    The module automatically updates:

    • numbering
    • totals
    • layout

    Summary

    • Q = content and structure
    • M = logic and rendering
    • B = invocation

    This separation ensures scalability, consistency, and minimal maintenance.


  • local p = {}
    
    -- ============================================================
    -- Module:PhysicsQC
    -- Quantum Collection table-of-contents and gallery renderer.
    --
    -- Restored rules:
    --   * Index main groups: 2 columns.
    --   * Book II / Book III / Book IV: one horizontal row below Index.
    --   * Full contents: ONE column.
    --   * Full contents preserves raw body, including images.
    --   * Counts exclude File/Image/Book/etc.
    --   * Gallery uses compact cards.
    -- ============================================================
    
    local function trim(s)
    	if not s then
    		return ''
    	end
    	return mw.text.trim(s)
    end
    
    local function escapeHtml(s)
    	return mw.text.encode(s or '')
    end
    
    local function getPageContent(pageName)
    	local title = mw.title.new(pageName or '')
    	if not title then
    		return ''
    	end
    
    	local text = title:getContent() or ''
    
    	-- Prevent visible <includeonly> / </includeonly> leaks from data/template pages.
    	text = text:gsub('<%s*/?%s*includeonly%s*>', '')
    
    	return text
    end
    
    local function getCurrentPageName()
    	local t = mw.title.getCurrentTitle()
    	if not t then
    		return ''
    	end
    	return t.prefixedText or ''
    end
    
    local function isCurrentBookPage()
    	return getCurrentPageName():match('^Book:Quantum Collection') ~= nil
    end
    
    local function stripNamespaceForLabel(page)
    	page = page or ''
    	page = page:gsub('^Physics:Quantum data analysis/', '')
    	page = page:gsub('^Physics:Quantum methods/', '')
    	page = page:gsub('^Physics:Quantum materials/', '')
    	page = page:gsub('^Physics:Quantum matter/', '')
    	page = page:gsub('^Physics:Quantum atoms/', '')
    	page = page:gsub('^Physics:Quantum basics/', '')
    	page = page:gsub('^Physics:Quantum ', '')
    	page = page:gsub('^Physics:', '')
    	page = page:gsub('^Book:Quantum Collection/', '')
    	page = page:gsub('_', ' ')
    	return page
    end
    
    local function makeAnchorId(s)
    	s = s or ''
    	s = mw.text.decode(s, true)
    	s = s:gsub('<[^>]+>', '')
    	s = s:gsub('&nbsp;', ' ')
    	s = trim(s)
    	s = s:gsub('%s+', '_')
    	return mw.uri.anchorEncode(s)
    end
    
    local function isSkippedLink(link)
    	if not link or link == '' then
    		return true
    	end
    
    	link = trim(link)
    
    	if link:match('^#') then return true end
    	if link:match('^File:') then return true end
    	if link:match('^Image:') then return true end
    	if link:match('^Category:') then return true end
    	if link:match('^Help:') then return true end
    	if link:match('^Special:') then return true end
    	if link:match('^Template:') then return true end
    	if link:match('^Module:') then return true end
    
    	return false
    end
    
    local function isSkippedArticleCountLink(link)
    	if isSkippedLink(link) then return true end
    	if link:match('^Book:') then return true end
    	return false
    end
    
    local function parseWikiLink(raw)
    	raw = raw or ''
    
    	local target, label = raw:match('^([^|]+)|(.+)$')
    
    	if not target then
    		target = raw
    		label = stripNamespaceForLabel(raw)
    	end
    
    	target = trim(target)
    	label = trim(label)
    
    	return target, label
    end
    
    local function countArticleLinks(text)
    	local count = 0
    	local seen = {}
    
    	for raw in (text or ''):gmatch('%[%[([^%]]+)%]%]') do
    		local target = raw:match('^([^|#]+)') or raw
    		target = trim(target)
    
    		if not isSkippedArticleCountLink(target) and not seen[target] then
    			seen[target] = true
    			count = count + 1
    		end
    	end
    
    	return count
    end
    
    -- ============================================================
    -- Parse == Index ==
    -- ============================================================
    
    local function parseIndex(content)
    	local indexText = content:match('==%s*Index%s*==%s*(.-)%s*==%s*Full contents%s*==')
    
    	if not indexText then
    		indexText = content:match('==%s*Index%s*==%s*(.*)') or ''
    	end
    
    	local groups = {}
    	local currentGroup = nil
    
    	for line in indexText:gmatch('[^\r\n]+') do
    		line = trim(line)
    
    		if line ~= '' then
    			local boldContent = line:match("^'''%s*(.-)%s*'''%s*$")
    
    			if boldContent then
    				currentGroup = {
    					name = trim(boldContent),
    					links = {}
    				}
    				table.insert(groups, currentGroup)
    			else
    				for raw in line:gmatch('%[%[([^%]]+)%]%]') do
    					local target, label = parseWikiLink(raw)
    					target = target:match('^([^#]+)') or target
    					target = trim(target)
    
    					if currentGroup and not isSkippedLink(target) then
    						table.insert(currentGroup.links, {
    							target = target,
    							label = label
    						})
    					end
    				end
    			end
    		end
    	end
    
    	return groups
    end
    
    local function isBookGroup(group)
    	if not group or not group.name then
    		return false
    	end
    
    	local name = group.name
    
    	if name == 'Book II' then return true end
    	if name == 'Book III' then return true end
    	if name == 'Book IV' then return true end
    
    	if name == 'Quantum Book II' then return true end
    	if name == 'Quantum Book III' then return true end
    	if name == 'Quantum Book IV' then return true end
    
    	return false
    end
    
    local function displayBookGroupName(name)
    	name = name or ''
    	name = name:gsub('^Quantum%s+', '')
    	return name
    end
    
    local function renderOrdinaryIndexGroup(group, startNumber)
    	local out = {}
    	local n = startNumber or 1
    
    	table.insert(out, '<div style="break-inside:avoid; margin-bottom:0.8em;">')
    	table.insert(out, '<div style="font-weight:bold; margin-bottom:0.25em;">' .. escapeHtml(group.name) .. '</div>')
    
    	if #group.links > 0 then
    		table.insert(out, '<div style="margin-top:0; margin-left:1.2em;">')
    
    		for _, link in ipairs(group.links) do
    			local anchor = makeAnchorId(link.label)
    
    			if link.target:match('^Book:') then
    				table.insert(out, '<div>' .. n .. '. [[' .. link.target .. '|' .. escapeHtml(link.label) .. ']]</div>')
    				n = n + 1
    			else
    				table.insert(out, '<div>' .. n .. '. [[#' .. anchor .. '|' .. escapeHtml(link.label) .. ']]</div>')
    				n = n + 1
    			end
    		end
    
    		table.insert(out, '</div>')
    	end
    
    	table.insert(out, '</div>')
    
    	return table.concat(out, '\n'), n
    end
    
    local function renderBookRow(bookGroups)
    	local out = {}
    
    	table.insert(out, '<div class="noexcerpt" style="margin:1em 0 0.8em 0;">')
    	table.insert(out, '<div style="display:flex; gap:2em; align-items:flex-start;">')
    
    	for _, group in ipairs(bookGroups) do
    		table.insert(out, '<div style="flex:1; min-width:0;">')
    		table.insert(out, '<div style="font-weight:bold; margin-bottom:0.25em;">' .. escapeHtml(displayBookGroupName(group.name)) .. '</div>')
    
    		if #group.links > 0 then
    			table.insert(out, '<ul style="margin-top:0;">')
    
    			for _, link in ipairs(group.links) do
    				if link.target:match('^Book:') then
    					table.insert(out, '<li>[[' .. link.target .. '|' .. escapeHtml(link.label) .. ']]</li>')
    				else
    					local anchor = makeAnchorId(link.label)
    					table.insert(out, '<li>[[#' .. anchor .. '|' .. escapeHtml(link.label) .. ']]</li>')
    				end
    			end
    
    			table.insert(out, '</ul>')
    		end
    
    		table.insert(out, '</div>')
    	end
    
    	table.insert(out, '</div>')
    	table.insert(out, '</div>')
    
    	return table.concat(out, '\n')
    end
    
    local function renderIndex(groups)
    	if #groups == 0 then
    		return ''
    	end
    
    	local normalGroups = {}
    	local bookGroups = {}
    
    	for _, group in ipairs(groups) do
    		if isBookGroup(group) then
    			table.insert(bookGroups, group)
    		else
    			table.insert(normalGroups, group)
    		end
    	end
    
    	local out = {}
    
    	table.insert(out, '= Index =')
    
    	table.insert(out, '<div class="noexcerpt" style="column-count:2; column-gap:2em;">')
    
    	local indexNumber = 1
    
    	for _, group in ipairs(normalGroups) do
    		local rendered
    		rendered, indexNumber = renderOrdinaryIndexGroup(group, indexNumber)
    		table.insert(out, rendered)
    	end
    
    	table.insert(out, '</div>')
    
    	if #bookGroups > 0 then
    		table.insert(out, renderBookRow(bookGroups))
    	end
    
    	return table.concat(out, '\n')
    end
    
    -- ============================================================
    -- Parse == Full contents ==
    -- ============================================================
    
    local function parseFullContents(content)
    	local full = content:match('==%s*Full contents%s*==%s*(.*)') or ''
    	local sections = {}
    	local current = nil
    
    	for line in full:gmatch('[^\r\n]+') do
    		local heading = line:match('^===%s*(.-)%s*===%s*$')
    
    		if heading then
    			current = {
    				name = trim(heading),
    				lines = {}
    			}
    			table.insert(sections, current)
    		elseif current then
    			table.insert(current.lines, line)
    		end
    	end
    
    	return sections
    end
    
    local function forceOrderedList(body, startNumber)
    	body = body or ''
    	startNumber = startNumber or 1
    
    	-- Extra safety in case includeonly tags survive from copied content.
    	body = body:gsub('<%s*/?%s*includeonly%s*>', '')
    
    	-- If the data page already contains an ordered list, preserve it.
    	if body:match('<%s*ol[%s>]') then
    		return body, startNumber
    	end
    
    	local out = {}
    	local inList = false
    	local n = startNumber
    
    	for line in body:gmatch('([^\r\n]*)\r?\n?') do
    		if line ~= '' then
    			local liContent = line:match('^%s*<%s*li%s*>(.-)<%s*/%s*li%s*>%s*$')
    
    			if liContent then
    				if not inList then
    					table.insert(out, '<div style="margin-top:0; margin-left:1.6em;">')
    					inList = true
    				end
    
    				table.insert(out, '<div>' .. n .. '. ' .. liContent .. '</div>')
    				n = n + 1
    			else
    				if inList then
    					table.insert(out, '</div>')
    					inList = false
    				end
    
    				table.insert(out, line)
    			end
    		end
    	end
    
    	if inList then
    		table.insert(out, '</div>')
    	end
    
    	return table.concat(out, '\n'), n
    end
    
    local function renderSectionList(section, collapse, sectionNumber, articleStartNumber)
    	local body, nextArticleNumber = forceOrderedList(table.concat(section.lines, '\n'), articleStartNumber)
    	local count = countArticleLinks(body)
    	local anchor = makeAnchorId(section.name)
    	local collapsedClass = ''
    
    	if collapse then
    		collapsedClass = ' mw-collapsed'
    	end
    
    	local sectionLabel = section.name
    	if sectionNumber then
    		sectionLabel = tostring(sectionNumber) .. '. ' .. sectionLabel
    	end
    
    	local out = {}
    
    	table.insert(out, '<div id="' .. anchor .. '" class="mw-collapsible' .. collapsedClass .. '" style="border:1px solid #e0d890; background:#fffdf0; margin:0.8em 0; padding:0.6em;">')
    	table.insert(out, '<div style="font-weight:bold; margin-bottom:0.4em;">' .. escapeHtml(sectionLabel) .. ' (' .. count .. ') <span style="font-size:85%; font-weight:normal;">[[#Index|↑ Back to index]]</span></div>')
    
    	-- IMPORTANT:
    	-- Full contents is intentionally ONE column and raw.
    	-- This preserves original list formatting and any images.
    	table.insert(out, '<div>')
    	table.insert(out, body)
    	table.insert(out, '</div>')
    
    	table.insert(out, '</div>')
    
    	return table.concat(out, '\n'), nextArticleNumber
    end
    
    local function renderFullContents(sections)
    	if #sections == 0 then
    		return ''
    	end
    
    	local out = {}
    	local collapse = false
    
    	table.insert(out, '= Full contents =')
    
    	local articleNumber = 1
    
    	for i, section in ipairs(sections) do
    		local rendered
    		rendered, articleNumber = renderSectionList(section, collapse, i, articleNumber)
    		table.insert(out, rendered)
    	end
    
    	return table.concat(out, '\n')
    end
    
    local function totalArticleCount(sections)
    	local n = 0
    
    	for _, section in ipairs(sections) do
    		n = n + countArticleLinks(table.concat(section.lines, '\n'))
    	end
    
    	return n
    end
    
    -- ============================================================
    -- Public TOC functions
    -- ============================================================
    
    function p.tocHeading(frame)
    	local args = frame.args or {}
    	local dataPage = args[1] or 'Physics:Quantum basics/See also'
    	local content = getPageContent(dataPage)
    	local sections = parseFullContents(content)
    	local n = totalArticleCount(sections)
    
    	return '= Table of contents (' .. n .. ' articles) ='
    end
    
    function p.tocHeadingAndList(frame)
    	local args = frame.args or {}
    	local dataPage = args[1] or 'Physics:Quantum basics/See also'
    	local content = getPageContent(dataPage)
    
    	local groups = parseIndex(content)
    	local sections = parseFullContents(content)
    	local n = totalArticleCount(sections)
    
    	local out = {}
    
    	table.insert(out, '= Table of contents (' .. n .. ' articles) =')
    	table.insert(out, renderIndex(groups))
    	table.insert(out, renderFullContents(sections))
    
    	return table.concat(out, '\n\n')
    end
    
    -- ============================================================
    -- Child-book wrappers
    -- ============================================================
    
    function p.qm(frame)
    	frame = frame or {}
    	frame.args = frame.args or {}
    	frame.args[1] = frame.args[1] or 'Physics:Quantum basics/See also/Matter'
    	return p.tocHeadingAndList(frame)
    end
    
    function p.qt(frame)
    	frame = frame or {}
    	frame.args = frame.args or {}
    	frame.args[1] = frame.args[1] or 'Physics:Quantum basics/See also/Methods'
    	return p.tocHeadingAndList(frame)
    end
    
    function p.qd(frame)
    	frame = frame or {}
    	frame.args = frame.args or {}
    	frame.args[1] = frame.args[1] or 'Physics:Quantum basics/See also/Data Analysis'
    	return p.tocHeadingAndList(frame)
    end
    
    -- ============================================================
    -- Gallery helpers
    -- ============================================================
    
    local function extractPagesForGallery(content)
    	local pages = {}
    	local seen = {}
    
    	local full = content:match('==%s*Full contents%s*==%s*(.*)') or content
    
    	for raw in full:gmatch('%[%[([^%]]+)%]%]') do
    		local target = raw:match('^([^|#]+)') or raw
    		target = trim(target)
    
    		if not isSkippedLink(target) and not target:match('^Book:') and not seen[target] then
    			seen[target] = true
    			table.insert(pages, target)
    		end
    	end
    
    	return pages
    end
    
    local function firstFileInArticle(page)
    	local text = getPageContent(page)
    
    	if text == '' then
    		return nil
    	end
    
    	local file = text:match('%[%[%s*[Ff]ile%s*:%s*([^%]|%]]+)')
    
    	if not file then
    		file = text:match('%[%[%s*[Ii]mage%s*:%s*([^%]|%]]+)')
    	end
    
    	if file then
    		file = trim(file)
    		file = file:gsub('|.*$', '')
    		file = trim(file)
    	end
    
    	return file
    end
    
    local function normalizeGalleryWidth(value)
    	local width = tonumber(value) or 150
    
    	if width < 40 then
    		width = 40
    	elseif width > 400 then
    		width = 400
    	end
    
    	return math.floor(width)
    end
    
    local function renderGalleryCard(page, file, width)
    	local label = stripNamespaceForLabel(page)
    	local out = {}
    
    	table.insert(out, '<div style="display:inline-block; vertical-align:top; width:180px; margin:6px; padding:5px; border:1px solid #e0d890; background:#fff8cc; text-align:center;">')
    	if file then
    		table.insert(out, '[[File:' .. file .. '|' .. width .. 'px]]')
    	else
    		table.insert(out, '<div style="height:120px; display:flex; align-items:center; justify-content:center; border:1px dashed #c0a850; background:#fffdf0; color:#900; font-size:90%;">No image found</div>')
    	end
    
    	table.insert(out, '<div style="font-size:90%; line-height:1.25; margin-top:4px;">[[' .. page .. '|' .. escapeHtml(label) .. ']]</div>')
    	table.insert(out, '</div>')
    
    	return table.concat(out, '\n')
    end
    
    -- ============================================================
    -- Public gallery function
    -- ============================================================
    
    function p.gallery(frame)
    	local args = frame.args or {}
    	local dataPage = args[1] or 'Physics:Quantum basics/See also'
    	local width = normalizeGalleryWidth(args.width or args[2])
    	local content = getPageContent(dataPage)
    
    	local pages = extractPagesForGallery(content)
    
    	local out = {}
    	local imageCount = 0
    	local missingCount = 0
    	local pageCount = #pages
    
    	table.insert(out, '<div class="noexcerpt" style="max-width:100%; clear:both;">')
    
    	for _, page in ipairs(pages) do
    		local file = firstFileInArticle(page)
    
    		if file then
    			imageCount = imageCount + 1
    		else
    			missingCount = missingCount + 1
    		end
    
    		table.insert(out, renderGalleryCard(page, file, width))
    	end
    
    	table.insert(out, '</div>')
    
    	local heading = '== Gallery (' .. imageCount .. ' images, ' .. missingCount .. ' missing, ' .. pageCount .. ' pages) =='
    
    	return heading .. '\n' .. table.concat(out, '\n')
    end
    
    return p