Skip to content

Instantly share code, notes, and snippets.

@ebanDev
Last active July 11, 2025 08:05
Show Gist options
  • Save ebanDev/51a76595dd609cdacb35a5d375b97e61 to your computer and use it in GitHub Desktop.
Save ebanDev/51a76595dd609cdacb35a5d375b97e61 to your computer and use it in GitHub Desktop.
FolderCover patch for KOReader
--[[
FolderCover patch for KOReader (v3.0)
========================================
Features:
- Automatically detects cover images in directories
- Supports multiple image formats (jpg, jpeg, png, webp, gif)
- Configurable folder name and file count display with positioning options
- Rounded corners and proper image cropping
- Settings integration with BookInfoManager
- Text overlay positioning (top, middle, bottom)
- Enhanced styling and alpha transparency
Supported filenames: cover.*, folder.*, .cover.*, .folder.* (case insensitive)
Settings (accessible through BookInfoManager):
- folder_cover_disabled: Enable/disable folder covers
- folder_cover_show_folder_name: Show folder name overlay
- folder_cover_show_file_count: Show file count overlay
- folder_cover_folder_name_position: Position of folder name (top/middle/bottom)
- folder_cover_file_count_position: Position of file count (top/middle/bottom)
Author: Eban
License: Same as KOReader (AGPL v3)
]]
-- Prevent double-loading
if rawget(_G, "FolderCoverPatchApplied") then return end
_G.FolderCoverPatchApplied = true
------------------------------------------------------------
-- SETTINGS INTEGRATION
------------------------------------------------------------
-- Mock BookInfoManager if not available (for standalone testing)
local BookInfoManager = {}
local settings_cache = {}
function BookInfoManager:getSetting(key)
-- Default settings
local defaults = {
folder_cover_disabled = false,
folder_cover_show_folder_name = false,
folder_cover_show_file_count = false,
folder_cover_folder_name_position = "middle",
folder_cover_file_count_position = "bottom",
}
-- Try to get real BookInfoManager if available
local success, real_manager = pcall(require, "bookinfomanager")
if success and real_manager and real_manager.getSetting then
return real_manager:getSetting(key)
end
-- Fallback to defaults
return settings_cache[key] or defaults[key]
end
-- Allow runtime setting changes for testing
function BookInfoManager:setSetting(key, value)
settings_cache[key] = value
end
------------------------------------------------------------
-- CONFIGURATION
------------------------------------------------------------
local VERBOSE = false
local COVER_CANDIDATES = {"cover", "folder", ".cover", ".folder"}
local COVER_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".gif"}
------------------------------------------------------------
-- INITIALIZATION
------------------------------------------------------------
local LoggerFactory = require("logger")
local log = (type(LoggerFactory)=="function" and LoggerFactory("FolderCover"))
or (LoggerFactory and LoggerFactory.new and LoggerFactory:new("FolderCover"))
or { dbg=function() end, info=print, warn=print, err=print }
if not VERBOSE then
log.dbg = function() end
log.info = function() end
end
local Device = require("device")
local Screen = Device.screen
------------------------------------------------------------
-- FOLDER COVER MANAGER
------------------------------------------------------------
local FolderCoverManager = {
cover_candidates = COVER_CANDIDATES,
cover_extensions = COVER_EXTENSIONS,
}
function FolderCoverManager:findCover(dir_path)
if not dir_path or dir_path == "" or dir_path == ".." or dir_path:match("%.%.$") then
return nil
end
dir_path = dir_path:gsub("[/\\]+$", "")
-- Try exact matches with lowercase and uppercase extensions
for _, candidate in ipairs(self.cover_candidates) do
for _, ext in ipairs(self.cover_extensions) do
local exact_path = dir_path .. "/" .. candidate .. ext
local f = io.open(exact_path, "rb")
if f then
f:close()
return exact_path
end
local upper_path = dir_path .. "/" .. candidate .. ext:upper()
if upper_path ~= exact_path then
f = io.open(upper_path, "rb")
if f then
f:close()
return upper_path
end
end
end
end
-- Fallback: scan directory for case-insensitive matches
local success, handle = pcall(io.popen, 'ls -1 "' .. dir_path .. '" 2>/dev/null')
if success and handle then
for file in handle:lines() do
if file and file ~= "." and file ~= ".." and file ~= "" then
local file_lower = file:lower()
for _, candidate in ipairs(self.cover_candidates) do
for _, ext in ipairs(self.cover_extensions) do
if file_lower == candidate .. ext then
handle:close()
return dir_path .. "/" .. file
end
end
end
end
end
handle:close()
end
return nil
end
function FolderCoverManager:createFolderCoverWidget(image_path, width, height, folder_name, file_count)
local Size = require("ui/size")
local Geom = require("ui/geometry")
local ImageWidget = require("ui/widget/imagewidget")
local FrameContainer = require("ui/widget/container/framecontainer")
local CenterContainer = require("ui/widget/container/centercontainer")
local OverlapGroup = require("ui/widget/overlapgroup")
local Blitbuffer = require("ffi/blitbuffer")
local margin = Screen:scaleBySize(5)
local border = Size.border.thick
local inner_width = width - (margin + border) * 2
local inner_height = height - (margin + border) * 2
if inner_width <= 0 or inner_height <= 0 then
inner_width, inner_height = Size.item.height_default, Size.item.height_default
end
-- Create cover image with center cropping
local success, cover_image = pcall(function()
local temp_image = ImageWidget:new{ file = image_path, scale_factor = 1 }
temp_image:_render()
local orig_w = temp_image:getOriginalWidth()
local orig_h = temp_image:getOriginalHeight()
temp_image:free()
local scale_to_fill = 0
if orig_w and orig_h then
local scale_x = inner_width / orig_w
local scale_y = inner_height / orig_h
scale_to_fill = math.max(scale_x, scale_y)
end
return ImageWidget:new{
file = image_path,
width = inner_width,
height = inner_height,
scale_factor = scale_to_fill,
center_x_ratio = 0.5,
center_y_ratio = 0.5,
}
end)
if not success or not cover_image then
return nil
end
local overlays = {}
-- Create text overlays if enabled
if BookInfoManager:getSetting("folder_cover_show_folder_name") or
BookInfoManager:getSetting("folder_cover_show_file_count") then
local Font = require("ui/font")
local TextBoxWidget = require("ui/widget/textboxwidget")
local BD = require("ui/bidi")
local AlphaContainer = require("ui/widget/container/alphacontainer")
local TopContainer = require("ui/widget/container/topcontainer")
local BottomContainer = require("ui/widget/container/bottomcontainer")
local VerticalGroup = require("ui/widget/verticalgroup")
-- Helper function to create text overlay widget
local function createOverlayWidget(text, font_face, font_size, is_bold)
local text_widget = TextBoxWidget:new{
text = text,
face = Font:getFace(font_face, font_size),
width = inner_width,
alignment = "center",
bold = is_bold or false,
fgcolor = Blitbuffer.COLOR_BLACK,
}
return AlphaContainer:new{
alpha = 0.8,
FrameContainer:new{
bordersize = 0,
margin = 0,
padding = Size.padding.tiny,
background = Blitbuffer.COLOR_WHITE,
radius = Screen:scaleBySize(3),
CenterContainer:new{
dimen = Geom:new{
w = inner_width - Screen:scaleBySize(4),
h = text_widget:getSize().h + (is_bold and 6 or 4),
},
text_widget,
},
},
}
end
-- Create text widgets for folder name and file count
local folder_name_widget, file_count_widget
if BookInfoManager:getSetting("folder_cover_show_folder_name") and folder_name and folder_name ~= "" then
local display_name = folder_name
if display_name:match('/$') then
display_name = display_name:sub(1, -2)
end
display_name = BD.directory(display_name)
folder_name_widget = createOverlayWidget(display_name, "cfont", 18, true)
end
if BookInfoManager:getSetting("folder_cover_show_file_count") and file_count then
file_count_widget = createOverlayWidget(tostring(file_count), "infont", 15, false)
end
-- Get positions and group overlays
local folder_name_position = BookInfoManager:getSetting("folder_cover_folder_name_position") or "middle"
local file_count_position = BookInfoManager:getSetting("folder_cover_file_count_position") or "bottom"
local position_groups = { top = {}, middle = {}, bottom = {} }
if folder_name_widget then
table.insert(position_groups[folder_name_position], folder_name_widget)
end
if file_count_widget then
table.insert(position_groups[file_count_position], file_count_widget)
end
-- Helper function to create positioned overlay container
local function createPositionedOverlay(widgets_group, position)
if #widgets_group == 0 then return nil end
local container_widget = #widgets_group == 1 and widgets_group[1] or VerticalGroup:new(widgets_group)
if position == "top" then
return TopContainer:new{
dimen = Geom:new{w = inner_width, h = inner_height},
container_widget,
}
elseif position == "bottom" then
return BottomContainer:new{
dimen = Geom:new{w = inner_width, h = inner_height},
container_widget,
}
else -- middle
return CenterContainer:new{
dimen = Geom:new{w = inner_width, h = inner_height},
container_widget,
}
end
end
-- Create positioned overlays for each position group
for position, widgets_group in pairs(position_groups) do
local positioned_overlay = createPositionedOverlay(widgets_group, position)
if positioned_overlay then
table.insert(overlays, positioned_overlay)
end
end
end
-- Combine cover image with overlays
local content_parts = {
CenterContainer:new{
dimen = Geom:new{w = inner_width, h = inner_height},
cover_image,
}
}
for _, overlay in ipairs(overlays) do
table.insert(content_parts, overlay)
end
return FrameContainer:new{
width = width,
height = height,
margin = margin,
padding = 0,
bordersize = border,
background = Blitbuffer.COLOR_WHITE,
radius = Screen:scaleBySize(10),
OverlapGroup:new{
dimen = Geom:new{w = inner_width, h = inner_height},
unpack(content_parts)
},
}
end
------------------------------------------------------------
-- MOSAIC MENU PATCHING
------------------------------------------------------------
local function patch_mosaic_menu(MosaicMenu)
if MosaicMenu.__foldercover_patched then return end
MosaicMenu.__foldercover_patched = true
local MosaicMenuItem
for _, func_name in ipairs{"_updateItemsBuildUI", "update", "init"} do
local func = MosaicMenu[func_name]
if type(func) == "function" then
local i = 1
repeat
local name, value = debug.getupvalue(func, i)
if not name then break end
if name == "MosaicMenuItem" then
MosaicMenuItem = value
break
end
i = i + 1
until false
end
if MosaicMenuItem then break end
end
if not MosaicMenuItem then
if package.loaded.mosaicmenu and package.loaded.mosaicmenu.MosaicMenuItem then
MosaicMenuItem = package.loaded.mosaicmenu.MosaicMenuItem
else
log.err("FolderCover: Could not find MosaicMenuItem")
return
end
end
local original_update = MosaicMenuItem.update
if not original_update then
log.err("FolderCover: MosaicMenuItem.update method not found")
return
end
function MosaicMenuItem:update(...)
local result = original_update(self, ...)
-- Skip if folder covers are disabled
if BookInfoManager:getSetting("folder_cover_disabled") then
return result
end
if self._foldercover_processed then return result end
local is_directory = self.entry and (
self.entry.is_directory or
self.entry.isDir or
self.entry.type == "directory" or
self.entry.is_file == false or
(self.entry.is_file == nil and self.entry.file == nil)
)
if not is_directory then return result end
local dir_path = self.entry and (
self.entry.path or
self.entry.full_path or
self.entry.fullName or
self.entry.name
)
if not dir_path then return result end
local cover_path = FolderCoverManager:findCover(dir_path)
if not cover_path then return result end
self._foldercover_processed = true
local folder_name = BookInfoManager:getSetting("folder_cover_show_folder_name") and self.text or nil
local file_count = BookInfoManager:getSetting("folder_cover_show_file_count") and self.mandatory or nil
local cover_widget = FolderCoverManager:createFolderCoverWidget(
cover_path,
self.width,
self.height,
folder_name,
file_count
)
if not cover_widget then return result end
if not self._underline_container then
local Size = require("ui/size")
local Geom = require("ui/geometry")
local UnderlineContainer = require("ui/widget/container/underlinecontainer")
local uh = Size.line.focus_indicator
local up = Size.padding.tiny
self._underline_container = UnderlineContainer:new{
vertical_align = "top",
padding = up,
dimen = Geom:new{
x = 0, y = 0,
w = self.width,
h = self.height + uh + up
},
linesize = uh
}
self[1] = self._underline_container
end
if self._underline_container[1] then
self._underline_container[1]:free(true)
end
self._underline_container[1] = cover_widget
self._has_cover_image = true
if self.menu then
self.menu._has_cover_images = true
end
if VERBOSE then
log.info("FolderCover: Applied cover to", self.text or "directory")
end
return result
end
end
------------------------------------------------------------
-- MODULE HOOKING
------------------------------------------------------------
local original_require = require
function require(module_name)
local result = original_require(module_name)
if module_name == "mosaicmenu" and type(result) == "table" then
pcall(patch_mosaic_menu, result)
end
return result
end
if package.loaded.mosaicmenu then
pcall(patch_mosaic_menu, package.loaded.mosaicmenu)
end
@eduardorodrigues08
Copy link

