Module:visual-dict

This module allows the creation of annotated images, akin to existing visual dictionaries. Below is an example invokation of the module along with its rendering.


{{#invoke:visual-dict|labeled_image|image=Reading-Glasses.jpg|
caption=Eyeglasses parts|
width=300px|
annotations=
sta from 62.8 25.2 to 30.1 -21.3 [[temple#English|temple]],
sta from 165.2 93 to 218.1 30.2 [[screw#English|screw]],
sta from 75.4 89.8 to  25.4 152 [[nose pad#English|nose pad]],
sta from 33.6 90.3 to 2.5 110.3 [[rim#English|rim]],
sta from 128.8 100.2 to 143.2 161.2 [[lens#English|(left) lens]],
sta from 178.4 95.2 to 247.9 121.8 [[hinge#English|hinge]],
sta from 164.8 99.3 to 228.4 154.2 [[endpiece#English|endpiece]],
sta from 106.2 11.8 to 73.1 -49.7 [[temple tip#English|temple tip]],
sta from 84.7 78.4 to 84.7 56.1 [[nose bridge#English|nose bridge]],
}}

Annotations for a given image are specified like so:

sta from start_pos_x start_pos_y to end_pos_x end_pos_y link

where the x and y positions are measured relative to the upper left corner of the annotated image. Position 0, 0 is therefore the upper left corner. Text labels are specified using wikicode links as shown in the example above.

Transclusion edit

A single annotated image can appear in multiple pages through the transclusion mechanism.

For instance, the below figure is transcluded from User:Jeran_Renz/Sandbox.

Notes edit

⚠ This module is under construction. ⚠

References edit

  1. ^ Green, John (2006 July 1) Horse Anatomy[1], Courier Corporation, →ISBN

local export = {}


-- Constants
local ADD_PADDING_HOR = 10.0 -- Minimum padding for the annotated main image, horizontal and vertical.
local ADD_PADDING_VER = 10.0

local LDR_LINE_PATTERN = '<div class="ldr_line" style="transform:rotate(%.2fdeg); left: %.1fpx;top: %.1fpx;width: %.1fpx;"></div>'
local IMG_SRC_SPAN = '<span style="float: right;">[[File:VisualEditor_-_Icon_-_External-link.svg|link=File:%s|Image source.]]</span>'

local DIST_BTWN_LEADER_AND_TEXT = 3 -- In pixels.
local FONT_SIZE_SEC_MARGIN = 8 -- In pixels.


-- Module imports
local m_parameters = require("Module:parameters")


-- Parses widths specified in pixels. "300.0px" -> 300 or nil iff invalid format.
local function parse_width(width_string)
	local result = nil
	local match = mw.ustring.match(mw.ustring.lower(width_string), "^ *([%d\\.]+) *px *$")
	if match then
		result = math.abs(tonumber(match))
	end
	
	return result
end


-- Utility function to dump object into a code block to inspect.
local function debug_dump(object) 
	return "<code>" .. mw.getCurrentFrame():extensionTag('nowiki', mw.dumpObject(object)) .. "</code>"
end


-- Parses annotation specs and returns a table containing them.
local function parse_annotations_specs(annots)
	local result = {}
	
	local parts = mw.text.split(annots, ",", true)
	for i, cur_annot_spec in ipairs(parts) do
		cur_annot_spec = mw.text.trim(cur_annot_spec)
		if mw.ustring.len(cur_annot_spec) > 0 and mw.ustring.sub(cur_annot_spec, 1, 1) ~= '#' then
			local annot_parts = {mw.ustring.match(cur_annot_spec, "^ *(sta) *from *([%d%.-]+) *([%d%.-]+) *to *([%d%.-]+) *([%d%.-]+) *(.+) *$")}
			assert(annot_parts ~= nil, "Invalid annotation specification: " .. cur_annot_spec)

			local cur_annot = {["type"] = annot_parts[1],
				["ldr_start_x"] = tonumber(annot_parts[2]),
				["ldr_start_y"] = tonumber(annot_parts[3]),
				["ldr_end_x"]   = tonumber(annot_parts[4]),
				["ldr_end_y"]   = tonumber(annot_parts[5]),
				["label"]       = mw.text.trim(annot_parts[6]),
			}
			
			cur_annot["ldr_rises"] = cur_annot["ldr_end_y"] <= cur_annot["ldr_start_y"]
			cur_annot["ldr_goesright"] = cur_annot["ldr_end_x"] >= cur_annot["ldr_start_x"]
			guess_label_position(cur_annot)
			
		    table.insert(result, cur_annot)
		end
	end
	
	return result
end


-- Converts bare links like [[label]] to something like [[label#English|label]]
local function augment_wikilinks(annots, lang_fragment)
	for _, cur_annot in ipairs(annots) do
		cur_annot["label"] = mw.ustring.gsub(cur_annot["label"], 
			"%[%[([^|%]]*)%]%]", 
			"[[%1#" .. lang_fragment .. "|%1]]")
	end
end


local function escape_regex_literal(word)
    local result = mw.ustring.gsub(word, "([%(%)%.%%%+%-%*%?%[%^%$])", "%%%1")
    return result
end


-- Attempts to find the current term (page title) in the labels so as to tag them
-- for focus later in processing. Can highlight multiple labels.
local function hilite_current_term(annots, current_term)
	for _, cur_annot in ipairs(annots) do
		cur_annot["focus"] = false -- by default
		
	    -- Find the start and end position of the term in the text
	    local start_pos, end_pos = mw.ustring.find(cur_annot["label"], current_term)

	    if start_pos and end_pos then
	        -- Get the characters immediately before the start and immediately after the end
	        local char_before = start_pos > 1 and mw.ustring.sub(cur_annot['label'], start_pos - 1, start_pos - 1) or ' '
	        local char_after = end_pos < mw.ustring.len(cur_annot['label']) and mw.ustring.sub(cur_annot['label'], end_pos + 1, end_pos + 1) or ' '

	        -- Check if these characters are whitespace characters using a regular expression
	        if (mw.ustring.match(char_before, '[%s%-%[%]<>#%|,%.]') and mw.ustring.match(char_after, '[%s%-%[%]<>#%|,%.]')) then
	            cur_annot["focus"] = true
	        end
	    end
	end
end


-- Computes the paddings for the div containing the image.
local function compute_img_div_paddings(annots, img_width, img_height)
	local min_x = 1E9
	local min_y = 1E9
	local max_x = -1E9
	local max_y = -1E9
	
	for _, cur_annot in ipairs(annots) do
		min_x = math.min(min_x, cur_annot["ldr_start_x"], cur_annot["ldr_end_x"], cur_annot["lbl_start_x"], cur_annot["lbl_end_x"])
		min_y = math.min(min_y, cur_annot["ldr_start_y"], cur_annot["ldr_end_y"], cur_annot["lbl_start_y"], cur_annot["lbl_end_y"])
		max_x = math.max(max_x, cur_annot["ldr_start_x"], cur_annot["ldr_end_x"], cur_annot["lbl_start_x"], cur_annot["lbl_end_x"])
		max_y = math.max(max_y, cur_annot["ldr_start_y"], cur_annot["ldr_end_y"], cur_annot["lbl_start_y"], cur_annot["lbl_end_y"])
	end

	local max_hor_offset = math.max(-min_x, max_x - img_width, 0) + ADD_PADDING_HOR
	local max_ver_offset = math.max(-min_y, max_y - img_height, 0) + ADD_PADDING_VER
	
	local result = {["left"] = max_hor_offset, ["right"] = max_hor_offset, 
		["top"] = max_ver_offset, ["bottom"] = max_ver_offset}

	return result
end


-- Gets the image height given its width, keeping aspect ratio. Expensive.
local function get_image_height(image_filename, image_width) 
	local result = nil
	
	local title = mw.title.new("File:" .. image_filename)
	local file = title.file
	assert(file.exists, "Image does not exist: " .. image_filename)
	local width = file.width
	local height = file.height
	result = (image_width / width) * height
	
	return result
end


-- A simple mapping function returning a new table.
function fn_map(tbl, func)
    local newtbl = {}
    for i, v in ipairs(tbl) do
        newtbl[i] = func(v)
    end
    return newtbl
end


-- Computes the position attributes of the label for a given annotation,
-- when the position are not specified. Heuristic. Positions relative to img.
function guess_label_position(annot)
	local delta_x = 0	
	local delta_y = 0
	local lbl_alignment = nil
	
	delta_x = annot["ldr_goesright"] and DIST_BTWN_LEADER_AND_TEXT or -DIST_BTWN_LEADER_AND_TEXT
	delta_y = annot["ldr_rises"] and -DIST_BTWN_LEADER_AND_TEXT or DIST_BTWN_LEADER_AND_TEXT
	lbl_alignment = annot["ldr_goesright"] and "left" or "right"
	
	annot["lbl_start_x"] = annot["ldr_end_x"] + delta_x
	annot["lbl_start_y"] = annot["ldr_end_y"] + delta_y

	-- TODO: Find a way to get the size of the font. This is hacky at best.
	-- 30 characters are 171 pixel wide and 12px high for the specified font and size and line-height.
	local annot_label_lines = extract_label_lines(annot["label"])
	local line_chars = fn_map(annot_label_lines, function(txt) return mw.ustring.len(txt) end)
	local annot_nb_chars = math.max(unpack(line_chars))
	
	annot["lbl_width"] = (171.0 / 30.0) * annot_nb_chars
	annot["lbl_height"] = 12.0 * #line_chars + (#line_chars - 1) * 6 -- line-height: 12px, + fudge
	
	-- A small rectification in case the leader line is almost vertical or horizontal
	local is_almost_vertical = math.abs(annot["ldr_start_x"] - annot["ldr_end_x"]) < 6
	local is_almost_horizontal = math.abs(annot["ldr_start_y"] - annot["ldr_end_y"]) < 10
	
	if is_almost_vertical then
		lbl_alignment = "center"
		annot["lbl_start_x"] = annot["lbl_start_x"] + (annot["ldr_goesright"] and -1 or 1) * annot["lbl_width"] / 2.0
	end
	
	if is_almost_horizontal then
		annot["lbl_start_y"] = annot["ldr_end_y"] - annot["lbl_height"] / 2.0
	end

	-- Finalize end positions
	annot["lbl_end_x"] = annot["lbl_start_x"] + (annot["ldr_goesright"] and 1 or -1) * (annot["lbl_width"] + FONT_SIZE_SEC_MARGIN)
	annot["lbl_end_y"] = annot["lbl_start_y"] + ((annot["ldr_rises"] and not is_almost_horizontal) and -annot["lbl_height"] or annot["lbl_height"])
	annot["lbl_alignment"]   = lbl_alignment
end


-- Computes the HTML attributes to position annotations relative to their 
-- container div, and not the image they annotate anymore.
local function compute_position_attributes(annotations, pad_left, pad_right, pad_top, image_width)
	for i, cur_annot in ipairs(annotations) do
		local delta_x = cur_annot['ldr_end_x'] - cur_annot['ldr_start_x']
		local delta_y = cur_annot['ldr_end_y'] - cur_annot['ldr_start_y']
		
		-- leader lines
		cur_annot['ldr_length'] = math.sqrt(delta_x ^ 2 + delta_y ^ 2)
		cur_annot['ldr_div_top']  = cur_annot['ldr_start_y'] + delta_y/2.0 + pad_top
		cur_annot['ldr_div_left'] = cur_annot['ldr_start_x'] + delta_x/2.0 - cur_annot['ldr_length']/2.0 + pad_left
		cur_annot['ldr_div_rotation'] = math.deg(math.atan(delta_y / delta_x))
		
		if not cur_annot["ldr_goesright"] then
			cur_annot['ldr_div_rotation'] = cur_annot['ldr_div_rotation'] + 180.0
		end

		-- labels, either positioned from the left or the right
		cur_annot["lbl_div_top"] = pad_top + math.min(cur_annot["lbl_start_y"], cur_annot["lbl_end_y"])
		if cur_annot["ldr_goesright"] then
			cur_annot["lbl_div_left"] = math.min(cur_annot["lbl_start_x"], cur_annot["lbl_end_x"]) + pad_left
			cur_annot["lbl_div_right"] = nil
		else
			cur_annot["lbl_div_left"] = nil
			cur_annot["lbl_div_right"] = pad_right + image_width - math.max(cur_annot["lbl_start_x"], cur_annot["lbl_end_x"])
		end
	end
end


-- Returns either a pixel value or auto if the position is nil.
local function render_nilable_pos(position)
	return position and mw.ustring.format('%.1fpx', position) or 'auto'
end


-- Main loop to render each annotation, without containing divs.
local function render_annotations(annotations)
	local result = ""	

	for i, cur_annot in ipairs(annotations) do
		result = result .. 
				 tostring(mw.html.create("div")
				 	:addClass("label")
				 	:addClass("hiliter")
				 	:addClass(cur_annot["focus"] and "focus" or "")
					:css("left", render_nilable_pos(cur_annot['lbl_div_left']))
					:css("right", render_nilable_pos(cur_annot['lbl_div_right']))
					:css("top", render_nilable_pos(cur_annot['lbl_div_top']))
					:css("text-align", cur_annot["lbl_alignment"])
					:wikitext(cur_annot["label"])) ..
				 '\n' ..
				 mw.ustring.format(LDR_LINE_PATTERN, cur_annot['ldr_div_rotation'], 
				 	cur_annot['ldr_div_left'], cur_annot['ldr_div_top'], cur_annot['ldr_length']) .. 
				 '\n' 				 
	end
	
	return result
end


-- Extracts the label part of a wikitext link.
-- TODO: Do this using the API, but expandTemplate and preprocess don't work.
-- See export.remove_links https://en.wiktionary.org/wiki/Module:links
-- In the meantime, this is a heuristic.
-- Returns a list of rendered text strings, where the strings are split according
-- to the <br> tag in the original label.
function extract_label_lines(wikitext_link)
	local rendered_result = wikitext_link
	
	-- normalize
	rendered_result = mw.ustring.gsub(rendered_result, "\n", " ")
	rendered_result = mw.text.trim(mw.ustring.gsub(rendered_result, " +", " "))
	
	-- remove wikilinks
	rendered_result = mw.ustring.gsub(rendered_result, "%[%[([^|]*)%]%]", "%1")
	rendered_result = mw.ustring.gsub(rendered_result, "%[%[[^|]*|([^%]]+)%]%]", "%1")
	
	-- protect new lines
	rendered_result = mw.ustring.gsub(rendered_result, "<br */?>", "\n")
	
	-- remove tags, like span for culture
	rendered_result = mw.ustring.gsub(rendered_result, "<[^>]+>", "")
	
	-- split at br
	local result = mw.text.split(rendered_result, "\n")

	return result
end


-- Display an image with simple text annotations.
function export.labeled_image(frame)
	-- read arguments
	local params_specs = {
			["image"] = {required = true},
			["caption"] = {required = false, default = "Annotated image."},
			["width"] = {required = true},
			["annotations"] = {required = false},
			["colorscheme"] = {required = false, default = "cornflowerblue"},
		}
	
	local args = m_parameters.process(frame.args, params_specs, false)

	local image_filename = args["image"]
	local image_width_string = args["width"]
	local annotations_specs = args['annotations'] or ""
	local caption_text = args['caption']
	local colorscheme = mw.ustring.lower(args['colorscheme'])
	
	local image_width = parse_width(image_width_string)
	local image_height = get_image_height(image_filename, image_width)
	
	-- retrieve some configuration settings
	local lang_fragment = mw.language.fetchLanguageName(mw.language.getContentLanguage():getCode())
	local current_term = mw.title.getCurrentTitle().text
	
	-- parse and compute annotation elements
	local annotations = parse_annotations_specs(annotations_specs)
	augment_wikilinks(annotations, lang_fragment)
	hilite_current_term(annotations, current_term)
	local img_div_paddings = compute_img_div_paddings(annotations, image_width, image_height)
	compute_position_attributes(annotations, img_div_paddings['left'], img_div_paddings['right'], img_div_paddings['top'], image_width)
	
	-- rendering in HTML
	local inner_width = img_div_paddings['left'] + img_div_paddings['right'] + image_width + 1.0 * 2 -- with border
	local outer_width = inner_width + 3.0 * 2 + 1 * 2
	
	local result = 	frame:extensionTag('templatestyles', '', { src = "Module:visual-dict/styles.css" }) ..
	
					mw.ustring.format('<div class="visual-dict outer-container scheme-%s" style="width: min(100%%, %1.fpx);">', colorscheme, outer_width) ..
					mw.ustring.format(   '<div class="annotated-img-container" style="width: %.1fpx;">', inner_width) ..
					mw.ustring.format(      '<div class="annotated-img" style=" padding: %.1fpx %.1fpx  %.1fpx %.1fpx;">', img_div_paddings['top'], img_div_paddings['right'], img_div_paddings['bottom'],  img_div_paddings['left'] ) ..
					render_annotations(annotations) ..
					mw.ustring.format(         "[[File:%s|%.0fpx||center|link=]]", image_filename, image_width) ..
					                        '</div>' ..
											'<div class="caption thumbcaption">' .. 
					mw.ustring.format(         '<div class="magnify">[[File:VisualEditor_-_Icon_-_External-link.svg|link=File:%s|Image source.]]</div>', image_filename) ..
					                           '<p class="caption-text">' .. caption_text .. '</p>' ..
					                        '</div>' ..
					                     '</div>' ..
					                  '</div>'

    return result
end

return export