Module:visual-dict

From Wiktionary, the free dictionary
Jump to navigation Jump to search

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