@ebanDev Thank you very much for the patch!

Where can I find the option to change the settings of your patch in the Koreader interface? I couldn't find anything about "BookInfoManager" in the menus.

@ebanDev
Copy link
Author

ebanDev commented Jun 14, 2025

These settings are in the file manager menu: Settings -> Mosaic settings -> Folder covers ;)

@eduardorodrigues08
Copy link

The mosaic settings don't appear for me. I think it's because I'm using the "Project Title" plugin too. Speaking of which, your cover patch and the Project Title plugin make Koreader much more beautiful! Would it be possible to change the menu to be somewhere else, outside of the mosaic settings? Thanks

@WIZARD012358
Copy link

These settings are in the file manager menu: Settings -> Mosaic settings -> Folder covers ;)

Did you mean mosaic and detailed list settings?. I've looked into it in my kindle and there is no option for folder covers.
And also I cant change the settings even by editing the lua file in v3.0, even tho I could do it in v2.1.

@joshuacant
Copy link

Hi @ebanDev

Thank you for your work on this and for licensing it under the same terms as KOReader. I've adapted a couple portions of it to include in my KOReader plugin. Not only does it save me time doing what you had already done, it means that users can switch between your userpatch and my plugin and not have to set things up twice.

I also added some automatic generation based on books in the sub-folder, as well as a fallback image that's used elsewhere in my plugin. It's still very much a work in progress and I haven't wired up any options for it yet. If you want a peek, you can check out this branch: https://github.com/joshuacant/ProjectTitle/tree/foldercovers

Thanks again, and good luck with the KOReader PR.

@NescientBystander
Copy link

NescientBystander commented Jun 23, 2025

Hey, :) I really enjoy the cover folders, thank you, but I don't seem to have the options? (Folder name overlay, etc.) I go Settings > Mosaic and detailed list settings > and there aren't any options. Am I missing something, or did I make a mistake somewhere?

Also, is there any way to make the folders smaller? They are way bigger than my book covers

@sebdelsol
Copy link

Your patch is great, but please consider using KOReader's userpatch tools instead of hacking your way into the CoverBrowser plugin. It currently prevents other patches from accessing the CoverBrowser plugin object, which is quite problematic.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment