published on in Go

自制PDF阅读器

我们可以使用 go-fitz 很轻易的制作一款 pdf 文档阅读器,原理是使用 go-fitz 将 pdf 转换为 html 内容,然后使用 go 搭建一个 web 服务,之后再通过浏览器进行文档阅读。相比直接使用浏览器阅读 pdf 文档的好处是原本一些不支持在 pdf 文档中使用的浏览器插件变得可以使用了,我们可以很轻松在阅读 pdf 文档时使用文本翻译,文本语音合成,GPT文章总结……这些功能。

本教程将引导你使用 Go 和 go-fitz 库构建一个 PDF 阅读器。go-fitz 是一个流行的 Go 库,它允许你操作 PDF 文档,包括将它们转换为 HTML。

1. 导入依赖项

首先,你需要导入必要的依赖项:

import (
	"flag"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"syscall"

	"github.com/gen2brain/go-fitz"
)
  • flag:用于处理命令行参数。
  • fmt:用于格式化和打印输出。
  • log:用于记录信息。
  • net/http:用于创建 Web 服务器。
  • os:用于处理文件。
  • os/signal:用于处理操作系统信号。
  • strconv:用于将字符串转换为其他数据类型。
  • syscall:用于处理系统调用。
  • github.com/gen2brain/go-fitz:go-fitz 库。

2. 定义常量和模板

接下来,定义一个 HTML 框架和一个常量:

const frame = `
<!DOCTYPE html>
<html>
<head>
<style>
body{background-color:slategray}
div{position:relative;background-color:white;margin:1em auto;box-shadow:1px 1px 8px -2px black}
div#pages{background-color:#0000;margin:0;box-shadow:none}
p{position:absolute;white-space:pre;margin:0}
</style>
</head>
<body>
<div id="pages"></div>
</body>
<script charset="utf-8">
async function load_page(position, page_ix=-1) {
    const pages = document.querySelector('#pages');
    if (page_ix == -1) {
        if (position == 'beforeEnd') {
            page_ix = Number(
                pages.lastElementChild.id.substr('page'.length)) + 1;
        } else if (position == 'afterBegin') {
            page_ix = Number(
                pages.firstElementChild.id.substr('page'.length)) - 1;
        }
    }
    const url = '/page?ix=' + page_ix;
    const resp = await fetch(url);
    if (!resp.ok) {
        return;
    }
    const page = await resp.text()
    pages.insertAdjacentHTML(position, page);
    if (position == 'afterBegin') {
        window.scrollTo(0, pages.firstChild.offsetHeight);
    }
}

function load_more() {
    const autoload = async () => {
        if ((window.innerHeight + window.scrollY) >= document.body.parentNode.offsetHeight) {
            window.removeEventListener("scroll", autoload);
            console.log("loading page to end...");
            await load_page('beforeEnd');
            window.addEventListener("scroll", autoload);
        } else if (window.scrollY == 0) {
            window.removeEventListener("scroll", autoload);
            console.log("loading page to begin...");
            await load_page('afterBegin');
            window.addEventListener("scroll", autoload);
        }
    }
    window.addEventListener("scroll", autoload);
}

window.onload = async () => {
    const page_ix = (new URLSearchParams(location.search)).get('start') || 0;
    await load_page('beforeEnd', page_ix);
    await load_page('afterBegin', page_ix - 1);
    load_more();
}
</script>
</html>
`
  • frame:这是网页的 HTML 框架。它定义了页面的布局、样式和包含 PDF 内容的容器。
  • pages:这是将 PDF 页面插入的 HTML 容器的 ID。

3. 处理命令行参数

接下来,从命令行参数获取 PDF 文件名:

filename := flag.String("filename", "", "pdf filename")
flag.Parse()

这会获取一个名为 --filename 的命令行参数,它表示要打开的 PDF 文件的路径。

4. 打开 PDF 文档

使用 go-fitz 打开 PDF 文档:

var doc *fitz.Document

doc, err = fitz.New(*filename)
if err != nil {
    log.Fatal(err)
}

这会打开给定路径的 PDF 文档并将其存储在 doc 变量中。

5. 处理系统信号

设置一个信号通道来监听 SIGINT 信号(通常是按 Ctrl+C 触发):

signal_chan := make(chan os.Signal, 1)
signal.Notify(signal_chan, syscall.SIGINT)

并设置一个 goroutine 来处理信号并关闭 PDF 文档:

go func() {
    <-signal_chan 
    if doc != nil {
        doc.Close()
    }
    os.Exit(0)
}()

这将确保在收到 SIGINT 信号时关闭 PDF 文档并退出程序。

6. 处理页面请求

设置一个 HTTP 处理程序来处理 /page 路由,它将提供 PDF 的单个页面:

