Module:visual-dict
- The following documentation is located at Module:visual-dict/documentation. [edit]
- Useful links: subpage list • links • transclusions • testcases • sandbox
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
editA 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
editlocal 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