基于Python打造一个零依赖前端的本地照片浏览器(附源码)

 更新时间:2026年07月08日 08:32:34   作者:winfredzhang  
本文介绍了一个基于wxPython的桌面图片浏览器项目,项目采用轻量级工程结构,可以完美解决图片管理器卡顿和文件夹混乱的问题,感兴趣的小伙伴可以了解下

一、项目概览

工程结构非常克制:

rainnyDay/
├── photo_browser_app.py         # wx 桌面外壳 + 完整逻辑(打包入口)
├── photo_browser_core.py        # 与 wx 无关的纯逻辑,仅供测试导入
├── photo_browser_config.json    # 用户配置:文件夹列表 + 当前选中
├── photo_browser_app.exe        # PyInstaller 打包产物
└── tests/
    ├── test_photo_browser_core.py           # 纯逻辑单测
    ├── test_photo_browser_app_shortcuts.py  # 快捷键单测
    └── test_packaging_imports.py            # 打包相关的边界测试

程序运行时的核心工作流是:

  1. photo_browser_config.json 读取"文件夹列表 + 当前索引";
  2. 扫描当前文件夹里所有支持的图片文件(.jpg / .png / .webp / .tiff 等 8 种);
  3. 在该文件夹下生成一份 showimage.html,其中每张图片是一张卡片,点击后出现全屏 Lightbox;
  4. 启动一个绑到 127.0.0.1 随机端口的 ThreadingTCPServer,把这个文件夹作为静态资源根目录;
  5. 用 wxPython 的 wx.html2.WebView(Windows 下即 WebView2)加载 http://127.0.0.1:<port>/showimage.html

用一句话概括:这是一个把"生成 HTML + 内嵌 HTTP + 内嵌浏览器"缝合在一起的桌面应用

二、两个模块的镜像关系:为什么要把逻辑抄一份?

打开工程你会注意到一件奇怪的事:photo_browser_core.py 的前 389 行代码,和 photo_browser_app.py 的前 396 行几乎完全一致。所有 dataclass、scan_imagesload_configsave_configensure_gallery_htmlbuild_gallery_markupgallery_style_blockgallery_lightbox_block 都出现了两次。

这不是失手粘贴,而是刻意为之。看测试就明白了:

# tests/test_packaging_imports.py
def test_app_entrypoint_does_not_import_local_core_module(self):
    source = Path("photo_browser_app.py").read_text(encoding="utf-8")
    tree = ast.parse(source)
    imported_modules = []
    for node in ast.walk(tree):
        if isinstance(node, ast.Import):
            imported_modules.extend(alias.name for alias in node.names)
        elif isinstance(node, ast.ImportFrom) and node.module:
            imported_modules.append(node.module)
    self.assertNotIn("photo_browser_core", imported_modules)

这条测试硬性要求:打包入口 photo_browser_app.py 不能 import photo_browser_core

为什么? 因为 PyInstaller 打包时,hidden import 与自定义模块的依赖分析常常出问题——如果打包入口去 from photo_browser_core import ...,就必须保证 core 模块被正确探测并写入 frozen 目录,否则运行时会出现"本地测试都过、打包后一启动就崩"的经典事故。作者选择用重复换稳定

  • photo_browser_core.py 是"逻辑标本",专门给单元测试导入,保证纯函数不带 wx 依赖;
  • photo_browser_app.py 内联同样的逻辑并接上 wx 外壳,作为 PyInstaller 的唯一入口。

这是一种在打包脚本严苛的场景下常见的取舍:用少量代码重复,换来打包链路的确定性和测试链路的隔离性

三、配置模型:dataclass + JSON 往返

配置模型只有两条 dataclass:

@dataclass
class FolderEntry:
    label: str
    path: str
    template: str = ""

@dataclass
class AppConfig:
    folders: list[FolderEntry] = field(default_factory=list)
    current_index: int = 0

load_config / save_config 做了几件"看似小事、其实很重要"的防御:

def load_config(config_path: Path | str = CONFIG_FILE) -> AppConfig:
    path = Path(config_path)
    if not path.exists():
        return AppConfig()

    try:
        raw = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return AppConfig()

    folders = [
        FolderEntry(
            label=str(item.get("label", "")),
            path=str(item.get("path", "")),
            template=str(item.get("template", "")),
        )
        for item in raw.get("folders", [])
        if isinstance(item, dict) and item.get("path")
    ]
    current_index = _clamp_index(int(raw.get("current_index", 0) or 0), folders)
    return AppConfig(folders=folders, current_index=current_index)

一共处理了 5 种异常输入:

异常处理
配置文件不存在返回空 AppConfig()
JSON 损坏except JSONDecodeError → 空配置
磁盘 I/O 失败except OSError → 空配置
元素不是 dict / 缺 path列表推导式内直接过滤
current_index 越界_clamp_index 夹到 [0, len-1]

对于一个桌面小工具,**“启动一定要能起来,即使配置损坏”**是关键体验。这段代码没有任何"提示用户修复配置"的花活儿——它只是安静地退回默认值,让主界面告诉用户"还没有设置照片文件夹,按 Ctrl+, 打开设置"。这种"用降级换成功启动"的思路非常值得借鉴。

保存时也很干净:

def save_config(config_path: Path | str, config: AppConfig) -> None:
    path = Path(config_path)
    path.parent.mkdir(parents=True, exist_ok=True)
    payload = {
        "folders": [asdict(folder) for folder in config.folders],
        "current_index": _clamp_index(config.current_index, config.folders),
    }
    path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")

注意 ensure_ascii=False —— 用户目录里带中文名(例如"视频下载")时,配置文件是可读的;indent=2 方便手动排错。

四、get_app_dir():一个 8 行函数背后的打包玄机

def get_app_dir() -> Path:
    if getattr(sys, "frozen", False):
        return Path(sys.executable).resolve().parent
    return Path(__file__).resolve().parent

sys.frozen 是 PyInstaller 在打包后的可执行文件里注入的属性。这一层判断把 photo_browser_config.json 的位置正确地放到了:

  • 开发时:与源码同目录;
  • 打包后:与 photo_browser_app.exe 同目录(而不是被解压到临时目录的 _MEI 里)。

如果没有这一层判断,打包版的用户配置会被写进 %TEMP%\_MEIxxxx\,程序一关就消失。这个 bug 非常隐蔽,但作者用测试锁死了行为:

def test_frozen_app_dir_uses_executable_parent_for_config(self):
    import photo_browser_app
    with patch.object(sys, "frozen", True, create=True), patch.object(
        sys, "executable", "C:/Apps/PhotoBrowser/photo_browser_app.exe"
    ):
        self.assertEqual(
            photo_browser_app.get_app_dir(),
            Path("C:/Apps/PhotoBrowser"),
        )

规则:任何在打包后需要写文件的位置都必须走 get_app_dir(),不能用 __file__

五、扫图与生成 HTML:幂等 + 可升级

5.1 扫描图片

SUPPORTED_IMAGE_EXTENSIONS = {
    ".jpg", ".jpeg", ".png", ".gif",
    ".bmp", ".webp", ".tif", ".tiff",
}

def scan_images(folder: Path | str) -> list[Path]:
    folder_path = Path(folder)
    if not folder_path.exists() or not folder_path.is_dir():
        return []
    images = [
        path
        for path in folder_path.iterdir()
        if path.is_file() and path.suffix.lower() in SUPPORTED_IMAGE_EXTENSIONS
    ]
    return sorted(images, key=lambda path: path.name.lower())

细节两处:

  • 只看当前文件夹iterdir() 不递归),语义符合"每个文件夹是一个相册";
  • 排序 key 用 name.lower(),避免 b.PNG 排在 a.jpg 前面。测试专门盯了这个:
(folder / "b.PNG").write_bytes(b"png")
(folder / "a.jpg").write_bytes(b"jpg")
(folder / "c.webp").write_bytes(b"webp")
result = [path.name for path in scan_images(folder)]
self.assertEqual(result, ["a.jpg", "b.PNG", "c.webp"])

5.2 生成 HTML:ensure_gallery_html

def ensure_gallery_html(folder, template=None, html_name=GALLERY_FILE_NAME) -> Path:
    folder_path = Path(folder)
    folder_path.mkdir(parents=True, exist_ok=True)
    html_path = folder_path / html_name
    if html_path.exists():
        try:
            existing = html_path.read_text(encoding="utf-8")
        except OSError:
            existing = ""
        if is_modern_gallery_html(existing):
            return html_path
    ...

