Module:PhysicsQC: Difference between revisions

From ScholarlyWiki
Jump to navigation Jump to search
Use transcluded content for Quantum Collection index
Keep Quantum index section images inside headers
 
(5 intermediate revisions by the same user not shown)
Line 1: Line 1:
local p = {}
local p = {}
-- Cache bump: Use transcluded index content for live dependency refresh.
-- Cache bump: Render section images inside Quantum index headers.
-- Cache bump: Fields chapter expanded to 12 entries.
-- Cache bump: Fields chapter expanded to 12 entries.


Line 47: Line 47:
end)
end)


if ok and text and text ~= '' then
if ok and text and text ~= '' and text:match('==%s*Full contents%s*==') then
return text:gsub('<%s*/?%s*includeonly%s*>', '')
return text:gsub('<%s*/?%s*includeonly%s*>', '')
end
end
Line 395: Line 395:


local function renderSectionList(section, collapse, sectionNumber, articleStartNumber)
local function renderSectionList(section, collapse, sectionNumber, articleStartNumber)
local body, nextArticleNumber = forceOrderedList(table.concat(section.lines, '\n'), articleStartNumber)
local rawBody = table.concat(section.lines, '\n')
local sectionImage = ''
 
local divImage = rawBody:match('^%s*<div[^>]-float%s*:%s*right.-%[%[File:[^%]]+%]%].-</div>')
if divImage then
sectionImage = divImage:match('%[%[File:[^%]]+%]%]') or ''
rawBody = rawBody:gsub('^%s*<div[^>]-float%s*:%s*right.-%[%[File:[^%]]+%]%].-</div>%s*', '', 1)
else
sectionImage = rawBody:match('^%s*(%[%[File:[^%]]+%]%])') or ''
if sectionImage ~= '' then
rawBody = rawBody:gsub('^%s*%[%[File:[^%]]+%]%]%s*', '', 1)
end
end
 
local body, nextArticleNumber = forceOrderedList(rawBody, articleStartNumber)
local count = countArticleLinks(body)
local count = countArticleLinks(body)
local anchor = makeAnchorId(section.name)
local anchor = makeAnchorId(section.name)
Line 407: Line 421:
if sectionNumber then
if sectionNumber then
sectionLabel = tostring(sectionNumber) .. '. ' .. sectionLabel
sectionLabel = tostring(sectionNumber) .. '. ' .. sectionLabel
end
local imageHtml = ''
if sectionImage ~= '' then
imageHtml = '<div style="margin-left:auto; flex:0 0 auto;">' .. sectionImage .. '</div>'
end
end


Line 412: Line 431:


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 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>')
table.insert(out, '<div style="display:flex; align-items:flex-start; gap:1em; font-weight:bold; margin-bottom:0.4em;"><div style="min-width:0;">' .. escapeHtml(sectionLabel) .. ' (' .. count .. ') <span style="font-size:85%; font-weight:normal;">[[#Index|Back to index]]</span></div>' .. imageHtml .. '</div>')


-- IMPORTANT:
-- IMPORTANT:
Line 425: Line 444:
return table.concat(out, '\n'), nextArticleNumber
return table.concat(out, '\n'), nextArticleNumber
end
end
local function renderFullContents(sections)
local function renderFullContents(sections)
if #sections == 0 then
if #sections == 0 then
Line 464: Line 482:
local args = frame.args or {}
local args = frame.args or {}
local dataPage = args[1] or 'Physics:Quantum basics/See also'
local dataPage = args[1] or 'Physics:Quantum basics/See also'
local content = getFramePageContent(frame, dataPage)
local content = getPageContent(dataPage)
local sections = parseFullContents(content)
local sections = parseFullContents(content)
local n = totalArticleCount(sections)
local n = totalArticleCount(sections)
Line 474: Line 492:
local args = frame.args or {}
local args = frame.args or {}
local dataPage = args[1] or 'Physics:Quantum basics/See also'
local dataPage = args[1] or 'Physics:Quantum basics/See also'
local content = getFramePageContent(frame, dataPage)
local content = getPageContent(dataPage)


local groups = parseIndex(content)
local groups = parseIndex(content)
Line 596: Line 614:
local dataPage = args[1] or 'Physics:Quantum basics/See also'
local dataPage = args[1] or 'Physics:Quantum basics/See also'
local width = normalizeGalleryWidth(args.width or args[2])
local width = normalizeGalleryWidth(args.width or args[2])
local content = getFramePageContent(frame, dataPage)
local content = getPageContent(dataPage)


local pages = extractPagesForGallery(content)
local pages = extractPagesForGallery(content)

Latest revision as of 23:47, 20 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 (198 articles)

Index

Full contents

9. Quantum optics and experiments (5) Back to index
Experimental quantum physics: qubits, dilution refrigerators, quantum communication, and laboratory systems.
Experimental quantum physics: qubits, dilution refrigerators, quantum communication, and laboratory systems.
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 = {}
    -- Cache bump: Render section images inside Quantum index headers.
    -- Cache bump: Fields chapter expanded to 12 entries.
    
    -- ============================================================
    -- 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 getFramePageContent(frame, pageName)
    	if frame and frame.preprocess and pageName and pageName ~= '' then
    		local ok, text = pcall(function()
    			return frame:preprocess('{{:' .. pageName .. '}}')
    		end)
    
    		if ok and text and text ~= '' and text:match('==%s*Full contents%s*==') then
    			return text:gsub('<%s*/?%s*includeonly%s*>', '')
    		end
    	end
    
    	return getPageContent(pageName)
    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 rawBody = table.concat(section.lines, '\n')
    	local sectionImage = ''
    
    	local divImage = rawBody:match('^%s*<div[^>]-float%s*:%s*right.-%[%[File:[^%]]+%]%].-</div>')
    	if divImage then
    		sectionImage = divImage:match('%[%[File:[^%]]+%]%]') or ''
    		rawBody = rawBody:gsub('^%s*<div[^>]-float%s*:%s*right.-%[%[File:[^%]]+%]%].-</div>%s*', '', 1)
    	else
    		sectionImage = rawBody:match('^%s*(%[%[File:[^%]]+%]%])') or ''
    		if sectionImage ~= '' then
    			rawBody = rawBody:gsub('^%s*%[%[File:[^%]]+%]%]%s*', '', 1)
    		end
    	end
    
    	local body, nextArticleNumber = forceOrderedList(rawBody, 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 imageHtml = ''
    	if sectionImage ~= '' then
    		imageHtml = '<div style="margin-left:auto; flex:0 0 auto;">' .. sectionImage .. '</div>'
    	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="display:flex; align-items:flex-start; gap:1em; font-weight:bold; margin-bottom:0.4em;"><div style="min-width:0;">' .. escapeHtml(sectionLabel) .. ' (' .. count .. ') <span style="font-size:85%; font-weight:normal;">[[#Index|Back to index]]</span></div>' .. imageHtml .. '</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