func page_handler(w http.ResponseWriter, r *http.Request) {
    values := r.URL.Query()
    page_ix, err := strconv.Atoi(values.Get("ix"))
    if err != nil {
        http.Error(w, "page number valid faild", http.StatusBadRequest)
        return
    }
    log.Println("page ix:", page_ix)
    if page_ix < 0 || page_ix >= doc.NumPage() {
        http.Error(w, "load page faild", http.StatusNotFound)
        return
    }
    html, err := doc.HTML(page_ix, false)
    if err != nil {
        http.Error(w, "load page faild", http.StatusInternalServerError)
        return
    }
    fmt.Fprint(w, html)
}

这会获取请求的页面索引 (ix 查询参数),验证索引是否有效,然后使用 go-fitz 将页面转换为 HTML 并发送给客户端。

7. 处理首页请求

设置一个 HTTP 处理程序来处理 / 路由,它将提供带有 PDF 内容的 HTML 框架:

func index_handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, frame)
}

这会将 frame 框架发送给客户端,其中包含用于加载 PDF 页面的脚本。

8. 启动 Web 服务器

最后,启动一个在端口 8000 上监听的 Web 服务器:

http.HandleFunc("/page", page_handler)
http.HandleFunc("/", index_handler)
log.Fatal(http.ListenAndServe(":8000", nil))

这将启动服务器并允许客户端通过浏览器访问 PDF 内容。

9. 使用 PDF 阅读器

现在,你可以通过访问 http://localhost:8000 在浏览器中打开 PDF 阅读器。该页面将显示 PDF 的第一页。你可以使用滚动条浏览页面,或者使用键盘快捷键(如 )进行导航。你还可以使用浏览器的插件,如翻译器和语音合成,来增强阅读体验。

完整示例:

package main

import (
	"flag"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"syscall"

	"github.com/gen2brain/go-fitz"
)

var doc *fitz.Document

const frame = `
<!DOCTYPE html>
<html>
<head>
<style>
body{background-color:slategray}
div{position:relative;background-color:white;margin:1em auto;box-shadow:1px 1px 8px -2px black}
div#pages{background-color:#0000;margin:0;box-shadow:none}
p{position:absolute;white-space:pre;margin:0}
</style>
</head>
<body>
<div id="pages"></div>
</body>
<script charset="utf-8">
async function load_page(position, page_ix=-1) {
    const pages = document.querySelector('#pages');
    if (page_ix == -1) {
        if (position == 'beforeEnd') {
            page_ix = Number(
                pages.lastElementChild.id.substr('page'.length)) + 1;
        } else if (position == 'afterBegin') {
            page_ix = Number(
                pages.firstElementChild.id.substr('page'.length)) - 1;
        }
    }
    const url = '/page?ix=' + page_ix;
    const resp = await fetch(url);
    if (!resp.ok) {
        return;
    }
    const page = await resp.text()
    pages.insertAdjacentHTML(position, page);
    if (position == 'afterBegin') {
        window.scrollTo(0, pages.firstChild.offsetHeight);
    }
}

function load_more() {
    const autoload = async () => {
        if ((window.innerHeight + window.scrollY) >= document.body.parentNode.offsetHeight) {
            window.removeEventListener("scroll", autoload);
            console.log("loading page to end...");
            await load_page('beforeEnd');
            window.addEventListener("scroll", autoload);
        } else if (window.scrollY == 0) {
            window.removeEventListener("scroll", autoload);
            console.log("loading page to begin...");
            await load_page('afterBegin');
            window.addEventListener("scroll", autoload);
        }
    }
    window.addEventListener("scroll", autoload);
}

window.onload = async () => {
    const page_ix = (new URLSearchParams(location.search)).get('start') || 0;
    await load_page('beforeEnd', page_ix);
    await load_page('afterBegin', page_ix - 1);
    load_more();
}
</script>
</html>
`

func page_handler(w http.ResponseWriter, r *http.Request) {
    values := r.URL.Query()
    page_ix, err := strconv.Atoi(values.Get("ix"))
    if err != nil {
        http.Error(w, "page number valid faild", http.StatusBadRequest)
        return
    }
    log.Println("page ix:", page_ix)
    if page_ix < 0 || page_ix >= doc.NumPage() {
        http.Error(w, "load page faild", http.StatusNotFound)
        return
    }
    html, err := doc.HTML(page_ix, false)
    if err != nil {
        http.Error(w, "load page faild", http.StatusInternalServerError)
        return
    }
    fmt.Fprint(w, html)
}

func index_handler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, frame)
}

func main() {
    filename := flag.String("filename", "", "pdf filename")
    flag.Parse()

    var err error
	doc, err = fitz.New(*filename)
	if err != nil {
        log.Fatal(err)
	}

    signal_chan := make(chan os.Signal, 1)
    signal.Notify(signal_chan, syscall.SIGINT)
    go func() {
        <-signal_chan 
        if doc != nil {
            doc.Close()
        }
        os.Exit(0)
    }()

    http.HandleFunc("/page", page_handler)
    http.HandleFunc("/", index_handler)
    log.Fatal(http.ListenAndServe(":8000", nil))
}