关注两个字:“ensure”。函数的语义不是"生成",而是"确保存在且是最新版":

  1. 如果 showimage.html 已经存在,且带有版本标记(data-gallery-version="flex-lightbox-v2")以及所有必需的关键字(如 id="lightbox"closeLightbox 等),直接复用,一个字节都不重写;
  2. 否则(不存在或版本过旧),根据模板 or 默认样板,生成一份新的写回去。
def is_modern_gallery_html(source: str) -> bool:
    required_markers = [
        f'data-gallery-version="{GALLERY_VERSION}"',
        'class="photo-grid"',
        'class="photo-card"',
        "<img",
        'data-full-src=',
        'id="lightbox"',
        'id="lightbox-close"',
        "closeLightbox",
    ]
    return all(marker in source for marker in required_markers)

这个"版本探测"设计有两个重要好处:

  • 升级平滑:当代码迭代到 flex-lightbox-v3 时,只需要把 GALLERY_VERSION 常量改掉,老版本就会被自动重写;
  • 不覆盖用户改动:如果用户手动修改了 HTML 但保留了这些标记,程序就当"这是最新版",不动它。

测试甚至专门验证了两个反向路径:

# 有版本标记 → 复用
def test_ensure_gallery_html_reuses_existing_modern_gallery_without_overwriting

# 无版本标记 → 升级重写
def test_ensure_gallery_html_upgrades_legacy_existing_html_to_thumbnail_lightbox_gallery

5.3 URL 编码:中文/空格文件名的老坑

def build_gallery_markup(folder: Path, images: Iterable[Path]) -> str:
    cards = []
    for image_path in images:
        relative = image_path.relative_to(folder)
        src = quote(relative.as_posix())
        alt = html.escape(image_path.name, quote=True)
        cards.append(
            f'<a class="photo-card" href="{src}" rel="external nofollow"  data-full-src="{src}">'
            f'<img loading="lazy" src="{src}" alt="{alt}">'
            f"<span>{alt}</span>"
            f"</a>"
        )
    ...

两个非常容易忽略的细节:

  • URL 用 urllib.parse.quote 编码("first photo.jpg""first%20photo.jpg"),否则 WebView 加载会 404;
  • alt 属性用 html.escape(..., quote=True),避免文件名里带 " < > & 时把 HTML 解析破坏。

而且用的是 relative.as_posix(),把 Windows 反斜杠转成 URL 友好的正斜杠。所有细节都指向一个目标:在 Windows 中文用户环境下"不出错"

六、HTML 模板系统:占位符 + 强制注入

程序允许用户自带 HTML 模板(在设置里为每个文件夹指定一个),把生成的相册片段塞进去。这套机制分两层:

6.1 占位符替换

def apply_template(template_source: str, title: str, gallery_markup: str) -> str:
    document = template_source.replace("{{TITLE}}", title)
    if "{{IMAGE_GALLERY}}" in document:
        document = document.replace("{{IMAGE_GALLERY}}", gallery_markup)
    elif "</body>" in document:
        document = document.replace("</body>", gallery_markup + "\n</body>")
    else:
        document = document + "\n" + gallery_markup
    return inject_gallery_assets(document)

三级降级:{{IMAGE_GALLERY}}</body> 前 → 直接追加末尾。永远能塞进去

6.2 强制注入:Lightbox 样式与脚本

用户模板不可能记住所有需要的 CSS 和 JS,所以:

def inject_gallery_assets(document: str) -> str:
    if ".photo-card:hover" not in document:
        if "</head>" in document:
            document = document.replace("</head>", gallery_style_block() + "\n</head>", 1)
        else:
            document = gallery_style_block() + "\n" + document

    if 'id="lightbox"' not in document:
        if "</body>" in document:
            document = document.replace("</body>", gallery_lightbox_block() + "\n</body>", 1)
        else:
            document = document + "\n" + gallery_lightbox_block()
    return document

关键设计:用"特征字符串"(.photo-card:hover / id="lightbox")来判断该资源是否已经在模板里。如果模板已经自己写了这些,就一个字节都不加;否则强制注入。这样:

  • 用户可以自定义标题栏、导航、字体;
  • 但可以放心地不写 Lightbox 样式和交互 —— 程序会兜底。

测试也把这个逻辑锁死:

def test_template_gallery_receives_default_styles_and_lightbox_script(self):
    template.write_text("<html><head></head><body>{{IMAGE_GALLERY}}</body></html>", ...)
    ...
    self.assertIn(".photo-card:hover", html)
    self.assertIn('id="lightbox"', html)
    self.assertIn("closeLightbox", html)

