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 @@
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
+}