Module:PhysicsQC

From HandWiki Stage
Revision as of 09:18, 9 May 2026 by Harold (talk | contribs) (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;"...")
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search

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 (184 articles)

Index

Full contents

14. Plasma and fusion physics (8)

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 = {}
    
    -- =========================
    -- 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;" ..
        "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)
        return (s or ""):gsub("^%s+", ""):gsub("%s+$", "")
    end
    
    local function makeAnchorId(s)
        s = trim(s)
        s = s:gsub("%s+", "_")
        s = s:gsub("[^%w_%-]", "")
        return s
    end
    
    local function getContent(page)
        if not page or page == "" then
            return ""
        end
    
        local title = mw.title.new(page)
        if not title then
            return ""
        end
    
        return title:getContent() or ""
    end
    
    local function getCurrentPageName()
        local title = mw.title.getCurrentTitle()
        if not title then
            return ""
        end
        return title.prefixedText or ""
    end
    
    local function isBookPage()
        local page = getCurrentPageName()
    
        return page == "Book:Quantum Collection"
            or page == "Book:Quantum Collection/Matter (by scale)"
            or page == "Book:Quantum Collection/Methods and tools"
    end
    
    local function shouldCollapseSections()
        return not isBookPage()
    end
    
    local function shouldUseTwoColumnSections()
        return not isBookPage()
    end
    
    local function isIgnoredIndexTarget(target)
        return target:match("^(File|Image|Category|Help|Special):")
    end
    
    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
    
    local function displayText(target, display)
        if display and display ~= "" then
            return display
        end
    
        if target:match("^Book:Quantum Collection/") then
            return target:gsub("^Book:Quantum Collection/", "")
        end
    
        if target:match("^Physics:Quantum basics/See also/") then
            return target:gsub("^Physics:Quantum basics/See also/", "")
        end
    
        if target:match("^Physics:") then
            return target:gsub("^Physics:", "")
        end
    
        return target
    end
    
    function p.test(frame)
        return "Physics QC module working"
    end
    
    -- =========================
    -- 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
    
    local function extractFullContentsBlock(content)
        local fullBlock = content:match("==%s*Full contents%s*==%s*(.-)</includeonly>")
        if not fullBlock then
            fullBlock = content:match("==%s*Full contents%s*==%s*(.*)")
        end
        return fullBlock or ""
    end
    
    local function countFullContentsItems(content)
        local seen = {}
        local count = 0
        local fullBlock = extractFullContentsBlock(content)
    
        for target in fullBlock:gmatch("<li>%s*%[%[([^%]|#]+)") do
            target = trim(target)
    
            if not target:match("^(File|Image|Category|Help|Special|Template):") then
                local key = mw.ustring.lower(target)
                if not seen[key] then
                    seen[key] = true
                    count = count + 1
                end
            end
        end
    
        return count
    end
    
    -- =========================
    -- Section parsing
    -- =========================
    
    local function parseSections(fullBlock)
        local sections = {}
        local currentTitle = nil
        local currentLines = {}
    
        local function flushSection()
            if currentTitle then
                table.insert(sections, {
                    title = currentTitle,
                    body = table.concat(currentLines, "\n")
                })
            end
            currentLines = {}
        end
    
        for line in mw.text.gsplit(fullBlock, "\n", true) do
            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
    
    local function countRenderableItems(sectionBody)
        local count = 0
        local seen = {}
    
        for target in sectionBody:gmatch("<li>%s*%[%[([^%]|#]+)") do
            target = trim(target)
    
            if not target:match("^(File|Image|Category|Help|Special|Template):") then
                local key = mw.ustring.lower(target)
                if not seen[key] then
                    seen[key] = true
                    count = count + 1
                end
            end
        end
    
        return count
    end
    
    local function normalizeSectionBody(sectionBody)
        local lines = {}
    
        for line in mw.text.gsplit(sectionBody, "\n", true) do
            local trimmed = trim(line)
    
            if trimmed ~= "<ol>"
                and trimmed ~= "</ol>"
                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;">'
                and trimmed ~= "</div>" then
    
                local templateTarget = trimmed:match("^<li>%s*%[%[(Template:[^%]]+)%]%]%s*</li>%s*$")
                if templateTarget then
                    table.insert(lines, "[[" .. templateTarget .. "]]")
                else
                    table.insert(lines, line)
                end
            end
        end
    
        return table.concat(lines, "\n")
    end
    
    local function adjustSectionImageMarkup(sectionBody)
        if not sectionBody or sectionBody == "" then
            return sectionBody
        end
    
        local function adjustOne(markup)
            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
                out = out:gsub("^(%[%[%s*[Ff][Ii][Ll][Ee]:[^%]|%]]+)", "%1|thumb", 1)
                out = out:gsub("^(%[%[%s*[Ii][Mm][Aa][Gg][Ee]:[^%]|%]]+)", "%1|thumb", 1)
            end
    
            out = out:gsub("|%s*thumb%s*", "|thumb|center", 1)
            return out
        end
    
        sectionBody = sectionBody:gsub("(%[%[%s*[Ff][Ii][Ll][Ee]:.-%]%])", adjustOne, 1)
        sectionBody = sectionBody:gsub("(%[%[%s*[Ii][Mm][Aa][Gg][Ee]:.-%]%])", adjustOne, 1)
    
        return sectionBody
    end
    
    -- =========================
    -- Index parsing/rendering
    -- =========================
    
    local function parseExplicitIndex(indexBlock)
        local entries = {}
    
        for line in mw.text.gsplit(indexBlock, "\n", true) do
            local trimmed = trim(line)
    
            if trimmed ~= "" then
                local boldHeading = trimmed:match("^'''(.-)'''$")
                local isSubbook = isSubbookLine(trimmed)
    
                -- Match links anywhere in the line, so <li><!--QC:subbook-->[[...]]</li> works.
                local target, display = trimmed:match("%[%[([^%]|%]]+)|([^%]]+)%]%]")
                local plainTarget = nil
    
                if not target then
                    plainTarget = trimmed:match("%[%[([^%]|%]]+)%]%]")
                end
    
                if boldHeading then
                    table.insert(entries, {
                        kind = "heading",
                        text = trim(boldHeading)
                    })
                elseif target then
                    target = trim(target)
                    display = trim(display)
    
                    if not isIgnoredIndexTarget(target)
                        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
    
    local function renderIndexFromExplicitBlock(indexBlock)
        local entries = parseExplicitIndex(indexBlock)
        if #entries == 0 then
            return ""
        end
    
        local groups = {}
        local currentGroup = nil
    
        for _, entry in ipairs(entries) do
            if entry.kind == "heading" then
                currentGroup = {
                    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, {
                    target = entry.target,
                    display = entry.display,
                    isSubbook = entry.isSubbook
                })
            end
        end
    
        if #groups == 0 then
            return ""
        end
    
        local function countNumberedItems(groupList)
            local n = 0
            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 = {}
        local rightGroups = {}
    
        local split = math.ceil(#groups / 2)
    
        for i, group in ipairs(groups) do
            if i <= split then
                table.insert(leftGroups, group)
            else
                table.insert(rightGroups, group)
            end
        end
    
        if #rightGroups == 0 and #leftGroups > 1 then
            table.insert(rightGroups, table.remove(leftGroups))
        end
    
        local function renderItemLink(item)
            local target = item.target
            local label = displayText(target, item.display)
    
            if isRealPageTarget(target) then
                return '[[' .. target .. '|' .. label .. ']]'
            else
                local anchorId = makeAnchorId(target)
                return '[[#' .. anchorId .. '|' .. label .. ']]'
            end
        end
    
        local function renderColumn(groupList, startNumber)
            local out = {}
            local itemNumber = startNumber
    
            for _, group in ipairs(groupList) do
                if trim(group.heading) ~= "" then
                    table.insert(out,
                        '<div style="' .. STYLE.indexSubheading .. '">' ..
                        mw.text.encode(group.heading) ..
                        '</div>'
                    )
                end
    
                if #group.items > 0 then
                    local olOpen = false
    
                    for _, item in ipairs(group.items) do
                        if item.isSubbook then
                            if olOpen then
                                table.insert(out, '</ol>')
                                olOpen = false
                            end
    
                            table.insert(out,
                                '<div style="' .. STYLE.indexSubbook .. '">→ ' ..
                                renderItemLink(item) ..
                                '</div>'
                            )
                        else
                            if not olOpen then
                                table.insert(out, '<ol start="' .. tostring(itemNumber) .. '">')
                                olOpen = true
                            end
    
                            table.insert(out, '<li>' .. renderItemLink(item) .. '</li>')
                            itemNumber = itemNumber + 1
                        end
                    end
    
                    if olOpen then
                        table.insert(out, '</ol>')
                    end
                end
            end
    
            return table.concat(out, "\n"), itemNumber
        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
    
    local function renderIndexFromSections(sections)
        if #sections == 0 then
            return ""
        end
    
        local out = {}
    
        for _, section in ipairs(sections) do
            local anchorId = makeAnchorId(section.title)
            table.insert(out, "# [[#" .. anchorId .. "|" .. section.title .. "]]")
        end
    
        return table.concat(out, "\n")
    end
    
    -- =========================
    -- Rendering full contents
    -- =========================
    
    local function renderSingleSectionBox(section, sectionIndex, startIndex, collapseClass, adjustImagesForColumns)
        local out = {}
        local body = normalizeSectionBody(section.body)
    
        if adjustImagesForColumns then
            body = adjustSectionImageMarkup(body)
        end
    
        local itemCount = countRenderableItems(body)
        local anchorId = makeAnchorId(section.title)
    
        table.insert(out, '<div class="' .. collapseClass .. '" style="' .. STYLE.sectionBox .. '">')
    
        table.insert(out,
            '<div id="' .. anchorId .. '" style="' .. STYLE.sectionHeader .. '">' ..
            '<span>' ..
            tostring(sectionIndex) .. '. ' ..
            mw.text.encode(section.title) ..
            ' (' .. tostring(itemCount) .. ')' ..
            '</span>' ..
            '<span style="' .. STYLE.backLink .. '">[[#QCIndex|↑]]</span>' ..
            '</div>'
        )
    
        table.insert(out, '<div class="mw-collapsible-content" style="margin-top:0;">')
    
        if itemCount > 0 then
            table.insert(out, '<ol start="' .. tostring(startIndex) .. '">')
            table.insert(out, body)
            table.insert(out, '</ol>')
        else
            table.insert(out, body)
        end
    
        table.insert(out, '</div>')
        table.insert(out, '</div>')
    
        return table.concat(out, "\n"), itemCount
    end
    
    local function renderCollapsibleSections(fullBlock)
        local sections = parseSections(fullBlock)
        if #sections == 0 then
            return fullBlock
        end
    
        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 i <= halfway then
                table.insert(leftSections, section)
            else
                table.insert(rightSections, section)
            end
        end
    
        local function renderSectionColumn(sectionList, startSectionIndex, startItemIndex)
            local out = {}
            local sectionIndex = startSectionIndex
            local itemIndex = startItemIndex
    
            for _, section in ipairs(sectionList) do
                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")
        end
    
        local leftHtml = renderSectionColumn(leftSections, 1, 1)
    
        local leftItemCount = 0
        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)
    
        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
    
    -- =========================
    -- Gallery helpers
    -- =========================
    
    local function extractPhysicsLinks(content)
        local fullBlock = extractFullContentsBlock(content)
        local pages = {}
        local seenPages = {}
    
        for target in fullBlock:gmatch("<li>%s*%[%[([^%]|#]+)") do
            target = trim(target)
    
            if target:match("^Physics:") then
                local key = mw.ustring.lower(target)
                if not seenPages[key] then
                    seenPages[key] = true
                    table.insert(pages, target)
                end
            end
        end
    
        return pages
    end
    
    local function normalizeFileTarget(fileTarget)
        if not fileTarget or fileTarget == "" then
            return nil
        end
    
        fileTarget = trim(fileTarget)
        fileTarget = fileTarget:gsub("^[Ii][Mm][Aa][Gg][Ee]:", "File:")
        return fileTarget
    end
    
    local function extractFirstFileFromText(text)
        if not text or text == "" then
            return nil
        end
    
        local fileTarget = text:match("%[%[%s*([Ff][Ii][Ll][Ee]:[^%]|]+)")
        if fileTarget and fileTarget ~= "" then
            return normalizeFileTarget(fileTarget)
        end
    
        fileTarget = text:match("%[%[%s*([Ii][Mm][Aa][Gg][Ee]:[^%]|]+)")
        if fileTarget and fileTarget ~= "" then
            return normalizeFileTarget(fileTarget)
        end
    
        return nil
    end
    
    local function extractFirstFileFromPage(pageName)
        local content = getContent(pageName)
        if not content or content == "" then
            return nil
        end
    
        return extractFirstFileFromText(content)
    end
    
    local function makeCleanGalleryTitle(targetPage)
        local cleanTitle = targetPage or ""
        cleanTitle = cleanTitle:gsub("^Physics:Quantum%s+", "")
        cleanTitle = cleanTitle:gsub("^Physics:", "")
        return cleanTitle
    end
    
    -- =========================
    -- Core renderer
    -- =========================
    
    local function renderTocPage(pageName, showHeading)
        local content = getContent(pageName)
    
        if content == "" then
            if showHeading then
                return "= Table of contents (0 articles)=\n"
            end
            return ""
        end
    
        local count = countFullContentsItems(content)
        local indexBlock = extractIndexBlock(content)
        local fullBlock = extractFullContentsBlock(content)
        local sections = parseSections(fullBlock)
    
        local renderedIndex = ""
        if trim(indexBlock) ~= "" then
            renderedIndex = renderIndexFromExplicitBlock(indexBlock)
        else
            renderedIndex = renderIndexFromSections(sections)
        end
    
        local renderedFullBlock = renderCollapsibleSections(fullBlock)
    
        local result = {}
    
        if showHeading then
            table.insert(result, string.format("= Table of contents (%d articles)=", count))
        end
    
        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
    
    -- =========================
    -- Public functions
    -- =========================
    
    function p.tocHeading(frame)
        local pageName = frame.args[1]
        local content = getContent(pageName)
        local count = countFullContentsItems(content)
    
        return string.format("= Table of contents (%d articles)=", count)
    end
    
    function p.tocHeadingAndList(frame)
        local pageName = frame.args[1] or "Physics:Quantum basics/See also"
        return renderTocPage(pageName, true)
    end
    
    function p.qm(frame)
        return renderTocPage("Physics:Quantum basics/See also/Matter", true)
    end
    
    function p.qt(frame)
        return renderTocPage("Physics:Quantum basics/See also/Methods", true)
    end
    
    function p.gallery(frame)
        local pageName = trim(frame.args[1] or "")
        local content = getContent(pageName)
    
        if content == "" then
            return '[[Book:Quantum Collection|← Back to Book:Quantum Collection]]\n\n== Gallery ==\n\nNo content found.'
        end
    
        local thumbWidth = tonumber(frame.args.width or frame.args[2]) or 150
        local cardWidth = thumbWidth + 20
    
        local pages = extractPhysicsLinks(content)
        local totalPages = #pages
        local out = {}
        local seenFiles = {}
        local imageCount = 0
        local missingCount = 0
        local items = {}
    
        for _, targetPage in ipairs(pages) do
            local fileName = extractFirstFileFromPage(targetPage)
            local cleanTitle = makeCleanGalleryTitle(targetPage)
    
            if fileName and fileName ~= "" then
                local key = mw.ustring.lower(fileName)
    
                if not seenFiles[key] then
                    seenFiles[key] = true
                    imageCount = imageCount + 1
    
                    table.insert(items,
                        '<div style="' .. STYLE.galleryCard .. ' width:' .. tostring(cardWidth) .. 'px;">' ..
                        '[[' .. fileName .. '|' .. tostring(thumbWidth) .. 'px]]' ..
                        '<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,
                    '<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:#aa0000; margin-top:6px;">Image missing</div>' ..
                    '</div>'
                )
            end
        end
    
        table.insert(out, '[[Book:Quantum Collection|← Back to Book:Quantum Collection]]')
        table.insert(out, '')
        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
            table.insert(out, item)
        end
    
        table.insert(out, '</div>')
    
        return table.concat(out, "\n")
    end
    
    function p.galleryMissing(frame)
        local pageName = trim(frame.args[1] or "")
        local content = getContent(pageName)
    
        if content == "" then
            return "No content found."
        end
    
        local pages = extractPhysicsLinks(content)
        local out = {}
    
        table.insert(out, "== Pages with missing gallery image ==")
    
        for _, targetPage in ipairs(pages) do
            local fileName = extractFirstFileFromPage(targetPage)
            if not fileName or fileName == "" then
                table.insert(out, "* [[" .. targetPage .. "]]")
            end
        end
    
        return table.concat(out, "\n")
    end
    
    function p.galleryDuplicates(frame)
        local pageName = trim(frame.args[1] or "")
        local content = getContent(pageName)
    
        if content == "" then
            return "No content found."
        end
    
        local pages = extractPhysicsLinks(content)
        local seenFiles = {}
        local out = {}
    
        table.insert(out, "== Pages with duplicate first gallery image ==")
    
        for _, targetPage in ipairs(pages) do
            local fileName = extractFirstFileFromPage(targetPage)
    
            if fileName and fileName ~= "" then
                local key = mw.ustring.lower(fileName)
    
                if seenFiles[key] then
                    table.insert(out, "* [[" .. targetPage .. "]] → [[:"
                        .. fileName .. "]]")
                else
                    seenFiles[key] = targetPage
                end
            end
        end
    
        return table.concat(out, "\n")
    end
    
    function p.testwidth(frame)
        return 'arg1=' .. tostring(frame.args[1]) ..
            ', width=' .. tostring(frame.args.width) ..
            ', arg2=' .. tostring(frame.args[2])
    end
    
    return p