七、内嵌 JavaScript 为什么写得像 2015 年?

gallery_lightbox_block()

var lightbox = document.getElementById("lightbox");
var lightboxImage = document.getElementById("lightbox-image");
var closeButton = document.getElementById("lightbox-close");

function addOpenClass() {
  if (lightbox.className.indexOf("open") === -1) {
    lightbox.className += " open";
  }
}

var cards = document.querySelectorAll(".photo-card");
for (var i = 0; i < cards.length; i++) {
  cards[i].onclick = function(event) {
    event.preventDefault();
    var image = this.querySelector("img");
    openLightbox(this.getAttribute("data-full-src"), image ? image.alt : "");
  };
}

看不到任何 let / const / 箭头函数 / classList.add。为什么?测试文件里直接写死了:

self.assertNotIn("=>", html)
self.assertNotIn("const ", html)

这里其实是兼容遗留 WebView 后端的防御——wxPython 在 Windows 上的 wx.html2.WebView 默认后端可能是 IE 内核(wxWEBVIEW_BACKEND_IE),而不是 Edge/Chromium。作者选择了"如果任何环境都要跑,那就写 ES5 语法"。同样,CSS 里也能看到 -ms-flexbox 这样的老式前缀:

.photo-grid {
  display: -ms-flexbox;
  display: flex;
  -ms-flex-wrap: wrap;
  flex-wrap: wrap;
  ...
}

教训:桌面 WebView 应用不能假设浏览器是现代的,最好用 CanIUse 里 IE11 也能跑的子集

八、为什么不用file://?—— 一个"多此一举"的 HTTP 服务

来看这段简洁的服务器:

class QuietGalleryRequestHandler(http.server.SimpleHTTPRequestHandler):
    def log_message(self, format: str, *args: object) -> None:
        return  # 静默日志,避免污染 stdout

class ReusableThreadingTCPServer(ThreadingTCPServer):
    allow_reuse_address = True
    daemon_threads = True

class GalleryHttpServer:
    def start(self, folder: Path | str) -> str:
        folder_path = Path(folder).resolve()
        if self._server is not None and self._folder == folder_path:
            return self._base_url

        self.stop()
        handler = functools.partial(QuietGalleryRequestHandler, directory=str(folder_path))
        self._server = ReusableThreadingTCPServer(("127.0.0.1", 0), handler)
        port = self._server.server_address[1]
        self._base_url = f"http://127.0.0.1:{port}/"
        self._folder = folder_path
        self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
        self._thread.start()
        return self._base_url

设计要点:

  1. ("127.0.0.1", 0):绑本地回环,端口由系统分配,不冲突;
  2. allow_reuse_address = True:切换文件夹时快速关闭+重开不会报 Address already in use
  3. daemon_threads = True + daemon=True:主进程退出线程自动死掉,不遗留僵尸;
  4. 文件夹相同时短路:切回同一个文件夹不重启服务器。

关键问题:为什么不直接用 file:///C:/Users/.../showimage.html

打包测试给出了明确的答案:

def test_gallery_loading_uses_local_http_server_not_file_url_or_set_page(self):
    source = Path("photo_browser_app.py").read_text(encoding="utf-8")
    self.assertIn(".LoadURL(", source)
    self.assertNotIn(".resolve().as_uri()", source)
    self.assertNotIn("folder_base_url(", source)

