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 ], sta from 165.2 93 to 218.1 30.2 ], sta from 75.4 89.8 to 25.4 152 ], sta from 33.6 90.3 to 2.5 110.3 ], sta from 128.8 100.2 to 143.2 161.2 ], sta from 178.4 95.2 to 247.9 121.8 ], sta from 164.8 99.3 to 228.4 154.2 ], sta from 106.2 11.8 to 73.1 -49.7 ], sta from 84.7 78.4 to 84.7 56.1 ], }}
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.
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.
⚠ This module is under construction. ⚠
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;">]</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), "^ *(+) *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 *(+) *(+) *to *(+) *(+) *(.+) *$")}
assert(annot_parts ~= nil, "Invalid annotation specification: " .. cur_annot_spec)
local cur_annot = { = annot_parts,
= tonumber(annot_parts),
= tonumber(annot_parts),
= tonumber(annot_parts),
= tonumber(annot_parts),
= mw.text.trim(annot_parts),
}
cur_annot = cur_annot <= cur_annot
cur_annot = cur_annot >= cur_annot
guess_label_position(cur_annot)
table.insert(result, cur_annot)
end
end
return result
end
-- Converts bare links like ] to something like ]
local function augment_wikilinks(annots, lang_fragment)
for _, cur_annot in ipairs(annots) do
cur_annot = mw.ustring.gsub(cur_annot,
"%]*)%]%]",
"]")
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 = 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, 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, start_pos - 1, start_pos - 1) or ' '
local char_after = end_pos < mw.ustring.len(cur_annot) and mw.ustring.sub(cur_annot, end_pos + 1, end_pos + 1) or ' '
-- Check if these characters are whitespace characters using a regular expression
if (mw.ustring.match(char_before, '<>#%|,%.]') and mw.ustring.match(char_after, '<>#%|,%.]')) then
cur_annot = 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, cur_annot, cur_annot, cur_annot)
min_y = math.min(min_y, cur_annot, cur_annot, cur_annot, cur_annot)
max_x = math.max(max_x, cur_annot, cur_annot, cur_annot, cur_annot)
max_y = math.max(max_y, cur_annot, cur_annot, cur_annot, cur_annot)
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 = { = max_hor_offset, = max_hor_offset,
= max_ver_offset, = 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 = 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 and DIST_BTWN_LEADER_AND_TEXT or -DIST_BTWN_LEADER_AND_TEXT
delta_y = annot and -DIST_BTWN_LEADER_AND_TEXT or DIST_BTWN_LEADER_AND_TEXT
lbl_alignment = annot and "left" or "right"
annot = annot + delta_x
annot = annot + 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)
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 = (171.0 / 30.0) * annot_nb_chars
annot = 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 - annot) < 6
local is_almost_horizontal = math.abs(annot - annot) < 10
if is_almost_vertical then
lbl_alignment = "center"
annot = annot + (annot and -1 or 1) * annot / 2.0
end
if is_almost_horizontal then
annot = annot - annot / 2.0
end
-- Finalize end positions
annot = annot + (annot and 1 or -1) * (annot + FONT_SIZE_SEC_MARGIN)
annot = annot + ((annot and not is_almost_horizontal) and -annot or annot)
annot = 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 - cur_annot
local delta_y = cur_annot - cur_annot
-- leader lines
cur_annot = math.sqrt(delta_x ^ 2 + delta_y ^ 2)
cur_annot = cur_annot + delta_y/2.0 + pad_top
cur_annot = cur_annot + delta_x/2.0 - cur_annot/2.0 + pad_left
cur_annot = math.deg(math.atan(delta_y / delta_x))
if not cur_annot then
cur_annot = cur_annot + 180.0
end
-- labels, either positioned from the left or the right
cur_annot = pad_top + math.min(cur_annot, cur_annot)
if cur_annot then
cur_annot = math.min(cur_annot, cur_annot) + pad_left
cur_annot = nil
else
cur_annot = nil
cur_annot = pad_right + image_width - math.max(cur_annot, cur_annot)
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 and "focus" or "")
:css("left", render_nilable_pos(cur_annot))
:css("right", render_nilable_pos(cur_annot))
:css("top", render_nilable_pos(cur_annot))
:css("text-align", cur_annot)
:wikitext(cur_annot)) ..
'\n' ..
mw.ustring.format(LDR_LINE_PATTERN, cur_annot,
cur_annot, cur_annot, cur_annot) ..
'\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.orghttps://dictious.com/en/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 = {
= {required = true},
= {required = false, default = "Annotated image."},
= {required = true},
= {required = false},
= {required = false, default = "cornflowerblue"},
}
local args = m_parameters.process(frame.args, params_specs, false)
local image_filename = args
local image_width_string = args
local annotations_specs = args or ""
local caption_text = args
local colorscheme = mw.ustring.lower(args)
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, img_div_paddings, img_div_paddings, image_width)
-- rendering in HTML
local inner_width = img_div_paddings + img_div_paddings + 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, img_div_paddings, img_div_paddings, img_div_paddings ) ..
render_annotations(annotations) ..
mw.ustring.format( "]", image_filename, image_width) ..
'</div>' ..
'<div class="caption thumbcaption">' ..
mw.ustring.format( '<div class="magnify">]</div>', image_filename) ..
'<p class="caption-text">' .. caption_text .. '</p>' ..
'</div>' ..
'</div>' ..
'</div>'
return result
end
return export