diff --git a/assets/webui/index.html b/assets/webui/index.html index d592e9f..858ab5e 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -10,25 +10,34 @@
- - - +
+ + + +
- - - -
- - 100% - - - - +
+ + + +
+
+ + 100% + + + +
+
+ + + +
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index 7c1108b..779d343 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -32,7 +32,8 @@ body { } button, -input { +input, +select { font: inherit; } @@ -45,13 +46,15 @@ button { } button:disabled, -input:disabled { +input:disabled, +select:disabled { cursor: not-allowed; opacity: 0.48; } button:focus-visible, input:focus-visible, +select:focus-visible, a:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; @@ -149,10 +152,29 @@ button:not(:disabled):hover { flex: 1 1 auto; } -.divider { +.tool-group { + position: relative; + display: inline-flex; + align-items: center; + gap: 8px; + flex: 0 0 auto; + min-width: 0; +} + +.tool-group + .tool-group { + margin-left: 2px; + padding-left: 18px; +} + +.tool-group + .tool-group::before { + position: absolute; + top: 50%; + left: 5px; width: 1px; height: 22px; background: var(--line); + content: ""; + transform: translateY(-50%); } .page-control { @@ -176,6 +198,16 @@ button:not(:disabled):hover { background: transparent; } +.format-select { + height: var(--control-height); + min-width: 64px; + padding: 0 7px; + border: 1px solid var(--line); + border-radius: 0; + background: #ffffff; + color: #1f2428; +} + .zoom-label { min-width: 42px; text-align: center; @@ -842,11 +874,20 @@ body[aria-busy="true"] .viewer-panel { row-gap: 0; } - .divider { + .toolbar-space { display: none; } - .toolbar-space { + .tool-group { + gap: 6px; + } + + .tool-group + .tool-group { + margin-left: 0; + padding-left: 0; + } + + .tool-group + .tool-group::before { display: none; } @@ -894,6 +935,11 @@ body[aria-busy="true"] .viewer-panel { padding-inline: 8px; } + .format-select { + min-width: 56px; + padding: 0 5px; + } + .footer-links { justify-content: flex-end; } diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 5b4e6a9..8b5563c 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -26,11 +26,14 @@ const STATUS = { engine: "正在准备引擎", recovering: "正在恢复引擎", fonts: "正在匹配字体", - exporting: "正在导出 PDF", + exporting: "正在导出文档 PDF", + pageExporting: "正在导出单页", }; const WASM_CALLBACKS = [ "ofdgoOpen", "ofdgoRenderPage", + "ofdgoExportFormats", + "ofdgoExportPage", "ofdgoExportPDF", "ofdgoFontCandidates", ]; @@ -63,6 +66,7 @@ const state = { thumbnailCache: new Map(), thumbnailInFlight: new Set(), thumbnailObserver: null, + exportFormats: [], showPages: !COMPACT_LAYOUT.matches, showMeta: !COMPACT_LAYOUT.matches, }; @@ -84,6 +88,8 @@ const el = { zoomLabel: document.querySelector("#zoomLabel"), fitButton: document.querySelector("#fitButton"), fitHeightButton: document.querySelector("#fitHeightButton"), + pageExportFormat: document.querySelector("#pageExportFormat"), + exportPageButton: document.querySelector("#exportPageButton"), exportButton: document.querySelector("#exportButton"), emptyState: document.querySelector("#emptyState"), progressPanel: document.querySelector("#progressPanel"), @@ -119,6 +125,7 @@ el.zoomOutButton.addEventListener("click", () => setScale(state.scale - 0.1)); el.zoomInButton.addEventListener("click", () => setScale(state.scale + 0.1)); el.fitButton.addEventListener("click", fitWidth); el.fitHeightButton.addEventListener("click", fitHeight); +el.exportPageButton.addEventListener("click", exportCurrentPage); el.exportButton.addEventListener("click", exportPDF); el.pageInput.addEventListener("change", () => { const page = Number.parseInt(el.pageInput.value, 10); @@ -180,6 +187,7 @@ async function boot() { try { await ensureWASM(); setProgress("引擎准备完成", 70); + loadExportFormats(); setEmpty("选择 OFD 文件"); updateFontSummary(); renderFontList(); @@ -195,6 +203,21 @@ async function boot() { } } +function loadExportFormats() { + const formats = callWASM("ofdgoExportFormats") || []; + state.exportFormats = formats; + el.pageExportFormat.replaceChildren(); + for (const format of formats) { + const option = document.createElement("option"); + option.value = format.value; + option.textContent = format.label; + el.pageExportFormat.append(option); + } + if (formats.length) { + el.pageExportFormat.value = formats[0].value; + } +} + async function ensureWASM() { if (state.ready && !state.wasmExited) { return; @@ -858,14 +881,14 @@ async function exportPDF() { return; } const openSeq = state.openSeq; - el.exportButton.disabled = true; - setBusy(true, "正在准备 PDF", 18, STATUS.exporting); + setExportControlsDisabled(true); + setBusy(true, "正在准备文档 PDF", 18, STATUS.exporting); try { await waitForPaint(); if (openSeq !== state.openSeq) { return; } - setProgress("正在生成 PDF", 45); + setProgress("正在生成文档 PDF", 45); await waitForPaint(); if (openSeq !== state.openSeq) { return; @@ -874,18 +897,53 @@ async function exportPDF() { if (openSeq !== state.openSeq) { return; } - setProgress("正在保存 PDF", 86); + setProgress("正在保存文档 PDF", 86); const bytes = base64ToBytes(result.base64); - const blob = new Blob([bytes], { type: "application/pdf" }); - const link = document.createElement("a"); - const url = URL.createObjectURL(blob); - link.href = url; - link.download = pdfFileName(); - document.body.append(link); - link.click(); - link.remove(); - window.setTimeout(() => URL.revokeObjectURL(url), 1000); - setStatus(`PDF 已导出 ${formatBytes(result.size || bytes.length)}`); + downloadBytes(bytes, "application/pdf", pdfFileName()); + setStatus(`文档 PDF 已导出 ${formatBytes(result.size || bytes.length)}`); + } catch (err) { + if (openSeq === state.openSeq) { + showError(err, false); + } + } finally { + if (openSeq === state.openSeq) { + setBusy(false); + updateControls(); + } + } +} + +async function exportCurrentPage() { + if (!state.doc) { + return; + } + const format = el.pageExportFormat.value; + if (!format) { + return; + } + const info = exportFormatInfo(format); + const label = info?.label || String(format || "").toUpperCase(); + const openSeq = state.openSeq; + setExportControlsDisabled(true); + setBusy(true, `正在准备 ${label}`, 18, STATUS.pageExporting); + try { + await waitForPaint(); + if (openSeq !== state.openSeq) { + return; + } + setProgress(`正在生成 ${label}`, 45); + await waitForPaint(); + if (openSeq !== state.openSeq) { + return; + } + const result = callWASM("ofdgoExportPage", state.pageIndex, format); + if (openSeq !== state.openSeq) { + return; + } + setProgress(`正在保存 ${result.label || label}`, 86); + const bytes = base64ToBytes(result.base64); + downloadBytes(bytes, result.mime || info?.mime || "application/octet-stream", pageFileName(result.extension || info?.extension || format)); + setStatus(`${result.label || label} 已导出 ${formatBytes(result.size || bytes.length, result.label || label)}`); } catch (err) { if (openSeq === state.openSeq) { showError(err, false); @@ -1536,9 +1594,17 @@ function updateControls() { el.fitHeightButton.disabled = !hasDoc; el.fitButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "width"); el.fitHeightButton.toggleAttribute("aria-pressed", hasDoc && state.fitMode === "height"); + el.pageExportFormat.disabled = !hasDoc || !state.exportFormats.length; + el.exportPageButton.disabled = !hasDoc || !state.exportFormats.length; el.exportButton.disabled = !hasDoc; } +function setExportControlsDisabled(disabled) { + el.exportButton.disabled = disabled; + el.exportPageButton.disabled = disabled; + el.pageExportFormat.disabled = disabled; +} + function callWASM(name, ...args) { const fn = globalThis[name]; if (typeof fn !== "function") { @@ -1624,15 +1690,39 @@ function base64ToBytes(base64) { return bytes; } -function pdfFileName() { - const base = (state.fileName || state.doc?.title || "ofdgo").replace(/\.[^.]+$/, ""); - const safe = base.replace(/[\\/:*?"<>|]+/g, "_").trim() || "ofdgo"; - return `${safe}.pdf`; +function downloadBytes(bytes, mime, name) { + const blob = new Blob([bytes], { type: mime }); + const link = document.createElement("a"); + const url = URL.createObjectURL(blob); + link.href = url; + link.download = name; + document.body.append(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 1000); } -function formatBytes(size) { +function baseFileName() { + const base = (state.fileName || state.doc?.title || "ofdgo").replace(/\.[^.]+$/, ""); + return base.replace(/[\\/:*?"<>|]+/g, "_").trim() || "ofdgo"; +} + +function pdfFileName() { + return `${baseFileName()}.pdf`; +} + +function pageFileName(extension) { + const page = String(state.pageIndex + 1).padStart(String(state.doc?.pageCount || 1).length, "0"); + return `${baseFileName()}_p${page}.${extension || "bin"}`; +} + +function exportFormatInfo(value) { + return state.exportFormats.find((format) => format.value === value) || null; +} + +function formatBytes(size, fallback = "PDF") { if (!Number.isFinite(size) || size <= 0) { - return "PDF"; + return fallback; } if (size < 1024 * 1024) { return `${Math.round(size / 1024)} KB`; diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index 8dd060c..c000576 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -42,6 +42,8 @@ type apiResult struct { func RunWASM() { registerCallback("ofdgoOpen", openDocument) registerCallback("ofdgoRenderPage", renderPage) + registerCallback("ofdgoExportFormats", exportFormats) + registerCallback("ofdgoExportPage", exportPage) registerCallback("ofdgoExportPDF", exportPDF) registerCallback("ofdgoFontCandidates", fontCandidates) select {} @@ -113,7 +115,38 @@ func renderPage(args []js.Value) (any, error) { return currentSession.RenderPageSVG(args[0].Int()) } -// exportPDF 导出OFD为PDF +// exportFormats 获取导出格式 +// 入参: args 浏览器参数 +// 返回: any 导出格式, error 错误信息 +func exportFormats(args []js.Value) (any, error) { + return ExportFormats(), nil +} + +// exportPage 导出OFD单页 +// 入参: args 浏览器参数 +// 返回: any 导出结果, error 错误信息 +func exportPage(args []js.Value) (any, error) { + if currentSession == nil { + return nil, fmt.Errorf("ofd document is not opened") + } + if len(args) < 2 { + return nil, fmt.Errorf("missing export page arguments") + } + data, format, err := currentSession.ExportPage(args[0].Int(), args[1].String()) + if err != nil { + return nil, err + } + return map[string]any{ + "base64": base64.StdEncoding.EncodeToString(data), + "size": len(data), + "format": format.Value, + "label": format.Label, + "extension": format.Extension, + "mime": format.MIME, + }, nil +} + +// exportPDF 导出OFD文档为PDF // 入参: args 浏览器参数 // 返回: any PDF结果, error 错误信息 func exportPDF(args []js.Value) (any, error) { @@ -124,7 +157,10 @@ func exportPDF(args []js.Value) (any, error) { if err != nil { return nil, err } - return map[string]any{"base64": base64.StdEncoding.EncodeToString(data), "size": len(data)}, nil + return map[string]any{ + "base64": base64.StdEncoding.EncodeToString(data), + "size": len(data), + }, nil } // fontCandidates 获取字体候选名称 diff --git a/internal/webui/webui.go b/internal/webui/webui.go index 6977458..f1701e4 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -17,6 +17,12 @@ package webui import ( "bytes" "fmt" + "image" + "image/color" + "image/draw" + "image/jpeg" + "image/png" + "strings" "github.com/xiaoqidun/ofdgo" ) @@ -72,6 +78,47 @@ type PageSVG struct { SVG string `json:"svg"` } +// ExportFormat 导出格式 +type ExportFormat struct { + Value string `json:"value"` + Label string `json:"label"` + Extension string `json:"extension"` + MIME string `json:"mime"` +} + +// supportedExportFormats 导出格式列表 +var supportedExportFormats = []ExportFormat{ + {Value: "svg", Label: "SVG", Extension: "svg", MIME: "image/svg+xml"}, + {Value: "pdf", Label: "PDF", Extension: "pdf", MIME: "application/pdf"}, + {Value: "eps", Label: "EPS", Extension: "eps", MIME: "application/postscript"}, + {Value: "png", Label: "PNG", Extension: "png", MIME: "image/png"}, + {Value: "jpg", Label: "JPEG", Extension: "jpg", MIME: "image/jpeg"}, +} + +// ExportFormats 获取导出格式 +// 返回: []ExportFormat 导出格式列表 +func ExportFormats() []ExportFormat { + formats := make([]ExportFormat, len(supportedExportFormats)) + copy(formats, supportedExportFormats) + return formats +} + +// exportFormat 获取导出格式 +// 入参: value 格式值 +// 返回: ExportFormat 导出格式, bool 是否支持 +func exportFormat(value string) (ExportFormat, bool) { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "jpeg" { + value = "jpg" + } + for _, format := range supportedExportFormats { + if format.Value == value { + return format, true + } + } + return ExportFormat{}, false +} + // Open 打开浏览器内存中的OFD文档 // 入参: data OFD文件数据, opts 打开选项 // 返回: *Session 文档会话, error 错误信息 @@ -146,14 +193,7 @@ func (s *Session) Info() DocumentInfo { // 入参: index 页面索引 // 返回: PageSVG 页面SVG结果, error 错误信息 func (s *Session) RenderPageSVG(index int) (PageSVG, error) { - if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil { - return PageSVG{}, fmt.Errorf("ofd document is not opened") - } - if index < 0 || index >= len(s.doc.Pages.Page) { - return PageSVG{}, fmt.Errorf("page index %d out of range", index) - } - pageRef := s.doc.Pages.Page[index] - page, err := s.Reader.PageContent(pageRef) + pageRef, page, err := s.pageContent(index) if err != nil { return PageSVG{}, err } @@ -168,7 +208,46 @@ func (s *Session) RenderPageSVG(index int) (PageSVG, error) { return PageSVG{Index: index, Number: index + 1, ID: pageRef.ID, Width: box.W, Height: box.H, SVG: buf.String()}, nil } -// ExportPDF 导出整个文档为PDF +// ExportPage 导出单页 +// 入参: index 页面索引, value 导出格式 +// 返回: []byte 文件数据, ExportFormat 导出格式, error 错误信息 +func (s *Session) ExportPage(index int, value string) ([]byte, ExportFormat, error) { + format, ok := exportFormat(value) + if !ok { + return nil, ExportFormat{}, fmt.Errorf("unsupported export format %s", value) + } + _, page, err := s.pageContent(index) + if err != nil { + return nil, ExportFormat{}, err + } + var buf bytes.Buffer + switch format.Value { + case "svg": + err = s.Renderer.RenderToSVG(page, &buf) + case "pdf": + err = s.Renderer.RenderToPDF(page, &buf) + case "eps": + err = s.Renderer.RenderToEPS(page, &buf) + case "png": + img, renderErr := s.Renderer.RenderToImage(page) + if renderErr != nil { + return nil, ExportFormat{}, renderErr + } + err = png.Encode(&buf, img) + case "jpg": + img, renderErr := s.Renderer.RenderToImage(page) + if renderErr != nil { + return nil, ExportFormat{}, renderErr + } + err = jpeg.Encode(&buf, imageWithWhiteBackground(img), &jpeg.Options{Quality: 95}) + } + if err != nil { + return nil, ExportFormat{}, err + } + return buf.Bytes(), format, nil +} + +// ExportPDF 导出文档为PDF // 返回: []byte PDF文件数据, error 错误信息 func (s *Session) ExportPDF() ([]byte, error) { if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil { @@ -180,3 +259,32 @@ func (s *Session) ExportPDF() ([]byte, error) { } return buf.Bytes(), nil } + +// imageWithWhiteBackground 填充图片白色背景 +// 入参: img 图片对象 +// 返回: image.Image 图片对象 +func imageWithWhiteBackground(img image.Image) image.Image { + bounds := img.Bounds() + dst := image.NewRGBA(bounds) + draw.Draw(dst, bounds, &image.Uniform{C: color.White}, image.Point{}, draw.Src) + draw.Draw(dst, bounds, img, bounds.Min, draw.Over) + return dst +} + +// pageContent 获取页面内容 +// 入参: index 页面索引 +// 返回: ofdgo.Page 页面引用, *ofdgo.PageContent 页面内容, error 错误信息 +func (s *Session) pageContent(index int) (ofdgo.Page, *ofdgo.PageContent, error) { + if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil { + return ofdgo.Page{}, nil, fmt.Errorf("ofd document is not opened") + } + if index < 0 || index >= len(s.doc.Pages.Page) { + return ofdgo.Page{}, nil, fmt.Errorf("page index %d out of range", index) + } + pageRef := s.doc.Pages.Page[index] + page, err := s.Reader.PageContent(pageRef) + if err != nil { + return ofdgo.Page{}, nil, err + } + return pageRef, page, nil +}