Module:Ppoem

From Wikisource
Jump to navigation Jump to search

-- This is an module to implement "ppoem" (a.k.a. proper poem)
-- The aim here is to provide a poem syntax that's simple, but semantically
-- correct and able to handle things like export and line wrapping.
require('strict')

local p = {} --p stands for package

local getArgs = require('Module:Arguments').getArgs

-- return true if an item is in a given list
local function check_in_list(x, list)
	for k, v in pairs(list) do
		if x == v then
			return true
		end
	end
	return false
end

-- Error if the args[name] is not in the given list of values
local function check_arg_in_list(args, name, list, allowNil)
	
	if  args[name] == nil then
		if allowNil then
			return
		else
			error("Argument '" .. name .. "' may not be empty")
		end
	end
	
	local inlist = check_in_list(args[name], list)
	
	if not inlist then
		error("Unknown argument value: '" .. name .. "=" .. args[name] .. "'. Expected one of: " .. table.concat(list, ", "))
	end
end

--[=[
Decompose a single line into a data structure containing all relevant information
]=]
function p.parse_line(line)

	-- do indents first
	local nbsps = 0
	local ems = 0

    line = line:gsub("^ +", function(spaces)
        nbsps = spaces:len()
        return ""
    end, 1)

	if nbsps == 0 then
        -- replace leading colons with  
        line = line:gsub("^(:+)%s*", function(colons)
            ems = colons:len()
            return ""
        end, 1)
	end
	
	-- for all lines, classes come next
	local classes = {}
    line = line:gsub("^{(.-)}%s*", function(classes_match)
    	for class_name in string.gmatch(classes_match, "%S+") do
			table.insert(classes, "ws-poem-" .. class_name)
		end
        return ""
    end, 1)

	local alignment
    line = line:gsub("^>>(%s*)(.?)", function(whitespace, next_char)
    	if whitespace == '' and next_char == '>' then
    		-- this is a >>>, which is handled later,
    		-- so return nil so nothing is replaced
    		return nil
    	end
    	-- Otherwise, set alignment and replace the angle brackets with the
    	-- char following them (in effect, delete the angle brackets).
        alignment = "r"
        return next_char
    end, 1)

    line = line:gsub("^<>%s*", function()
        alignment = "c"
        return ""
    end, 1)
    
	-- nothing left - this is a stanza break line
	if line == "" then
		local stanza = {
			type = 'stanza',
			align = alignment
		}
		if #classes > 0 then
			stanza['classes'] = classes
		end
		return stanza
	end
	
	-- at this point this must be a content line
    local line_num;
    line = line:gsub("%s*>>>%s*(.+)$", function(line_num_str)
        line_num = line_num_str;
        return ""
    end, 1)
    
    local verse_num;
    line = line:gsub("^(.-)%s*<<<%s*", function(verse_num_str)
        verse_num = verse_num_str;
        return ""
    end, 1)

	local line_data = {
		type = 'line',
		align = alignment,
		content = line,
		line_num = line_num,
		verse_num = verse_num,
	}
	
	if #classes > 0 then
		line_data['classes'] = classes
	end
	
	if nbsps > 0 then
		line_data['indent'] = { nbsp = nbsps }
	elseif ems > 0 then
		line_data['indent'] = { em = ems }
	end
	
	return line_data
end

local function construct_stanza(stanza)
	local classes = { 'ws-poem-stanza' }
	
	if stanza['classes'] then
		for k, v in pairs(stanza['classes']) do
			table.insert(classes, v)
		end
	end
	
    if stanza['align'] == 'r' then
    	table.insert(classes, 'ws-poem-right')
    elseif stanza['align'] == 'c' then
    	table.insert(classes, 'ws-poem-center')
    end
	
	local s = "<div class=\"" .. table.concat(classes, " ") .. "\">"
	return s
end

-- construct a fixed width span for use in indenting
local function construct_fixed_width(ems)
	local emsp = "&emsp;"
	local s = mw.html.create("span")
		:addClass("ws-poem-indent")
		:css({
			width = ems .. "em",
		})
		:wikitext(emsp:rep(ems))
	return tostring(s)
end

