mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 13:56:32 +08:00
1 parent
d37f41cd90
commit
0385e8c5fd
7 files changed
+625
-28
No files matched your search
+12
-2
@@ -594,10 +594,20 @@
|
||||
</section>
|
||||
<div class="panel-row">
|
||||
<div class="panel-title compact">文档</div>
|
||||
<button id="infoButton" class="small-button" type="button" aria-label="修改文档信息" aria-haspopup="dialog" aria-controls="infoPanel" hidden>修改</button>
|
||||
<div id="documentActions" class="panel-actions" hidden>
|
||||
<button id="documentAddButton" class="small-button" type="button" title="新建文档" aria-haspopup="dialog" aria-controls="createPanel">新建</button>
|
||||
<button id="infoButton" class="small-button" type="button" title="修改文档" aria-label="修改文档信息" aria-haspopup="dialog" aria-controls="infoPanel" hidden>修改</button>
|
||||
<button id="documentDeleteButton" class="small-button" type="button" title="删除文档">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="editNotice" class="font-permission-hint" role="status" hidden></div>
|
||||
<select id="documentSelect" class="document-select" aria-label="选择文档" title="切换文档" hidden></select>
|
||||
<div id="documentRow" class="document-row" hidden>
|
||||
<select id="documentSelect" class="document-select" aria-label="选择文档" title="切换文档"></select>
|
||||
<div id="documentOrder" class="panel-actions" hidden>
|
||||
<button id="documentUpButton" class="small-button" type="button" title="前移文档" aria-label="前移文档">↑</button>
|
||||
<button id="documentDownButton" class="small-button" type="button" title="后移文档" aria-label="后移文档">↓</button>
|
||||
</div>
|
||||
</div>
|
||||
<dl class="meta-list">
|
||||
<div>
|
||||
<dt>文件</dt>
|
||||
|
||||
+21
-2
@@ -1516,11 +1516,28 @@ body[data-hide-meta] .meta-panel {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.document-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.document-row .panel-actions {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.document-row .small-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.document-select {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
width: 0;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
margin-bottom: 10px;
|
||||
padding: 0 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -1914,6 +1931,8 @@ button.outline-link:hover {
|
||||
.editor-group > [hidden],
|
||||
.panel-row > [hidden],
|
||||
.panel-actions > [hidden],
|
||||
.document-row[hidden],
|
||||
.document-row > [hidden],
|
||||
.meta-list > [hidden],
|
||||
.navigation-tabs[hidden],
|
||||
.page-selection-tools[hidden],
|
||||
|
||||
+77
-6
@@ -59,6 +59,7 @@ const state = {
|
||||
importing: false,
|
||||
imageImporting: false,
|
||||
createInsert: false,
|
||||
createPackage: false,
|
||||
pageSelection: new Set(),
|
||||
pageSelectionAnchor: null,
|
||||
pageMultiSelect: false,
|
||||
@@ -233,6 +234,13 @@ const el = {
|
||||
paragraphButton: document.querySelector("#paragraphButton"),
|
||||
infoButton: document.querySelector("#infoButton"),
|
||||
documentSelect: document.querySelector("#documentSelect"),
|
||||
documentActions: document.querySelector("#documentActions"),
|
||||
documentRow: document.querySelector("#documentRow"),
|
||||
documentOrder: document.querySelector("#documentOrder"),
|
||||
documentAddButton: document.querySelector("#documentAddButton"),
|
||||
documentDeleteButton: document.querySelector("#documentDeleteButton"),
|
||||
documentUpButton: document.querySelector("#documentUpButton"),
|
||||
documentDownButton: document.querySelector("#documentDownButton"),
|
||||
infoPanel: document.querySelector("#infoPanel"),
|
||||
infoForm: document.querySelector("#infoForm"),
|
||||
infoTitle: document.querySelector("#infoTitle"),
|
||||
@@ -670,6 +678,18 @@ editorClick(el.paragraphButton, () => {
|
||||
el.paragraphPanel.showModal();
|
||||
});
|
||||
el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close());
|
||||
editorClick(el.documentAddButton, () => openCreatePanel(false, true));
|
||||
editorClick(el.documentDeleteButton, () => {
|
||||
if (window.confirm("删除当前文档?")) return changeDocument("ofdgoChangeDocument", null, "delete", state.doc.documentIndex || 0);
|
||||
});
|
||||
editorClick(el.documentUpButton, () => {
|
||||
const index = state.doc.documentIndex || 0;
|
||||
return changeDocument("ofdgoChangeDocument", null, "move", index, index - 1);
|
||||
});
|
||||
editorClick(el.documentDownButton, () => {
|
||||
const index = state.doc.documentIndex || 0;
|
||||
return changeDocument("ofdgoChangeDocument", null, "move", index, index + 1);
|
||||
});
|
||||
editorClick(el.infoButton, () => {
|
||||
for (const key of ["Title", "Author", "Subject"]) el[`info${key}`].value = state.doc[key.toLowerCase()] || "";
|
||||
el.infoCustomRows.replaceChildren();
|
||||
@@ -1956,6 +1976,8 @@ async function switchDocument(index) {
|
||||
resetCompositeScope();
|
||||
canvasEditor.clear();
|
||||
canvasEditor.setTool("");
|
||||
delete state.textDefaults.fontChoice;
|
||||
updateTextFonts(null, true);
|
||||
state.ofdBytes = null;
|
||||
setEditorInfo(doc);
|
||||
await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: true, clearSelection: true, fitMode: state.fitMode, scale: state.scale });
|
||||
@@ -2058,12 +2080,13 @@ function confirmTextReflow(item) {
|
||||
|| window.confirm("将重新排版原文,是否继续?"));
|
||||
}
|
||||
|
||||
function openCreatePanel(insert = false) {
|
||||
function openCreatePanel(insert = false, packageDocument = false) {
|
||||
if (document.body.hasAttribute("aria-busy")) {
|
||||
return;
|
||||
}
|
||||
el.createForm.reset();
|
||||
state.createInsert = insert === true;
|
||||
state.createPackage = packageDocument === true;
|
||||
el.createMode.value = state.createInsert ? "images" : "blank";
|
||||
el.createSubmit.textContent = state.createInsert ? "插入" : "新建";
|
||||
el.createPanel.setAttribute("aria-label", state.createInsert ? "插入图片" : "新建文档");
|
||||
@@ -2076,7 +2099,7 @@ function openCreatePanel(insert = false) {
|
||||
function updateCreateOptions() {
|
||||
const images = el.createMode.value === "images", busy = Boolean(state.imageImporting);
|
||||
const toggle = (id, visible) => { document.getElementById(id).hidden = !visible; };
|
||||
toggle("createModeRow", !state.createInsert);
|
||||
toggle("createModeRow", !state.createInsert && !state.createPackage);
|
||||
el.createName.closest(".form-row").hidden = Boolean(state.createInsert);
|
||||
el.createName.disabled = busy || Boolean(state.createInsert);
|
||||
el.createMode.disabled = busy;
|
||||
@@ -2105,6 +2128,11 @@ function imagePageRequest() {
|
||||
|
||||
async function createDocument(event) {
|
||||
event.preventDefault();
|
||||
if (state.createPackage) {
|
||||
if (await changeDocument("ofdgoChangeDocument", null, "add", el.createName.value.trim() || "未命名",
|
||||
Number(el.createWidth.value), Number(el.createHeight.value), (state.doc.documentIndex || 0) + 1)) el.createPanel.close();
|
||||
return;
|
||||
}
|
||||
if (state.createInsert) {
|
||||
const at = { before: state.pageIndex, after: state.pageIndex + 1, first: 0, last: state.doc.pageCount }[el.createPosition.value];
|
||||
if (await changeDocument("ofdgoImportImages", null, ...imagePageRequest(), at, "", state.renderAnnotations)) el.createPanel.close();
|
||||
@@ -2888,6 +2916,36 @@ async function changeDocument(name, item, ...args) {
|
||||
if (doc.revision === state.editorInfo?.revision) {
|
||||
return true;
|
||||
}
|
||||
const previousDocument = state.doc.documents?.[state.doc.documentIndex || 0];
|
||||
const nextDocument = doc.documents?.[doc.documentIndex || 0];
|
||||
const documentChanged = previousDocument?.root !== nextDocument?.root || previousDocument?.id !== nextDocument?.id;
|
||||
const packageChanged = documentChanged || name === "ofdgoChangeDocument" || restoring
|
||||
&& JSON.stringify(doc.documents?.map(entry => [entry.root, entry.id])) !== JSON.stringify(state.doc.documents?.map(entry => [entry.root, entry.id]));
|
||||
if (packageChanged) {
|
||||
resetCompositeScope();
|
||||
state.objectClipboard = state.styleClipboard = null;
|
||||
state.pageSelection.clear();
|
||||
state.pageSelectionAnchor = null;
|
||||
state.pageMultiSelect = false;
|
||||
state.outlineSelection = null;
|
||||
state.outlineExpanded.clear();
|
||||
canvasEditor.clear();
|
||||
canvasEditor.setTool("");
|
||||
if (documentChanged) {
|
||||
delete state.textDefaults.fontChoice;
|
||||
updateTextFonts(null, true);
|
||||
}
|
||||
state.ofdBytes = null;
|
||||
setEditorInfo(doc);
|
||||
openSeq = ++state.openSeq;
|
||||
await openDocument({ doc, openSeq, skipAutoFonts: true, resetScroll: documentChanged, clearSelection: true,
|
||||
pageIndex: documentChanged ? 0 : state.pageIndex, fitMode: state.fitMode, scale: state.scale });
|
||||
if (openSeq === state.openSeq) {
|
||||
if (!restoring) rememberEditorView(before, revision);
|
||||
return true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
const view = name === "ofdgoUndo" ? state.editorViews.get(revision) : name === "ofdgoRedo" ? state.editorViews.get(doc.revision) : null;
|
||||
const reindex = !!scope && (scoped && ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoCopyObjects", "ofdgoOrderObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath", "ofdgoGroupObjects"].includes(name)
|
||||
|| inserting || name === "ofdgoPasteObjects" && !!args[4]);
|
||||
@@ -2907,7 +2965,8 @@ async function changeDocument(name, item, ...args) {
|
||||
}
|
||||
setEditorInfo(doc);
|
||||
if (name === "ofdgoUpdateInfo") {
|
||||
Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject, customData: doc.customData });
|
||||
Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject, customData: doc.customData,
|
||||
documents: doc.documents, documentIndex: doc.documentIndex });
|
||||
renderMeta();
|
||||
updateControls();
|
||||
rememberEditorView(before, revision);
|
||||
@@ -7423,7 +7482,7 @@ function renderMeta(keepDetails = false) {
|
||||
}));
|
||||
});
|
||||
const doc = state.doc || {};
|
||||
el.documentSelect.hidden = (doc.documents?.length || 0) < 2;
|
||||
updateDocumentControls();
|
||||
renderMetaContent(el.documentSelect, doc.documents || [], () => {
|
||||
el.documentSelect.replaceChildren(...(doc.documents || []).map(entry => {
|
||||
const option = document.createElement("option");
|
||||
@@ -8245,12 +8304,24 @@ function updateFitSpace() {
|
||||
el.pageFrame.style.setProperty("--fit-gap", `${gap}px`);
|
||||
}
|
||||
|
||||
function updateDocumentControls() {
|
||||
const count = state.doc?.documents?.length || 0, index = state.doc?.documentIndex || 0;
|
||||
const disabled = !state.doc || !state.ready || state.exporting || document.body.hasAttribute("aria-busy");
|
||||
el.documentActions.hidden = el.documentOrder.hidden = !state.editing;
|
||||
el.documentRow.hidden = !count || !state.editing && count < 2;
|
||||
el.documentSelect.disabled = disabled;
|
||||
el.documentAddButton.disabled = el.infoButton.disabled = disabled || !state.editing;
|
||||
el.documentDeleteButton.disabled = disabled || !state.editing || count <= 1;
|
||||
el.documentUpButton.disabled = disabled || !state.editing || index === 0;
|
||||
el.documentDownButton.disabled = disabled || !state.editing || index >= count - 1;
|
||||
}
|
||||
|
||||
function updateControls() {
|
||||
renderSecurity();
|
||||
el.attachmentAdd.disabled = !state.editing || !state.ready || state.exporting;
|
||||
renderMetaContent(el.attachmentList, [state.doc?.attachments, state.doc?.attachmentError, state.editing], renderAttachments);
|
||||
const hasDoc = Boolean(state.doc);
|
||||
el.documentSelect.disabled = !hasDoc || !state.ready || state.exporting || document.body.hasAttribute("aria-busy");
|
||||
updateDocumentControls();
|
||||
updateDisplayControls();
|
||||
updateEditorTools();
|
||||
const pageCount = state.doc ? state.doc.pageCount : 0;
|
||||
@@ -8454,7 +8525,7 @@ function showError(err, empty = !state.doc) {
|
||||
|
||||
function setBusy(busy, text = "", percent = 0, status = "") {
|
||||
document.body.toggleAttribute("aria-busy", busy);
|
||||
el.documentSelect.disabled = busy || !state.doc || !state.ready || state.exporting;
|
||||
updateDocumentControls();
|
||||
renderSecurity();
|
||||
updateDisplayControls();
|
||||
el.editButton.disabled = busy || Boolean(sealPreview) || !state.doc || !state.ready || state.exporting;
|
||||
|
||||
+49
-1
@@ -185,6 +185,7 @@ func (r *browserReaderAt) ReadAt(data []byte, offset int64) (int, error) {
|
||||
func RunWASM() {
|
||||
registerCallback("ofdgoOpen", openDocument)
|
||||
registerCallback("ofdgoSelectDocument", selectDocument)
|
||||
registerCallback("ofdgoChangeDocument", changePackageDocument)
|
||||
registerAsyncCallback("ofdgoConvertPDF", convertPDFDocument)
|
||||
registerAsyncCallback("ofdgoConvertFile", convertFile)
|
||||
registerAsyncCallback("ofdgoPackFiles", packFiles)
|
||||
@@ -1153,7 +1154,7 @@ func editorSummary() editorInfo {
|
||||
if !currentSession.doc.Permissions.Edit {
|
||||
info.EditWarnings = append(info.EditWarnings, "原文件声明不允许编辑")
|
||||
}
|
||||
if currentSession.doc.Signatures != "" {
|
||||
if slices.ContainsFunc(currentSession.Reader.OFD.DocBody, func(body ofdgo.DocBody) bool { return body.Signatures != "" }) {
|
||||
info.EditWarnings = append(info.EditWarnings, "修改文档可能使原签名失效")
|
||||
}
|
||||
info.PageCapabilities = make([]uint, currentEditor.PageCount())
|
||||
@@ -3360,6 +3361,47 @@ func changeAttachment(args []js.Value) (any, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// changePackageDocument 调用库层文档增删、改名或排序,不在浏览器内重写包结构
|
||||
// 入参: args 操作及标题、页面尺寸、文档索引或移动位置
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func changePackageDocument(args []js.Value) (any, error) {
|
||||
if currentEditor == nil || len(args) == 0 {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
}
|
||||
return changeAtomicObjects(func() error {
|
||||
var err error
|
||||
switch args[0].String() {
|
||||
case "add":
|
||||
if len(args) != 5 {
|
||||
return fmt.Errorf("invalid document arguments")
|
||||
}
|
||||
_, err = currentEditor.AddDocument(args[1].String(), args[2].Float(), args[3].Float(), args[4].Int())
|
||||
case "delete":
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("invalid document arguments")
|
||||
}
|
||||
err = currentEditor.DeleteDocument(args[1].Int())
|
||||
case "move":
|
||||
if len(args) != 3 {
|
||||
return fmt.Errorf("invalid document arguments")
|
||||
}
|
||||
err = currentEditor.MoveDocument(args[1].Int(), args[2].Int())
|
||||
case "rename":
|
||||
if len(args) != 3 {
|
||||
return fmt.Errorf("invalid document arguments")
|
||||
}
|
||||
err = currentEditor.RenameDocument(args[1].Int(), args[2].String())
|
||||
default:
|
||||
return fmt.Errorf("invalid document operation")
|
||||
}
|
||||
if err == nil {
|
||||
copiedObjects, copiedStyle = nil, nil
|
||||
clearImport()
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
// selectDocument 切换包内文档,保留编辑快照并清除跨文档剪贴板
|
||||
// 入参: args 文档索引
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
@@ -3457,6 +3499,12 @@ func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) {
|
||||
}
|
||||
editor.SetFontFS()
|
||||
if currentSession != nil {
|
||||
previous := currentSession.Reader
|
||||
oldBody, newBody := previous.OFD.DocBody[previous.DocumentIndex()], reader.OFD.DocBody[reader.DocumentIndex()]
|
||||
if oldBody.DocRoot != newBody.DocRoot || oldBody.DocInfo.DocID != newBody.DocInfo.DocID {
|
||||
copiedObjects, copiedStyle = nil, nil
|
||||
clearImport()
|
||||
}
|
||||
ofdgo.WithRenderBackends(currentSession.Renderer.Backends())(session.Renderer)
|
||||
if currentSession.fontFS != nil {
|
||||
session.fontFS = currentSession.fontFS
|
||||
|
||||
@@ -102,6 +102,8 @@ type DocumentInfo struct {
|
||||
type DocumentEntry struct {
|
||||
Index int `json:"index"`
|
||||
Title string `json:"title"`
|
||||
Root string `json:"root"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
// DocumentDetails 不含页面数组的文档补充信息
|
||||
@@ -433,7 +435,7 @@ func (s *Session) Summary() DocumentInfo {
|
||||
DetailsPending: true,
|
||||
}
|
||||
for index, body := range s.Reader.OFD.DocBody {
|
||||
info.Documents = append(info.Documents, DocumentEntry{Index: index, Title: body.DocInfo.Title})
|
||||
info.Documents = append(info.Documents, DocumentEntry{Index: index, Title: body.DocInfo.Title, Root: body.DocRoot, ID: body.DocInfo.DocID})
|
||||
}
|
||||
if docInfo, err := s.Reader.DocInfo(); err == nil && docInfo != nil {
|
||||
info.Title = docInfo.Title
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DocumentCount 获取编辑包内的文档数量,新建编辑器包含一个当前文档
|
||||
// 返回: int 文档数量
|
||||
func (e *Editor) DocumentCount() int {
|
||||
if e.source == nil {
|
||||
return 1
|
||||
}
|
||||
return e.source.reader.DocumentCount()
|
||||
}
|
||||
|
||||
// DocumentIndex 获取当前编辑文档索引,从0开始
|
||||
// 返回: int 文档索引
|
||||
func (e *Editor) DocumentIndex() int {
|
||||
if e.source == nil {
|
||||
return 0
|
||||
}
|
||||
return e.source.reader.DocumentIndex()
|
||||
}
|
||||
|
||||
// AddDocument 在包内插入含一页的空白文档并选中,不修改输入包
|
||||
// 当前文档的修改保留,新文档使用独立目录和文档标识;可通过Undo撤销
|
||||
// 入参: title 文档标题, width 页面宽度, height 页面高度, at 插入位置,从0开始,可等于文档数量
|
||||
// 返回: int 新文档索引, error 创建错误
|
||||
func (e *Editor) AddDocument(title string, width, height float64, at int) (int, error) {
|
||||
if at < 0 || at > e.DocumentCount() {
|
||||
return 0, fmt.Errorf("document index %d out of range", at)
|
||||
}
|
||||
created := NewEditor()
|
||||
created.Info.Title = title
|
||||
if _, err := created.AddPage(width, height); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
err := e.editDocuments(func(reader *Reader, data []byte, root *editorXML) (int, error) {
|
||||
directory := ""
|
||||
for number := 0; ; number++ {
|
||||
candidate := fmt.Sprintf("Doc_%d", number)
|
||||
occupied := false
|
||||
for name := range reader.fileNamesFold {
|
||||
if name == strings.ToLower(candidate) || strings.HasPrefix(name, strings.ToLower(candidate)+"/") {
|
||||
occupied = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !occupied {
|
||||
directory = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
createdReader, err := created.Reader()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for name, content := range createdReader.files {
|
||||
if name == "OFD.xml" {
|
||||
continue
|
||||
}
|
||||
reader.files[directory+strings.TrimPrefix(name, "Doc_0")] = content
|
||||
}
|
||||
body, err := encodeOFDXML(func(x *ofdXML) {
|
||||
x.root("DocBody", nil)
|
||||
x.start("DocInfo", nil)
|
||||
for _, field := range [][2]string{{"DocID", created.Info.DocID}, {"Title", title}, {"CreationDate", created.Info.CreationDate}, {"Creator", ofdCreator}} {
|
||||
if field[1] != "" {
|
||||
x.text(field[0], field[1])
|
||||
}
|
||||
}
|
||||
x.end("DocInfo")
|
||||
x.text("DocRoot", directory+"/Document.xml")
|
||||
x.end("DocBody")
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
position := root.close
|
||||
if existing := root.childAt("DocBody", at); existing != nil {
|
||||
position = existing.start
|
||||
}
|
||||
reader.files[reader.fileNamesFold["ofd.xml"]] = editorPatchXML(data, []editorXMLPatch{{position, position, bytes.TrimPrefix(body, []byte(xml.Header))}})
|
||||
return at, nil
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return at, nil
|
||||
}
|
||||
|
||||
// RenameDocument 修改指定文档标题,保留其余元数据和内容,可通过Undo撤销
|
||||
// 入参: index 文档索引,从0开始, title 标题,空值移除标题
|
||||
// 返回: error 修改错误
|
||||
func (e *Editor) RenameDocument(index int, title string) error {
|
||||
if index < 0 || index >= e.DocumentCount() {
|
||||
return fmt.Errorf("document index %d out of range", index)
|
||||
}
|
||||
if index == e.DocumentIndex() {
|
||||
info := e.Info
|
||||
info.Title = title
|
||||
e.SetInfo(info)
|
||||
return nil
|
||||
}
|
||||
if e.source.reader.OFD.DocBody[index].DocInfo.Title == title {
|
||||
return nil
|
||||
}
|
||||
return e.editDocuments(func(reader *Reader, data []byte, _ *editorXML) (int, error) {
|
||||
data, info, err := editorDocInfoXML(data, NewEditor().Info.DocID, index)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
reader.files[reader.fileNamesFold["ofd.xml"]] = editorXMLSetText(data, info, [][2]string{{"Title", title}})
|
||||
return reader.DocumentIndex(), nil
|
||||
})
|
||||
}
|
||||
|
||||
// MoveDocument 调整包内文档顺序,当前文档随条目移动,不重写内容,可通过Undo撤销
|
||||
// 入参: from 原文档索引, to 目标索引,均从0开始
|
||||
// 返回: error 移动错误
|
||||
func (e *Editor) MoveDocument(from, to int) error {
|
||||
if from < 0 || from >= e.DocumentCount() || to < 0 || to >= e.DocumentCount() {
|
||||
return fmt.Errorf("document index out of range")
|
||||
}
|
||||
if from == to {
|
||||
return nil
|
||||
}
|
||||
return e.editDocuments(func(reader *Reader, data []byte, root *editorXML) (int, error) {
|
||||
bodies := make([][]byte, reader.DocumentCount())
|
||||
for index := range bodies {
|
||||
body := root.childAt("DocBody", index)
|
||||
bodies[index] = data[body.start:body.end]
|
||||
}
|
||||
moveEditorItem(bodies, from, to)
|
||||
var patches []editorXMLPatch
|
||||
for index, content := range bodies {
|
||||
body := root.childAt("DocBody", index)
|
||||
patches = append(patches, editorXMLPatch{body.start, body.end, content})
|
||||
}
|
||||
reader.files[reader.fileNamesFold["ofd.xml"]] = editorPatchXML(data, patches)
|
||||
selected := reader.DocumentIndex()
|
||||
if selected == from {
|
||||
selected = to
|
||||
} else if from < selected && to >= selected {
|
||||
selected--
|
||||
} else if from > selected && to <= selected {
|
||||
selected++
|
||||
}
|
||||
return selected, nil
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteDocument 删除指定文档并清理独占文件,保留其他文档的共享资源,可通过Undo撤销
|
||||
// 包内至少保留一个文档;删除当前文档时选中后一个,末尾则选中前一个
|
||||
// 原签名保留,修改根索引或受保护文件可能使原签名失效
|
||||
// 入参: index 文档索引,从0开始
|
||||
// 返回: error 删除错误
|
||||
func (e *Editor) DeleteDocument(index int) error {
|
||||
if index < 0 || index >= e.DocumentCount() {
|
||||
return fmt.Errorf("document index %d out of range", index)
|
||||
}
|
||||
if e.DocumentCount() == 1 {
|
||||
return fmt.Errorf("package must contain at least one document")
|
||||
}
|
||||
return e.editDocuments(func(reader *Reader, data []byte, root *editorXML) (int, error) {
|
||||
removed, err := editorDocumentFiles(reader, index)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
directory := path.Dir(cleanPackagePath(reader.OFD.DocBody[index].DocRoot))
|
||||
if directory != "." && !slices.ContainsFunc(reader.OFD.DocBody, func(body DocBody) bool {
|
||||
name := cleanPackagePath(body.DocRoot)
|
||||
return name != cleanPackagePath(reader.OFD.DocBody[index].DocRoot) && strings.HasPrefix(strings.ToLower(name), strings.ToLower(directory)+"/")
|
||||
}) {
|
||||
for name := range reader.fileNamesFold {
|
||||
if strings.HasPrefix(name, strings.ToLower(directory)+"/") {
|
||||
removed[name] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for other := range reader.OFD.DocBody {
|
||||
if other == index {
|
||||
continue
|
||||
}
|
||||
kept, err := editorDocumentFiles(reader, other)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for name := range kept {
|
||||
delete(removed, name)
|
||||
}
|
||||
}
|
||||
delete(removed, "ofd.xml")
|
||||
for name := range reader.files {
|
||||
if removed[strings.ToLower(name)] {
|
||||
delete(reader.files, name)
|
||||
}
|
||||
}
|
||||
if reader.Zip != nil {
|
||||
reader.Zip = &zip.Reader{Comment: reader.Zip.Comment, File: slices.DeleteFunc(slices.Clone(reader.Zip.File), func(file *zip.File) bool {
|
||||
return removed[strings.ToLower(cleanPackagePath(file.Name))]
|
||||
})}
|
||||
}
|
||||
body := root.childAt("DocBody", index)
|
||||
reader.files[reader.fileNamesFold["ofd.xml"]] = editorPatchXML(data, []editorXMLPatch{{start: body.start, end: body.end}})
|
||||
selected := reader.DocumentIndex()
|
||||
if selected > index {
|
||||
selected--
|
||||
} else if selected == index {
|
||||
selected = min(index, reader.DocumentCount()-2)
|
||||
}
|
||||
return selected, nil
|
||||
})
|
||||
}
|
||||
|
||||
// editDocuments 在独立包快照中修改文档入口,失败时不改变当前状态或历史
|
||||
// 入参: edit 修改包快照并返回新的当前文档索引
|
||||
// 返回: error 修改错误
|
||||
func (e *Editor) editDocuments(edit func(*Reader, []byte, *editorXML) (int, error)) error {
|
||||
reader, err := e.Reader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := reader.readFile("OFD.xml")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := parseEditorXML(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reader.files = maps.Clone(reader.files)
|
||||
index, err := edit(reader, data, root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := e.transactionSnapshot()
|
||||
reader.documentIndex, reader.doc = index, nil
|
||||
if err := reader.initRoot(); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := reader.Doc(); err != nil {
|
||||
return err
|
||||
}
|
||||
if e.source != nil && e.documentRoot() == reader.OFD.DocBody[index].DocRoot && e.Info.DocID == reader.OFD.DocBody[index].DocInfo.DocID {
|
||||
source := *e.source
|
||||
source.reader = reader
|
||||
e.source = &source
|
||||
} else {
|
||||
next, err := reader.Editor()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
next.history, next.historyIndex, next.historyLimit = e.history, e.historyIndex, e.historyLimit
|
||||
next.revision, next.serial = e.revision, e.serial
|
||||
next.OnWriteProgress, next.output = e.OnWriteProgress, e.output
|
||||
next.encryption = e.encryption
|
||||
next.backends, next.fontDirs, next.fontFS = e.backends, e.fontDirs, e.fontFS
|
||||
*e = *next
|
||||
}
|
||||
e.recordTransaction(before)
|
||||
return nil
|
||||
}
|
||||
|
||||
// documentRoot 获取当前文档的包内入口,区分不同文档的资源和标识域
|
||||
// 返回: string 文档入口,新建单文档编辑器为空
|
||||
func (e *Editor) documentRoot() string {
|
||||
if e.source == nil {
|
||||
return ""
|
||||
}
|
||||
return e.source.reader.OFD.DocBody[e.DocumentIndex()].DocRoot
|
||||
}
|
||||
|
||||
// sameDocument 判断快照是否属于同一文档,区别删除后复用目录的新文档
|
||||
// 入参: other 编辑快照
|
||||
// 返回: bool 是否为同一文档
|
||||
func (e *Editor) sameDocument(other *Editor) bool {
|
||||
if e.source == nil || other.source == nil {
|
||||
return e.source == nil && other.source == nil
|
||||
}
|
||||
return e.documentRoot() == other.documentRoot() && e.source.info.DocID == other.source.info.DocID
|
||||
}
|
||||
|
||||
// editorDocumentFiles 按标准路径遍历文档依赖,签名摘要引用不作为内容依赖
|
||||
// 入参: reader 包快照, index 文档索引
|
||||
// 返回: map[string]bool 大小写折叠的文件路径, error 读取错误
|
||||
func editorDocumentFiles(reader *Reader, index int) (map[string]bool, error) {
|
||||
body := reader.OFD.DocBody[index]
|
||||
queue := []string{cleanPackagePath(body.DocRoot)}
|
||||
data, err := reader.readFile("OFD.xml")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root, err := parseEditorXML(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var collect func(*editorXML)
|
||||
collect = func(node *editorXML) {
|
||||
if node.name.Local == "Cover" || node.name.Local == "Signatures" || node.name.Local == "DocRoot" {
|
||||
if value := strings.TrimSpace(editorImportText(data, node)); value != "" {
|
||||
queue = append(queue, resolveResourcePath("OFD.xml", "", value))
|
||||
}
|
||||
}
|
||||
if node.name.Local == "Version" && node.attr("BaseLoc") != "" {
|
||||
queue = append(queue, resolveResourcePath("OFD.xml", "", node.attr("BaseLoc")))
|
||||
}
|
||||
for _, child := range node.children {
|
||||
collect(child)
|
||||
}
|
||||
}
|
||||
collect(root.childAt("DocBody", index))
|
||||
seen := make(map[string]bool)
|
||||
for len(queue) > 0 {
|
||||
name := queue[0]
|
||||
queue = queue[1:]
|
||||
key := strings.ToLower(name)
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
actual, exists := reader.fileNamesFold[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
input, err := reader.openFile(actual)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
buffer := bufio.NewReader(input)
|
||||
if prefix, _ := buffer.Peek(3); bytes.Equal(prefix, []byte{0xef, 0xbb, 0xbf}) {
|
||||
buffer.Discard(3)
|
||||
}
|
||||
header, _ := buffer.Peek(128)
|
||||
for len(header) > 0 && len(bytes.TrimSpace(header)) == 0 {
|
||||
buffer.Discard(len(header))
|
||||
header, _ = buffer.Peek(128)
|
||||
}
|
||||
if !bytes.HasPrefix(bytes.TrimSpace(header), []byte("<")) {
|
||||
input.Close()
|
||||
continue
|
||||
}
|
||||
decoder := xml.NewDecoder(buffer)
|
||||
base, resource, depth := "", false, 0
|
||||
var text strings.Builder
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
input.Close()
|
||||
return nil, err
|
||||
}
|
||||
switch node := token.(type) {
|
||||
case xml.StartElement:
|
||||
if depth == 0 && node.Name.Space != "" && node.Name.Space != ofdNamespace && node.Name.Space != "http://www.ofdspec.org" {
|
||||
input.Close()
|
||||
goto nextFile
|
||||
}
|
||||
if node.Name.Space != "" && node.Name.Space != ofdNamespace && node.Name.Space != "http://www.ofdspec.org" {
|
||||
input.Close()
|
||||
return nil, fmt.Errorf("cannot determine document dependencies in %s", actual)
|
||||
}
|
||||
if depth == 0 {
|
||||
resource = node.Name.Local == "Res"
|
||||
}
|
||||
for _, attr := range node.Attr {
|
||||
if resource && depth == 0 && attr.Name.Local == "BaseLoc" {
|
||||
base = attr.Value
|
||||
} else if attr.Name.Local == "BaseLoc" || attr.Name.Local == "FileLoc" || node.Name.Local == "DrawParam" && attr.Name.Local == "Link" {
|
||||
queue = append(queue, resolveResourcePath(actual, base, attr.Value))
|
||||
}
|
||||
}
|
||||
depth++
|
||||
text.Reset()
|
||||
case xml.CharData:
|
||||
text.Write(node)
|
||||
case xml.EndElement:
|
||||
switch node.Name.Local {
|
||||
case "DocRoot", "Signatures", "Cover", "PublicRes", "DocumentRes", "PageRes", "Annotations", "Attachments", "FileLoc", "Profile", "SignedValue", "SchemaLoc", "ExtendData", "File", "FontFile", "MediaFile", "CustomTags", "Extensions":
|
||||
if value := strings.TrimSpace(text.String()); value != "" {
|
||||
queue = append(queue, resolveResourcePath(actual, base, value))
|
||||
}
|
||||
}
|
||||
depth--
|
||||
text.Reset()
|
||||
}
|
||||
}
|
||||
input.Close()
|
||||
nextFile:
|
||||
}
|
||||
return seen, nil
|
||||
}
|
||||
+46
-16
@@ -49,15 +49,14 @@ func (e *Editor) Transaction(edit func(*Editor) error) error {
|
||||
changed := e.revision != before.revision
|
||||
e.history, e.historyIndex, e.historyLimit = before.history, before.historyIndex, before.historyLimit
|
||||
e.backends, e.fontDirs, e.fontFS = before.backends, before.fontDirs, before.fontFS
|
||||
e.fontRenderer, e.fontMetrics = before.fontRenderer, before.fontMetrics
|
||||
if e.sameDocument(&before) {
|
||||
e.fontRenderer, e.fontMetrics = before.fontRenderer, before.fontMetrics
|
||||
} else {
|
||||
e.fontRenderer, e.fontMetrics = nil, make(map[string]FontMetrics)
|
||||
}
|
||||
if changed {
|
||||
e.serial, e.revision = before.serial, before.revision
|
||||
if change := e.recordChange(); change != nil {
|
||||
after := e.transactionSnapshot()
|
||||
before.history, after.history = nil, nil
|
||||
change.undo = func(e *Editor) { e.restoreTransaction(before) }
|
||||
change.redo = func(e *Editor) { e.restoreTransaction(after) }
|
||||
}
|
||||
e.recordTransaction(before)
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
@@ -65,7 +64,7 @@ func (e *Editor) Transaction(edit func(*Editor) error) error {
|
||||
|
||||
// SetHistoryLimit 设置撤销和重做记录的总上限,默认关闭,非正数关闭并释放记录
|
||||
// 缩减上限时优先保留最近的撤销记录,不影响当前文档及修订标识
|
||||
// 记录页面、对象及SetInfo操作,不包含Info的直接修改及字体、图片注册,资源继续共享
|
||||
// 记录文档、页面、对象及SetInfo操作,不包含Info的直接修改及字体、图片注册
|
||||
// 入参: limit 最大记录数
|
||||
func (e *Editor) SetHistoryLimit(limit int) {
|
||||
e.historyLimit = max(0, limit)
|
||||
@@ -149,23 +148,54 @@ func (e *Editor) transactionSnapshot() Editor {
|
||||
return next
|
||||
}
|
||||
|
||||
// restoreTransaction 恢复文档状态并保留历史容器、共享资源和递增标识
|
||||
// restoreTransaction 恢复文档状态,保留历史和配置,仅在同一文档内共享资源
|
||||
// 入参: state 文档状态
|
||||
func (e *Editor) restoreTransaction(state Editor) {
|
||||
state = state.transactionSnapshot()
|
||||
if len(e.resources) > len(state.resources) {
|
||||
state.resources = append(state.resources, e.resources[len(state.resources):]...)
|
||||
state.retainDocumentResources(e)
|
||||
if !e.sameDocument(&state) {
|
||||
state.fontRenderer, state.fontMetrics = nil, make(map[string]FontMetrics)
|
||||
}
|
||||
maps.Copy(state.fonts, e.fonts)
|
||||
maps.Copy(state.images, e.images)
|
||||
maps.Copy(state.resourceID, e.resourceID)
|
||||
state.history, state.historyIndex, state.historyLimit = e.history, e.historyIndex, e.historyLimit
|
||||
state.serial, state.maxID = max(e.serial, state.serial), max(e.maxID, state.maxID)
|
||||
state.serial = max(e.serial, state.serial)
|
||||
state.backends, state.fontDirs, state.fontFS = e.backends, e.fontDirs, e.fontFS
|
||||
state.fontRenderer, state.fontMetrics = e.fontRenderer, e.fontMetrics
|
||||
state.encryption, state.output, state.OnWriteProgress = e.encryption, e.output, e.OnWriteProgress
|
||||
*e = state
|
||||
}
|
||||
|
||||
// retainDocumentResources 保留同一文档后续注册的资源,不跨文档混用编号和字体缓存
|
||||
// 入参: source 当前文档状态
|
||||
func (e *Editor) retainDocumentResources(source *Editor) {
|
||||
if !e.sameDocument(source) {
|
||||
return
|
||||
}
|
||||
if len(source.resources) > len(e.resources) {
|
||||
e.resources = append(e.resources, source.resources[len(e.resources):]...)
|
||||
}
|
||||
maps.Copy(e.fonts, source.fonts)
|
||||
maps.Copy(e.images, source.images)
|
||||
maps.Copy(e.resourceID, source.resourceID)
|
||||
e.maxID = max(e.maxID, source.maxID)
|
||||
e.fontRenderer, e.fontMetrics = source.fontRenderer, source.fontMetrics
|
||||
}
|
||||
|
||||
// recordTransaction 记录事务快照,跨文档撤销前保留各文档后续注册的资源
|
||||
// 入参: before 修改前的文档状态
|
||||
func (e *Editor) recordTransaction(before Editor) {
|
||||
if change := e.recordChange(); change != nil {
|
||||
after := e.transactionSnapshot()
|
||||
before.history, after.history = nil, nil
|
||||
change.undo = func(e *Editor) {
|
||||
after.retainDocumentResources(e)
|
||||
e.restoreTransaction(before)
|
||||
}
|
||||
change.redo = func(e *Editor) {
|
||||
before.retainDocumentResources(e)
|
||||
e.restoreTransaction(after)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// recordChange 分配修订标识并为已完成的有效修改预留记录
|
||||
// 返回: *editorChange 记录,未启用历史时为nil
|
||||
func (e *Editor) recordChange() *editorChange {
|
||||
|
||||
Reference in new issue
Block a user