WebView2(以及旧版 IE WebView)在 file:// 协议下有一堆限制:

  • 无法访问子路径的图片(同源策略在 file:// 下极为严格);
  • 中文路径在部分 Edge/Chromium 版本下会解析失败
  • loading="lazy" 等特性对 file:// 支持不完整
  • 相对路径的 <img src="a.jpg"> 有时会被解析成 file:///a.jpg

所以作者做了这个看似"多此一举"的决定:把每个相册文件夹当作静态站点根目录,用真 HTTP 服务提供服务。代价是多起了一个线程,收益是所有相对路径、中文文件名、懒加载都按标准 HTTP 语义走,行为可预测。

urllib.request 侧还写了端到端的测试来锁死这个能力:

def test_gallery_http_server_serves_html_and_images(self):
    with TemporaryDirectory() as tmp:
        folder = Path(tmp)
        (folder / "showimage.html").write_text('<img src="photo.png">', encoding="utf-8")
        (folder / "photo.png").write_bytes(b"fake-png")
        server = photo_browser_app.GalleryHttpServer()
        try:
            base_url = server.start(folder)
            html = urllib.request.urlopen(base_url + "showimage.html", timeout=5).read()
            image = urllib.request.urlopen(base_url + "photo.png", timeout=5).read()
        finally:
            server.stop()
        self.assertIn(b"photo.png", html)
        self.assertEqual(image, b"fake-png")

九、wx 外壳:菜单、快捷键、全屏

9.1 加速键表

ID_SETTINGS = wx.NewIdRef()
ID_SWITCH_FOLDER = wx.NewIdRef()
ID_ADD_FOLDER = wx.NewIdRef()
ID_RELOAD = wx.NewIdRef()
ID_TOGGLE_FULLSCREEN = wx.NewIdRef()

def build_accelerator_entries() -> list[wx.AcceleratorEntry]:
    return [
        wx.AcceleratorEntry(wx.ACCEL_CTRL, ord(","), ID_SETTINGS),
        wx.AcceleratorEntry(wx.ACCEL_CTRL | wx.ACCEL_SHIFT, ord("F"), ID_SWITCH_FOLDER),
        wx.AcceleratorEntry(wx.ACCEL_CTRL, ord("O"), ID_ADD_FOLDER),
        wx.AcceleratorEntry(0, wx.WXK_F11, ID_TOGGLE_FULLSCREEN),
        wx.AcceleratorEntry(0, wx.WXK_F5, ID_RELOAD),
    ]

wx.NewIdRef() 而非硬编码的 ID,避免和 wx 内置菜单项 ID 冲突。全屏用 F11、刷新用 F5,都是浏览器习惯。而且有单测:

def test_fullscreen_shortcut_uses_f11(self):
    entries = build_accelerator_entries()
    fullscreen_entries = [
        entry for entry in entries
        if entry.GetCommand() == int(ID_TOGGLE_FULLSCREEN)
    ]
    self.assertEqual(len(fullscreen_entries), 1)
    self.assertEqual(fullscreen_entries[0].GetKeyCode(), wx.WXK_F11)

9.2 全屏切换

def on_toggle_fullscreen(self, event):
    self.is_fullscreen = not self.is_fullscreen
    if self.is_fullscreen:
        self.SetMenuBar(None)
        self.ShowFullScreen(True, wx.FULLSCREEN_ALL)
        return
    self.ShowFullScreen(False)
    if self.menu_bar is not None:
        self.SetMenuBar(self.menu_bar)

进入全屏时把菜单栏拆掉(SetMenuBar(None))以获得完全沉浸;退出时把之前保留的 self.menu_bar 装回去。这里 menu_bar 是构造菜单时缓存的引用,避免二次创建。

9.3 关闭时保存

def on_close(self, event: wx.CloseEvent) -> None:
    self.gallery_server.stop()
    save_config(CONFIG_FILE, self.config)
    event.Skip()

先停 HTTP,再存配置,再 event.Skip() 让框架继续走关闭流程。顺序很重要:先停线程,避免关闭过程中还有请求打进来。

十、几个可以偷师的设计思路

把整个工程当成一个案例,可以提炼出五条对任何一个 Python 桌面小工具都通用的经验:

  1. 打包入口不 import 本地模块。 用少量代码重复换取 PyInstaller 依赖分析的稳定性。
  2. 写文件必须走 get_app_dir() sys.frozen 二分支 + Path(sys.executable).parent 是打包版本正确落盘的唯一姿势。
  3. 配置读取要"永远返回对象"。 JSON 坏了、字段错了、索引越界,都退回默认值,让主界面告诉用户"没配置",而不是弹异常。
  4. WebView 加载本地资源用 HTTP,不用 file:// 一段 20 行的 ThreadingTCPServer 就能规避掉所有相对路径、中文文件名、同源策略的坑。
  5. 给生成产物加版本标记。 一个字符串常量 flex-lightbox-v2 + 一个 is_modern_gallery_html() 检查,让 HTML 可以在多次运行中安全升级,同时保留用户手改的部分。

到此这篇关于基于Python打造一个零依赖前端的本地照片浏览器(附源码)的文章就介绍到这了,更多相关Python本地照片浏览器内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

最新评论