feat(前端界面): OFDGo WebUI支持单页输出
build.yaml / build (push) Successful in 33s

This commit is contained in:
2026-07-07 10:45:17 +08:00
parent 2fc360e048
commit db860a22f1
5 changed files with 343 additions and 54 deletions
+26 -17
View File
@@ -10,25 +10,34 @@
<div class="app">
<header class="toolbar app-bar">
<input id="ofdInput" class="file-input" type="file" accept=".ofd,application/ofd,application/zip">
<button id="ofdButton" class="button primary" type="button" data-short="打开">打开</button>
<input id="fontInput" class="file-input" type="file" accept=".ttf,.otf,.ttc,font/*" multiple>
<button id="togglePagesButton" class="icon-button" type="button" title="页面栏" aria-label="页面栏" aria-pressed="true">&#9776;</button>
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" aria-label="信息栏" aria-pressed="true">&#9432;</button>
<div class="tool-group">
<button id="ofdButton" class="button primary" type="button" data-short="打开">打开</button>
<button id="togglePagesButton" class="icon-button" type="button" title="页面栏" aria-label="页面栏" aria-pressed="true">&#9776;</button>
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" aria-label="信息栏" aria-pressed="true">&#9432;</button>
</div>
<div class="toolbar-space"></div>
<button id="prevButton" class="icon-button" type="button" title="上一页" disabled>&lsaquo;</button>
<label class="page-control">
<input id="pageInput" type="number" min="1" value="1" disabled>
<span>/</span>
<span id="pageTotal">0</span>
</label>
<button id="nextButton" class="icon-button" type="button" title="下一页" disabled>&rsaquo;</button>
<div class="divider"></div>
<button id="zoomOutButton" class="icon-button" type="button" title="缩小" disabled>-</button>
<span id="zoomLabel" class="zoom-label">100%</span>
<button id="zoomInButton" class="icon-button" type="button" title="放大" disabled>+</button>
<button id="fitButton" class="button" type="button" data-short="宽" disabled>适宽</button>
<button id="fitHeightButton" class="button" type="button" data-short="" disabled>适高</button>
<button id="exportButton" class="button" type="button" data-short="PDF" title="导出 PDF" aria-label="导出 PDF" disabled>PDF</button>
<div class="tool-group">
<button id="prevButton" class="icon-button" type="button" title="上一页" disabled>&lsaquo;</button>
<label class="page-control">
<input id="pageInput" type="number" min="1" value="1" disabled>
<span>/</span>
<span id="pageTotal">0</span>
</label>
<button id="nextButton" class="icon-button" type="button" title="下一页" disabled>&rsaquo;</button>
</div>
<div class="tool-group">
<button id="zoomOutButton" class="icon-button" type="button" title="缩小" disabled>-</button>
<span id="zoomLabel" class="zoom-label">100%</span>
<button id="zoomInButton" class="icon-button" type="button" title="放大" disabled>+</button>
<button id="fitButton" class="button" type="button" data-short="" disabled>适宽</button>
<button id="fitHeightButton" class="button" type="button" data-short="高" disabled>适高</button>
</div>
<div class="tool-group">
<select id="pageExportFormat" class="format-select" title="单页格式" aria-label="单页格式" disabled></select>
<button id="exportPageButton" class="button" type="button" data-short="页" title="导出单页" aria-label="导出单页" disabled>单页</button>
<button id="exportButton" class="button" type="button" data-short="文档" title="导出文档 PDF" aria-label="导出文档 PDF" disabled>文档</button>
</div>
</header>
<main class="workspace">
+51 -5
View File
@@ -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;
}
+111 -21
View File
@@ -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`;
+38 -2
View File
@@ -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 获取字体候选名称
+117 -9
View File
@@ -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
}