src/image-render.lua
1-- image-render.lua
2--
3-- Issue 9-013 (redesign) — renderer side. The augmentation step
4-- (augment-embeddings-with-images.lua) ranks images alongside poems and writes
5-- image-manifest.json. This module lets flat-html-generator DRAW those image
6-- entries: it injects lightweight "pseudo-poem" objects into the poems list so
7-- the existing poem_index lookups find them, and formats an image as a small
8-- box instead of a poem.
9--
10-- Kept separate (and dependency-light) so it can be required from both the main
11-- thread and each effil worker's fresh Lua state, and unit-tested on its own.
12
13local M = {}
14
15local manifest_cache = nil -- per-process memoize (worker states each load once)
16
17-- {{{ function M.load_manifest()
18-- Read image-manifest.json from `path`. Returns the { "poem_index" = record }
19-- map, or an empty table if the file is absent (the feature is optional: no
20-- manifest -> no image entries, poems render exactly as before). Memoized per
21-- process so each worker state parses it at most once.
22function M.load_manifest(path, read_json_fn)
23 if manifest_cache ~= nil then return manifest_cache end
24 local data = read_json_fn(path)
25 manifest_cache = (data and data.images) or {}
26 return manifest_cache
27end
28-- }}}
29
30-- {{{ local function basename()
31local function basename(path)
32 return (path or ""):match("([^/]+)$") or (path or "")
33end
34-- }}}
35
36-- {{{ local function media_href()
37-- Where a file lives under output/media/, url-encoded for an href/src.
38-- Art images (path under input/images/<source>/...) KEEP their source + subdir
39-- structure, because their human-given basenames collide (e.g.
40-- my-art/proposed-movement-design.png vs my-art/game-design/proposed-movement-design.png)
41-- and a flat output/media/<basename> would let one silently overwrite the other.
42-- flatten_media_files writes art to the very same <source>/<subpath>, so the two
43-- agree. Mastodon attachments (content-addressed hashes, NOT under input/images/)
44-- keep just the basename -- already unique, and we don't want their 7-level
45-- nesting back. Slashes are preserved; space / ? / # / % are percent-encoded.
46-- Inlined (not required from a lib) on purpose: this module also runs inside
47-- effil worker threads with their own Lua state.
48local function media_href(path)
49 path = path or ""
50 local sub = path:match("input/images/(.+)$") or (path:match("([^/]+)$") or path)
51 return (sub:gsub("[^%w%-%._~/]", function(c)
52 return string.format("%%%02X", string.byte(c))
53 end))
54end
55-- }}}
56
57-- {{{ local function media_type_for()
58-- Guess a media_type from a filename extension (the manifest stores width/height
59-- but render_attachment_images keys image rendering off "image/...").
60local function media_type_for(filename)
61 local ext = (filename or ""):match("%.([%a%d]+)$")
62 return "image/" .. (ext and ext:lower() or "png")
63end
64-- }}}
65
66-- {{{ function M.inject_pseudo_poems()
67-- Append one pseudo-poem per manifest image to poems_data.poems so the
68-- renderer's poem_index lookups resolve them. Idempotent: a second call is a
69-- no-op (guarded by a marker on poems_data), so it is safe to call after every
70-- poems.json load. Class 2 (image-only posts) ALREADY exist in poems_data with
71-- their real poem_index -- we only TAG those in place; class 3 (standalone
72-- catalog images, poem_index beyond the poem range) are genuinely new and get
73-- appended.
74function M.inject_pseudo_poems(poems_data, manifest)
75 if not poems_data or not poems_data.poems then return poems_data end
76 if poems_data._images_injected then return poems_data end
77 poems_data._images_injected = true
78 if not manifest then return poems_data end
79
80 -- Index existing poems so class-2 entries can be tagged in place.
81 local by_index = {}
82 for _, p in ipairs(poems_data.poems) do by_index[p.poem_index] = p end
83
84 for key, rec in pairs(manifest) do
85 local pidx = rec.poem_index or tonumber(key)
86 if rec.class == 2 then
87 -- Tag the existing image-only post so the formatter draws it as an
88 -- image. Its attachments + content already live in poems_data.
89 local p = by_index[pidx]
90 if p then
91 p.is_image = true
92 p.image_class = 2
93 p.display_title = rec.category and (rec.category .. ": image post") or "image post"
94 end
95 else
96 -- Class 3: build a fresh pseudo-poem carrying the catalog image as a
97 -- normal attachment. The attachment keeps the FULL relative_path (not
98 -- just the basename) so render_attachment_images can namespace it the
99 -- same way flatten_media_files did -- output/media/<source>/<subpath>.
100 -- Passing only the basename used to work when media was flat, but that
101 -- is exactly what let same-named art images collide; the full path
102 -- carries the source + subdirs that keep them distinct. fname stays
103 -- the basename purely for the human-facing title/type.
104 local fname = basename(rec.relative_path)
105 poems_data.poems[#poems_data.poems + 1] = {
106 poem_index = pidx,
107 id = "img-" .. tostring(pidx),
108 is_image = true,
109 image_class = 3,
110 category = "image",
111 content = "",
112 creation_date = rec.creation_date,
113 display_title = rec.display_title or fname,
114 gallery_anchor = rec.gallery_anchor, -- deep-link to the chrono gallery
115 attachments = {{
116 relative_path = rec.relative_path,
117 media_type = media_type_for(fname),
118 width = rec.width, height = rec.height,
119 description = rec.display_title or fname,
120 }},
121 }
122 end
123 end
124 return poems_data
125end
126-- }}}
127
128-- Document-relative bases: "up to the site root, then down to the target." These
129-- image entries are only ever rendered into poem pages, which live one directory
130-- below the root (output/similar/, output/different/, output/chronological/), so
131-- the prefix is "../". Relative paths resolve identically whether opened locally
132-- from any folder or served on the site -- no per-environment conversion. (If an
133-- image entry were ever rendered at another depth, this prefix would move with
134-- the page; today every caller is depth 1.) flatten_media_files puts each image
135-- at output/media/<source>/<subpath>, which "../media/" + media_href() reaches.
136local MEDIA_BASE = "../media/"
137local GALLERY_BASE = "../gallery/chronological.html#"
138
139-- {{{ function M.text_image_link()
140-- For a TEXT+IMAGE post (a normal poem that also carries an image attachment),
141-- a direct link to the image file, always labelled "image.png" regardless of
142-- the real filename. These pictures have no gallery entry/name of their own, so
143-- this gives the reader a way to open the image. Returns "" when there is no
144-- image attachment (pure-text poems get nothing).
145function M.text_image_link(poem)
146 local att = poem.attachments and poem.attachments[1]
147 if not att then return "" end
148 local raw = att.relative_path or att.url or ""
149 if raw == "" then return "" end
150 -- media_href keeps art's source+subdir structure (collision-safe) and
151 -- url-encodes; Mastodon hashes collapse to the bare name. Matches the <img>
152 -- src in format_image_entry and where flatten_media_files placed the file.
153 return string.format('<a href="%s%s">image.png</a>', MEDIA_BASE, media_href(raw))
154end
155-- }}}
156
157-- {{{ function M.format_image_entry()
158-- Render a ranked IMAGE as a compact, CSS-free box: a marker + the
159-- source-qualified title, the image(s), then a closing rule. Self-contained
160-- (builds its own <img> from the attachment) so it renders identically in the
161-- main thread and in each effil worker without needing their private helpers.
162function M.format_image_entry(poem)
163 local title = poem.display_title or "image"
164 local esc = title:gsub("&", "&"):gsub("<", "<"):gsub(">", ">")
165 local imgs = {}
166 for _, att in ipairs(poem.attachments or {}) do
167 -- media_href namespaces art by source+subdir (so same-named pieces don't
168 -- collide in output/media/) and url-encodes; Mastodon hashes stay flat.
169 local src = MEDIA_BASE .. media_href(att.relative_path or att.url or "")
170 if att.width and att.height then
171 imgs[#imgs + 1] = string.format(
172 ' <img src="%s" alt="%s" loading="lazy" width="%d" height="%d" style="display:block; max-width:min(100%%,800px); height:auto">',
173 src, esc, att.width, att.height)
174 else
175 imgs[#imgs + 1] = string.format(
176 ' <img src="%s" alt="%s" loading="lazy" style="display:block; max-width:min(100%%,800px); height:auto">',
177 src, esc)
178 end
179 end
180 -- For a standalone catalog image, link the title to its spot on the
181 -- chronological gallery page (Issue 9-013 gallery links). Image-only posts
182 -- carry no gallery_anchor, so their title stays plain text.
183 local title_html = esc
184 if poem.gallery_anchor then
185 title_html = string.format('<a href="%s%s">%s</a>', GALLERY_BASE, poem.gallery_anchor, esc)
186 end
187 return table.concat({
188 "<hr>",
189 string.format('<font color="#868E96"><b>🖼 %s</b></font>', title_html),
190 table.concat(imgs, "\n"),
191 "<hr>",
192 "",
193 }, "\n")
194end
195-- }}}
196
197-- {{{ function M.reset_cache()
198-- Test hook: drop the memoized manifest so fixtures don't bleed between cases.
199function M.reset_cache()
200 manifest_cache = nil
201end
202-- }}}
203
204return M
205