文档转换封装

dev
truthhun 2 years ago
parent ef20f380a4
commit f1c696666b

@ -1,6 +1,7 @@
# ROADMAP
# TODO
- [ ] 用户允许上传的文件大小限制,避免文件太大导致服务器崩溃
- [ ] 每次上传的文件数量限制,避免一次上传太多,处理不过来
- [ ] 每天上传的文件数量限制,用于避免用户恶意上传文档
- [ ] 根据用户组来进行额度授权
- [ ] 根据用户组来进行额度授权
- [ ] 定时清除cache/convert目录下的文件

@ -25,6 +25,8 @@ const (
ConfigCategorySecurity = "security"
// ConfigCategoryFooter 底部链接
ConfigCategoryFooter = "footer"
// ConfigCategoryConverter 转换配置项
ConfigCategoryConverter = "converter"
)
type Config struct {
@ -173,6 +175,105 @@ type ConfigCaptcha struct {
Type string `json:"type"` // 验证码类型
}
const (
ConfigSystemSitename = "sitename"
ConfigSystemDomain = "domain"
ConfigSystemTitle = "title"
ConfigSystemDescription = "description"
ConfigSystemKeywords = "keywords"
ConfigSystemLogo = "logo"
ConfigSystemFavicon = "favicon"
ConfigSystemIcp = "icp"
ConfigSystemAnalytics = "analytics"
ConfigSystemCopyrightStartYear = "copyright_start_year"
)
type ConfigSystem struct {
Sitename string `json:"sitename"` // 网站名称
Domain string `json:"domain"` // 站点域名,不带 HTTPS:// 和 HTTP://
Title string `json:"title"` // 网站首页标题
Keywords string `json:"keywords"` // 系统关键字
Description string `json:"description"` // 系统描述
Logo string `json:"logo"` // logo
Favicon string `json:"favicon"` // logo
ICP string `json:"icp"` // 网站备案
Analytics string `json:"analytics"` // 统计代码
CopyrightStartYear string `json:"copyright_start_year"` // 版权年
}
const (
ConfigSecurityMaxDocumentSize = "max_document_size" // 是否关闭注册
ConfigSecurityIsClose = "is_close" // 是否关闭注册
ConfigSecurityCloseStatement = "close_statement" // 闭站说明
ConfigSecurityEnableRegister = "enable_register" // 是否允许注册
ConfigSecurityEnableCaptchaLogin = "enable_captcha_login" // 是否开启登录验证码
ConfigSecurityEnableCaptchaRegister = "enable_captcha_register" // 是否开启注册验证码
ConfigSecurityEnableCaptchaComment = "enable_captcha_comment" // 是否开启注册验证码
ConfigSecurityEnableCaptchaFindPassword = "enable_captcha_find_password" // 是否开启注册验证码
)
type ConfigSecurity struct {
MaxDocumentSize int32 `json:"max_document_size"` // 允许上传的最大文档大小
IsClose bool `json:"is_close"` // 是否闭站
CloseStatement string `json:"close_statement"` // 闭站说明
EnableRegister bool `json:"enable_register"` // 是否启用注册
EnableCaptchaLogin bool `json:"enable_captcha_login"` // 是否启用登录验证码
EnableCaptchaRegister bool `json:"enable_captcha_register"` // 是否启用注册验证码
EnableCaptchaComment bool `json:"enable_captcha_comment"` // 是否启用评论验证码
EnableCaptchaFindPassword bool `json:"enable_captcha_find_password"` // 找回密码是否需要验证码
}
const (
ConfigConverterMaxPreview = "max_preview" // 最大预览页数
ConfigConverterTimeout = "timeout" // 转换超时时间
ConfigConverterEnableSVGO = "enable_svgo" // 是否启用 SVGO
ConfigConverterEnableGZIP = "enable_gzip" // 是否启用 GZIP
)
// ConfigConverter 转换配置
type ConfigConverter struct {
MaxPreview int32 `json:"max_preview"` // 文档所允许的最大预览页数0 表示不限制,全部转换
Timeout int32 `json:"timeout"` // 转换超时时间单位为分钟默认30分钟
EnableSVGO bool `json:"enable_svgo"` // 是否对svg启用SVGO压缩。转换效率会有所下降。相对直接的svg文件可以节省1/2的存储空间
EnableGZIP bool `json:"enable_gzip"` // 是否对svg启用GZIP压缩。转换效率会有所下降。相对直接的svg文件可以节省3/4的存储空间
// GZIP和svgo都开启转换效率会有所下降可以综合节省约85%的存储空间
}
const (
ConfigFooterAbout = "about" // 关于我们
ConfigFooterContact = "contact" // 联系我们
ConfigFooterAgreement = "agreement" // 用户协议
ConfigFooterCopyright = "copyright" // 版权信息
ConfigFooterFeedback = "feedback" // 反馈信息
)
type ConfigFooter struct {
About string `json:"about"` // 关于我们
Contact string `json:"contact"` // 联系我们
Agreement string `json:"agreement"` // 用户协议、文库协议
Copyright string `json:"copyright"` // 版权信息、免责声明
Feedback string `json:"feedback"` // 反馈
}
func (m *DBModel) GetConfigOfFooter() (config ConfigFooter) {
var configs []Config
err := m.db.Where("category = ?", ConfigCategoryFooter).Find(&configs).Error
if err != nil && err != gorm.ErrRecordNotFound {
m.logger.Error("GetConfigOfFooter", zap.Error(err))
}
var data = make(map[string]interface{})
for _, cfg := range configs {
data[cfg.Name] = cfg.Value
}
bytes, _ := json.Marshal(data)
json.Unmarshal(bytes, &config)
return
}
// GetConfigOfCaptcha 获取验证码配置
func (m *DBModel) GetConfigOfCaptcha() (config ConfigCaptcha) {
var configs []Config
@ -206,32 +307,6 @@ func (m *DBModel) GetConfigOfCaptcha() (config ConfigCaptcha) {
return
}
const (
ConfigSystemSitename = "sitename"
ConfigSystemDomain = "domain"
ConfigSystemTitle = "title"
ConfigSystemDescription = "description"
ConfigSystemKeywords = "keywords"
ConfigSystemLogo = "logo"
ConfigSystemFavicon = "favicon"
ConfigSystemIcp = "icp"
ConfigSystemAnalytics = "analytics"
ConfigSystemCopyrightStartYear = "copyright_start_year"
)
type ConfigSystem struct {
Sitename string `json:"sitename"` // 网站名称
Domain string `json:"domain"` // 站点域名,不带 HTTPS:// 和 HTTP://
Title string `json:"title"` // 网站首页标题
Keywords string `json:"keywords"` // 系统关键字
Description string `json:"description"` // 系统描述
Logo string `json:"logo"` // logo
Favicon string `json:"favicon"` // logo
ICP string `json:"icp"` // 网站备案
Analytics string `json:"analytics"` // 统计代码
CopyrightStartYear string `json:"copyright_start_year"` // 版权年
}
// GetConfigOfSystem 获取系统配置
func (m *DBModel) GetConfigOfSystem() (config ConfigSystem) {
var confgis []Config
@ -252,28 +327,6 @@ func (m *DBModel) GetConfigOfSystem() (config ConfigSystem) {
return
}
const (
ConfigSecurityMaxDocumentSize = "max_document_size" // 是否关闭注册
ConfigSecurityIsClose = "is_close" // 是否关闭注册
ConfigSecurityCloseStatement = "close_statement" // 闭站说明
ConfigSecurityEnableRegister = "enable_register" // 是否允许注册
ConfigSecurityEnableCaptchaLogin = "enable_captcha_login" // 是否开启登录验证码
ConfigSecurityEnableCaptchaRegister = "enable_captcha_register" // 是否开启注册验证码
ConfigSecurityEnableCaptchaComment = "enable_captcha_comment" // 是否开启注册验证码
ConfigSecurityEnableCaptchaFindPassword = "enable_captcha_find_password" // 是否开启注册验证码
)
type ConfigSecurity struct {
MaxDocumentSize int32 `json:"max_document_size"` // 允许上传的最大文档大小
IsClose bool `json:"is_close"` // 是否闭站
CloseStatement string `json:"close_statement"` // 闭站说明
EnableRegister bool `json:"enable_register"` // 是否启用注册
EnableCaptchaLogin bool `json:"enable_captcha_login"` // 是否启用登录验证码
EnableCaptchaRegister bool `json:"enable_captcha_register"` // 是否启用注册验证码
EnableCaptchaComment bool `json:"enable_captcha_comment"` // 是否启用评论验证码
EnableCaptchaFindPassword bool `json:"enable_captcha_find_password"` // 找回密码是否需要验证码
}
// GetConfigOfSecurity 获取安全配置
func (m *DBModel) GetConfigOfSecurity(name ...string) (config ConfigSecurity) {
var configs []Config
@ -306,33 +359,25 @@ func (m *DBModel) GetConfigOfSecurity(name ...string) (config ConfigSecurity) {
return
}
const (
ConfigFooterAbout = "about" // 关于我们
ConfigFooterContact = "contact" // 联系我们
ConfigFooterAgreement = "agreement" // 用户协议
ConfigFooterCopyright = "copyright" // 版权信息
ConfigFooterFeedback = "feedback" // 反馈信息
)
type ConfigFooter struct {
About string `json:"about"` // 关于我们
Contact string `json:"contact"` // 联系我们
Agreement string `json:"agreement"` // 用户协议、文库协议
Copyright string `json:"copyright"` // 版权信息、免责声明
Feedback string `json:"feedback"` // 反馈
}
func (m *DBModel) GetConfigOfFooter() (config ConfigFooter) {
func (m *DBModel) GetConfigOfConverter() (config ConfigConverter) {
var configs []Config
err := m.db.Where("category = ?", ConfigCategoryFooter).Find(&configs).Error
err := m.db.Where("category = ?", ConfigCategoryConverter).Find(&configs).Error
if err != nil && err != gorm.ErrRecordNotFound {
m.logger.Error("GetConfigOfFooter", zap.Error(err))
m.logger.Error("GetConfigOfConverter", zap.Error(err))
}
var data = make(map[string]interface{})
for _, cfg := range configs {
data[cfg.Name] = cfg.Value
switch cfg.Name {
case "max_preview", "timeout":
data[cfg.Name], _ = strconv.Atoi(cfg.Value)
case "enable_svgo", "enable_gzip":
value, _ := strconv.ParseBool(cfg.Value)
data[cfg.Name] = value
default:
data[cfg.Name] = cfg.Value
}
}
bytes, _ := json.Marshal(data)
@ -378,6 +423,12 @@ func (m *DBModel) initConfig() (err error) {
{Category: ConfigCategoryFooter, Name: ConfigFooterAgreement, Label: "文库协议", Value: "/article/agreement", Placeholder: "请输入文库协议的链接地址,留空表示不显示", InputType: "text", Sort: 26, Options: ""},
{Category: ConfigCategoryFooter, Name: ConfigFooterCopyright, Label: "免责声明", Value: "/article/copyright", Placeholder: "请输入免责声明的链接地址,留空表示不显示", InputType: "text", Sort: 27, Options: ""},
{Category: ConfigCategoryFooter, Name: ConfigFooterFeedback, Label: "意见反馈", Value: "/article/feedback", Placeholder: "请输入意见反馈的链接地址,留空表示不显示", InputType: "text", Sort: 28, Options: ""},
// 转换配置项
{Category: ConfigCategoryConverter, Name: ConfigConverterMaxPreview, Label: "最大预览页数", Value: "0", Placeholder: "文档允许的最大预览页数0表示不限制", InputType: "number", Sort: 15, Options: ""},
{Category: ConfigCategoryConverter, Name: ConfigConverterTimeout, Label: "转换超时(分钟)", Value: "30", Placeholder: "文档转换超时时间默认为30分钟", InputType: "number", Sort: 16, Options: ""},
{Category: ConfigCategoryConverter, Name: ConfigConverterEnableGZIP, Label: "是否启用GZIP压缩", Value: "true", Placeholder: "是否对文档SVG预览文件启用GZIP压缩启用后转换效率会【稍微】下降但相对直接的SVG文件减少75%的存储空间", InputType: "switch", Sort: 17, Options: ""},
{Category: ConfigCategoryConverter, Name: ConfigConverterEnableSVGO, Label: "是否启用SVGO", Value: "false", Placeholder: "是否对文档SVG预览文件启用SVGO压缩启用后转换效率会【明显】下降但相对直接的SVG文件减少50%左右的存储空间", InputType: "switch", Sort: 18, Options: ""},
}
for _, cfg := range cfgs {

@ -462,3 +462,15 @@ func (m *DBModel) GetDocumentStatusConvertedByHash(hash []string) (statusMap map
}
return
}
// ConvertDocument 文档转换
// 1. 查询待转换的文档
// 2. 文档对应的md5 hash中是否有已转换的文档如果有则直接关联和调整状态为已转换
// 3. 文档转PDF
// 4. PDF截取第一章图片作为封面
// 5. 根据允许最大的预览页面将PDF转为svg同时转gzip压缩如果有需要的话
// 6. 提取PDF文本以及获取文档信息
// 7. 更新文档状态
func (m *DBModel) ConvertDocument() {
}

@ -1,10 +1,14 @@
package converter
import (
"bytes"
"compress/gzip"
"fmt"
"moredoc/util"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
@ -14,6 +18,7 @@ import (
const (
soffice = "soffice"
ebookConvert = "ebook-convert"
svgo = "svgo"
mutool = "mutool"
dirDteFmt = "2006/01/02/15"
)
@ -59,14 +64,14 @@ func (c *Converter) ConvertToPDF(src string) (dst string, err error) {
return c.ConvertMOBIToPDF(src)
case ".chm":
return c.ConvertCHMToPDF(src)
case ".doc", ".docx", ".rtf", ".wps", ".odt",
".xls", ".xlsx", ".et", ".ods",
".ppt", ".pptx", ".dps", ".odp", ".pps", ".ppsx", ".pot", ".potx":
return c.ConvertOfficeToPDF(src)
// case ".doc", ".docx", ".rtf", ".wps", ".odt",
// ".xls", ".xlsx", ".et", ".ods",
// ".ppt", ".pptx", ".dps", ".odp", ".pps", ".ppsx", ".pot", ".potx":
// return c.ConvertOfficeToPDF(src)
default:
return "", fmt.Errorf("不支持的文件类型:%s", ext)
return c.ConvertOfficeToPDF(src)
// return "", fmt.Errorf("不支持的文件类型:%s", ext)
}
return
}
// ConvertOfficeToPDF 通过soffice将office文档转换为pdf
@ -104,7 +109,7 @@ func (c *Converter) ConvertPDFToTxt(src string) (dst string, err error) {
src,
}
os.MkdirAll(filepath.Dir(dst), os.ModePerm)
c.logger.Info("convert pdf to txt", zap.String("cmd", mutool), zap.Strings("args", args))
c.logger.Debug("convert pdf to txt", zap.String("cmd", mutool), zap.Strings("args", args))
_, err = util.ExecCommand(mutool, args, c.timeout)
if err != nil {
return
@ -118,8 +123,29 @@ func (c *Converter) ConvertCHMToPDF(src string) (dst string, err error) {
}
// ConvertPDFToSVG 将PDF转为SVG
func (c *Converter) ConvertPDFToSVG(src string, fromPage, toPage int) (pages []Page, err error) {
return c.convertPDFToPage(src, fromPage, toPage, ".svg")
func (c *Converter) ConvertPDFToSVG(src string, fromPage, toPage int, enableSVGO, enableGZIP bool) (pages []Page, err error) {
pages, err = c.convertPDFToPage(src, fromPage, toPage, ".svg")
if err != nil {
return
}
if len(pages) == 0 {
return
}
if enableSVGO { // 压缩svg
c.CompressSVGBySVGO(filepath.Dir(pages[0].PagePath))
}
if enableGZIP { // gzip 压缩
for _, page := range pages {
if dst, errCompress := c.CompressSVGByGZIP(page.PagePath); errCompress == nil {
os.Remove(page.PagePath)
page.PagePath = dst
}
}
}
return
}
// ConvertPDFToPNG 将PDF转为PNG
@ -139,7 +165,7 @@ func (c *Converter) convertPDFToPage(src string, fromPage, toPage int, ext strin
pageRange,
}
os.MkdirAll(filepath.Dir(cacheFile), os.ModePerm)
c.logger.Info("convert pdf to page", zap.String("cmd", mutool), zap.Strings("args", args))
c.logger.Debug("convert pdf to page", zap.String("cmd", mutool), zap.Strings("args", args))
_, err = util.ExecCommand(mutool, args, c.timeout)
if err != nil {
return
@ -170,7 +196,7 @@ func (c *Converter) convertToPDFBySoffice(src string) (dst string, err error) {
}
args = append(args, src)
os.MkdirAll(filepath.Dir(dst), os.ModePerm)
c.logger.Info("convert to pdf by soffice", zap.String("cmd", soffice), zap.Strings("args", args))
c.logger.Debug("convert to pdf by soffice", zap.String("cmd", soffice), zap.Strings("args", args))
_, err = util.ExecCommand(soffice, args, c.timeout)
if err != nil {
c.logger.Error("convert to pdf by soffice", zap.String("cmd", soffice), zap.Strings("args", args), zap.Error(err))
@ -192,10 +218,96 @@ func (c *Converter) convertToPDFByCalibre(src string) (dst string, err error) {
"--pdf-page-margin-top", "36",
}
os.MkdirAll(filepath.Dir(dst), os.ModePerm)
c.logger.Info("convert to pdf by calibre", zap.String("cmd", ebookConvert), zap.Strings("args", args))
c.logger.Debug("convert to pdf by calibre", zap.String("cmd", ebookConvert), zap.Strings("args", args))
_, err = util.ExecCommand(ebookConvert, args, c.timeout)
if err != nil {
c.logger.Error("convert to pdf by calibre", zap.String("cmd", ebookConvert), zap.Strings("args", args), zap.Error(err))
}
return
}
func (c *Converter) CountPDFPages(file string) (pages int, err error) {
args := []string{
"show",
file,
"pages",
}
c.logger.Debug("count pdf pages", zap.String("cmd", mutool), zap.Strings("args", args))
var out string
out, err = util.ExecCommand(mutool, args, c.timeout)
if err != nil {
c.logger.Error("count pdf pages", zap.String("cmd", mutool), zap.Strings("args", args), zap.Error(err))
return
}
lines := strings.Split(out, "\n")
length := len(lines)
for i := length - 1; i >= 0; i-- {
line := strings.TrimSpace(strings.ToLower(lines[i]))
c.logger.Debug("count pdf pages", zap.String("line", line))
if strings.HasPrefix(line, "page") {
pages, _ = strconv.Atoi(strings.TrimSpace(strings.TrimLeft(strings.Split(line, "=")[0], "page")))
if pages > 0 {
return
}
}
}
return
}
func (c *Converter) ExistMupdf() (err error) {
_, err = exec.LookPath(mutool)
return
}
func (c *Converter) ExistSoffice() (err error) {
_, err = exec.LookPath(soffice)
return
}
func (c *Converter) ExistCalibre() (err error) {
_, err = exec.LookPath(ebookConvert)
return
}
func (c *Converter) ExistSVGO() (err error) {
_, err = exec.LookPath(svgo)
return
}
func (c *Converter) CompressSVGBySVGO(svgFolder string) (err error) {
args := []string{
"-f",
svgFolder,
}
c.logger.Debug("compress svg by svgo", zap.String("cmd", svgo), zap.Strings("args", args))
var out string
out, err = util.ExecCommand(svgo, args, c.timeout*10)
if err != nil {
c.logger.Error("compress svg by svgo", zap.String("cmd", svgo), zap.Strings("args", args), zap.Error(err))
}
c.logger.Debug("compress svg by svgo", zap.String("out", out))
return
}
// CompressSVGByGZIP 将SVG文件压缩为GZIP格式
func (c Converter) CompressSVGByGZIP(svgFile string) (dst string, err error) {
var svgBytes []byte
ext := filepath.Ext(svgFile)
dst = strings.TrimSuffix(svgFile, ext) + ".gzip.svg"
svgBytes, err = os.ReadFile(svgFile)
if err != nil {
c.logger.Error("read svg file", zap.String("svgFile", svgFile), zap.Error(err))
return
}
var buf bytes.Buffer
w := gzip.NewWriter(&buf)
defer w.Close()
w.Write(svgBytes)
w.Flush()
err = os.WriteFile(dst, buf.Bytes(), os.ModePerm)
if err != nil {
c.logger.Error("write svgz file", zap.String("svgzFile", dst), zap.Error(err))
}
return
}

@ -1,6 +1,7 @@
package converter
import (
"os/exec"
"testing"
"time"
@ -196,7 +197,7 @@ func TestConvertCHMToPDF(t *testing.T) {
func TestConvertPDFToSVG(t *testing.T) {
for _, file := range pdfFiles {
pages, err := converter.ConvertPDFToSVG(file, 0, 10000)
pages, err := converter.ConvertPDFToSVG(file, 0, 10000, false, true)
if err != nil {
t.Errorf("ConvertPDFToTxt() error = %v source file = %s", err, file)
return
@ -221,3 +222,25 @@ func TestConvertPDFToPNG(t *testing.T) {
}
}
}
func TestCountPDFPages(t *testing.T) {
for _, file := range pdfFiles {
now := time.Now()
pages, err := converter.CountPDFPages(file)
t.Log(time.Since(now))
if err != nil {
t.Errorf("CountPDFPages() error = %v source file = %s", err, file)
return
}
t.Logf("file = %spages = %d", file, pages)
}
}
func TestExistCommand(t *testing.T) {
t.Logf("calibre= %v", converter.ExistCalibre())
t.Logf("svgo= %v", converter.ExistSVGO())
t.Logf("mupdf= %v", converter.ExistMupdf())
t.Logf("soffice= %v", converter.ExistSoffice())
t.Log(exec.LookPath("soffice1"))
}

@ -10,6 +10,14 @@
</el-tab-pane>
</el-tabs>
<FormConfig :init-configs="configs" />
<el-alert
v-if="activeName == 'converter'"
title="同时启用GZIP和SVGO相对直接的SVG文件总体可以节省85%左右的存储空间。启用SVGO需要全局安装node.js的SVGO模块"
type="info"
:closable="false"
show-icon
>
</el-alert>
</el-card>
</template>
@ -40,6 +48,10 @@ export default {
label: '安全配置',
value: 'security',
},
{
label: '转换配置',
value: 'converter',
},
],
}
},

Loading…
Cancel
Save