--[=[
Construct a "proper poem"
]=]
function p._ppoem(args)

    check_arg_in_list(args, 'start', {"open", "stanza", "follow", "same-line"}, true)
    check_arg_in_list(args, 'end', {"close", "stanza", "follow", "same-line"}, true)

    local open = args['start'] == "open" or not args['start']
    local close = args['end'] == "close" or not args['end']
    
    local isPageNs = mw.title.getCurrentTitle():inNamespace(104)
    
    -- in Page namespace, we always open a fresh environment and close it at the end
    if isPageNs then
        open = true
        close = true
    end

	-- Try not to blow up if called without an argument
	-- split()/trim() handle empty strings fine, but throw when fed nil
	local input = ""
	if args[1] ~= nil then
		input = args[1]
	end

    local lines = mw.text.split(mw.text.trim(input), "\r?\n", false)
    local s = ""
    
    local pending_stanza
    
    -- start a new stanza
    -- this can be overridden later by an explict stanza line like '{stanza class}'
    if open or args['start'] == "stanza" then 
        pending_stanza = "<div class=\"ws-poem-stanza\">"
    end
    
    local have_line_num = false
    local have_verse_num = false

    local num_stanzas = 0
    local num_lines = 0
    
    -- we inherited an open stanza
    local continued_stanza = not (args['start'] == "stanza" or open)
    
    -- hide the BR in a span so we can manipulate it with CSS cross-browser
    local linebreak = '<span class="ws-poem-break"><br/></span>'

    for k,v in pairs(lines) do
    	
    	local line_data = p.parse_line(v)
    	
    	if line_data['type'] == 'stanza' then
    	--	mw.logObject(line_data)
    		pending_stanza = tostring(construct_stanza(line_data))
    	else
    		-- it's a line

		    -- we have to put something on the line to make sure it has height
		    if mw.text.trim( line_data.content ) == '' then
		    	line_data.content = '&nbsp;'
		    end

    		-- first start any pending stanza
    		if pending_stanza then
    			-- mw.log("pending: " .. pending_stanza, num_stanzas, num_lines)
    			if num_stanzas == 0 and num_lines == 0 and continued_stanza and not isPageNs then
    				-- mw.log("Skip stanza")
    				-- the stanza config in this case is just to set up the stanza in page NS
    				-- otherwise we continue the one from the last template
    			else
    				-- either we have our own stanzas to close, or we inherited one
	    			if num_stanzas > 0 or continued_stanza then
	    				-- add an extra BR for copy-paste
	    				s = s .. linebreak .. '</div>'
	    			end
	    			-- and now open the pending stanza
	    			s = s .. pending_stanza
	    		end
	    		pending_stanza = nil
	    		num_stanzas = num_stanzas + 1
    		end
    		
			if line_data['line_num'] then
                have_line_num = true
                local ln = mw.html.create("span")
                    :addClass("ws-poem-linenum")
                    :wikitext(line_data['line_num'])
                s = s .. tostring(ln)
			end
			
            if line_data['verse_num'] then
                have_verse_num = true
                local vn = mw.html.create("span")
                    :addClass("ws-poem-versenum")
                    :wikitext(line_data['verse_num'] .. " ")
                s = s .. tostring(vn)
            end
            
            -- open the line tag
            local line_classes = line_data['classes'] or {}
            table.insert(line_classes, 'ws-poem-line')
            
            if line_data['align'] == 'r' then
            	table.insert(line_classes, 'ws-poem-right')
            elseif line_data['align'] == 'c' then
            	table.insert(line_classes, 'ws-poem-center')
            end

            local line = ""
            
            if not open and k == 1 and args['start'] == 'same-line' then
            	-- line is already opened on previous page
            else
            	line = line .. "<span class=\"" ..  table.concat(line_classes, " ") .. "\">"
            end
            
            -- add indentation (REVIEW: do this with CSS?)
            if line_data['indent'] then
            	if line_data['indent']['em'] then
            		line = line .. construct_fixed_width(line_data['indent']['em'])
            	elseif line_data['indent']['nbsp'] then
            		local chr = "&nbsp;"
            		line = line .. chr:rep(line_data['indent']['nbsp'])
            	end
            end
            
            
            -- ...add the line content
            line = line .. line_data['content']

            if not close and k == #lines and args['end'] == 'same-line' then
            	-- don't close the line, it'll continue on the next page
            else
            	line = line .. linebreak .. '</span>'
            end
            s = s .. line
            
            num_lines = num_lines + 1
        end
    end

    if args['end'] == "stanza" or close then
        s = s .. linebreak .. '</div>'
    end
    
    if open then 
        local div = "<div class=\"ws-poem"
        if args['class'] ~= nil then
            div = div .. " " .. args['class']
        end
        
        -- hanging indentation is the default
        if args['no_hi'] == nil then
        	div = div .. " ws-poem-hi"	
        end
        
        -- add gutters if we see a line/verse number or the user tells us they want them
        if have_verse_num or args["gutter"] == "left" or args["gutter"] == "both" then
            div = div .. " ws-poem-left-gutter"
        end
        if have_line_num or args["gutter"] == "right" or args["gutter"] == "both" then
            div = div .. " ws-poem-right-gutter"
        end
        
        div = div .. "\""

        -- add an HTML and XML lang attributes if needed
        if args['lang'] ~= nil then
        	div = div .. " lang=\"" .. args['lang'] .. "\""
        	div = div .. " xml:lang=\"" .. args['lang'] .. "\""
        end

        -- set up the CSS style if needed
        
        local style = ""
        if args['align'] ~= nil and args['align'] ~= "" then
            style = style .. 'text-align:' .. args['align'] .. ';'
        end
        
        if args['style'] ~= nil and args['style'] ~= "" then
            style = style .. args['style']
        end
        
        if style ~= "" then
            div = div .. " style=\"" .. style .. "\""
        end

        div = div .. ">"
        s = div .. s
    end
            
    if close then
        s = s .. "</div>"
    end

    return s
end

function p.ppoem(frame)
	local args = getArgs(frame)
	return p._ppoem(args)
end

return p