feat(阅读编辑): 优化进度、框选、搜索与复制
build.yaml / build (push) Successful in 45s

This commit is contained in:
xiaoqidun committed 2026-10-27 14:19:36 +08:00
1 parent dfd4f733f9
commit 7bc31b6b84
6 files changed
+293 -97

No files matched your search

+34 -7
View File
@@ -2961,6 +2961,8 @@ async function mountDocumentTab(tab) {
async function releaseDocumentTab(tab) {
if (tab.closed) return;
const clipboard = workspace.objectClipboard;
if (clipboard?.tab === tab && !await prepareObjectClipboard(clipboard, tab) && workspace.objectClipboard === clipboard) workspace.objectClipboard = null;
tab.closed = true;
tab.print?.destroy();
tab.print = null;
@@ -6574,7 +6576,9 @@ function completePDF(tab) {
throw new DOMException("文档已关闭", "AbortError");
})();
task.promise.catch(err => {
if (current() && err.name !== "AbortError") {
if (!current()) return;
tab.pdfTask = null;
if (err.name !== "AbortError") {
tab.status = err.message;
if (isForegroundDocument(tab)) showError(err, false);
}
@@ -10490,7 +10494,7 @@ function copyEditorSelection(event) {
return true;
}
const index = items[0].index, ids = items.map(item => item.id);
const clipboard = { token: crypto.randomUUID(), source: `${tab.id}:${state.wasmSeq}:${state.doc.documentIndex || 0}:${state.doc.documentVersion || ""}`, page: state.doc.pages[index].id, bounds: selectionBounds(items), x: 0, y: 0, cut };
const clipboard = { tab, token: crypto.randomUUID(), source: `${tab.id}:${state.wasmSeq}:${state.doc.documentIndex || 0}:${state.doc.documentVersion || ""}`, page: state.doc.pages[index].id, bounds: selectionBounds(items), x: 0, y: 0, cut };
if (canvasEditor.nudge) {
clipboard.bounds.x += canvasEditor.nudge.x;
clipboard.bounds.y += canvasEditor.nudge.y;
@@ -10499,11 +10503,14 @@ function copyEditorSelection(event) {
event.clipboardData.setData("text/plain", items.map(item => item.text || "").filter(Boolean).join("\n"));
event.preventDefault();
state.objectClipboardToken = clipboard.token;
const previous = workspace.objectClipboard;
workspace.objectClipboard = clipboard;
if (previous?.tab) previous.ready.then(() => {
if (previous.tab && !previous.tab.closed) return callTabWASM(previous.tab, "ofdgoDiscardObjects", previous.token);
}).catch(() => {});
const capture = () => {
const openSeq = state.openSeq, revision = state.editorInfo.revision;
return callTabWASM(tab, "ofdgoCaptureObjects", index, ids, clipboard.token).then(async result => {
clipboard.data = result.bytes;
return callTabWASM(tab, "ofdgoCaptureObjects", index, ids, clipboard.token).then(async () => {
clipboard.cut = false;
if (cut && isForegroundDocument(tab) && openSeq === state.openSeq && revision === state.editorInfo?.revision && index === state.pageIndex
&& state.objectClipboardToken === clipboard.token && workspace.objectClipboard === clipboard
@@ -10520,6 +10527,24 @@ function copyEditorSelection(event) {
return true;
}
async function prepareObjectClipboard(clipboard, owner = documentTab()) {
if (!await clipboard.ready) return false;
if (clipboard.data) return true;
if (!clipboard.portable) {
const tab = clipboard.tab;
clipboard.portable = callTabWASM(tab, "ofdgoExportObjects", clipboard.token).then(result => {
clipboard.data = result.bytes;
clipboard.tab = null;
return true;
}, err => {
clipboard.portable = null;
if (workspace.objectClipboard === clipboard && isForegroundDocument(owner)) showError(err, false);
return false;
});
}
return clipboard.portable;
}
function visiblePageBounds(index) {
const page = state.doc.pages[index], surface = pageView.pageShell(index)?.querySelector(".page-surface");
const full = { x: 0, y: 0, width: page.width, height: page.height };
@@ -10548,8 +10573,10 @@ async function pasteEditorContent(event) {
setStatus("复制内容已失效,请重新复制");
return;
}
const openSeq = state.openSeq;
if (!await clipboard.ready || !isForegroundDocument(tab) || openSeq !== state.openSeq || index !== state.pageIndex || workspace.objectClipboard !== clipboard) return;
const openSeq = state.openSeq, identity = `${state.wasmSeq}:${state.doc.documentIndex || 0}:${state.doc.documentVersion || ""}`;
const local = state.objectClipboardToken === clipboard.token;
if (!await (local ? clipboard.ready : prepareObjectClipboard(clipboard, tab)) || !isForegroundDocument(tab) || openSeq !== state.openSeq || index !== state.pageIndex || workspace.objectClipboard !== clipboard) return;
if (identity !== `${state.wasmSeq}:${state.doc.documentIndex || 0}:${state.doc.documentVersion || ""}` || local && state.objectClipboardToken !== clipboard.token) return;
if (!state.editing || formDialogOpen() || document.body.hasAttribute("data-busy") || canvasEditor.input || canvasEditor.crop || canvasEditor.drag || canvasEditor.nudge || canvasEditor.nudgeCommit) return;
if (!pageCan("insert")) return;
const visible = visiblePageBounds(index), bounds = clipboard.bounds;
@@ -10562,7 +10589,7 @@ async function pasteEditorContent(event) {
offset[axis] = Math.max(visible[axis] + margin, Math.min(position, visible[axis] + visible[size] - margin - bounds[size])) - bounds[axis];
}
}
if (await changeDocument(state.objectClipboardToken === clipboard.token ? "ofdgoPasteObjects" : "ofdgoImportObjects", null, index, state.objectClipboardToken === clipboard.token ? token : clipboard.data, offset.x, offset.y)) {
if (await changeDocument(local ? "ofdgoPasteObjects" : "ofdgoImportObjects", null, index, local ? token : clipboard.data, offset.x, offset.y)) {
clipboard.target = target;
clipboard.targetPage = page.id;
clipboard.x = offset.x;
+60 -25
View File
@@ -395,28 +395,47 @@ function resizeCorner(node, event) {
return corner;
}
function alignmentSnap(box, targets, tolerance) {
const lines = [];
const offset = { x: 0, y: 0 };
for (const [axis, size, cross, span] of [["x", "width", "y", "height"], ["y", "height", "x", "width"]]) {
let distance = Infinity, line;
for (const target of targets) {
for (const anchor of [0, 0.5, 1]) {
const position = target[axis] + target[size] * anchor;
for (const edge of [0, 0.5, 1]) {
const delta = position - box[axis] - box[size] * edge;
if (Math.abs(delta) > tolerance[axis] || Math.abs(delta) >= distance) continue;
distance = Math.abs(delta);
export class AlignmentIndex {
constructor(targets) {
for (const [axis, size] of [["x", "width"], ["y", "height"]]) {
this[axis] = targets.flatMap((target, index) => [0, 0.5, 1].map((anchor, order) => ({
position: target[axis] + target[size] * anchor, target, order: index * 3 + order,
}))).sort((a, b) => a.position - b.position || a.order - b.order);
}
}
snap(box, tolerance) {
const lines = [], offset = { x: 0, y: 0 };
for (const [axis, size, cross, span] of [["x", "width", "y", "height"], ["y", "height", "x", "width"]]) {
const anchors = this[axis];
let best, distance = Infinity, rank = Infinity;
for (let edge = 0; edge < 3; edge++) {
const value = box[axis] + box[size] * edge / 2;
let low = 0, high = anchors.length;
while (low < high) {
const middle = (low + high) >> 1;
if (anchors[middle].position < value - tolerance[axis]) low = middle + 1;
else high = middle;
}
for (let i = low; i < anchors.length && anchors[i].position <= value + tolerance[axis]; i++) {
const anchor = anchors[i], delta = anchor.position - box[axis] - box[size] * edge / 2;
const next = Math.abs(delta), order = anchor.order * 3 + edge;
if (next > tolerance[axis] || next > distance || next === distance && order >= rank) continue;
best = anchor;
distance = next;
rank = order;
offset[axis] = delta;
const from = Math.min(box[cross], target[cross]);
const to = Math.max(box[cross] + box[span], target[cross] + target[span]);
line = axis === "x" ? `M${position} ${from}V${to}` : `M${from} ${position}H${to}`;
}
}
if (best) {
const { position, target } = best;
const from = Math.min(box[cross], target[cross]);
const to = Math.max(box[cross] + box[span], target[cross] + target[span]);
lines.push(axis === "x" ? `M${position} ${from}V${to}` : `M${from} ${position}H${to}`);
}
}
if (line) lines.push(line);
return { ...offset, path: lines.join("") };
}
return { ...offset, path: lines.join("") };
}
function transformedBox(box, matrix) {
@@ -548,6 +567,23 @@ export class ObjectIndex {
this.root = build([...items]);
}
queryBox(box) {
const result = [];
const intersects = node => node.x <= box.x + box.width && node.x + node.width >= box.x
&& node.y <= box.y + box.height && node.y + node.height >= box.y;
const visit = node => {
if (!node || !intersects(node)) return;
if (node.items) {
for (const item of node.items) {
if (!item.parent && item.x >= box.x && item.x + item.width <= box.x + box.width
&& item.y >= box.y && item.y + item.height <= box.y + box.height) result.push(item);
}
} else { visit(node.left); visit(node.right); }
};
visit(this.root);
return result.sort((a, b) => a.order - b.order);
}
query(point, tolerance = 0) {
const result = [];
const contains = box => point.x >= box.x - tolerance && point.x <= box.x + box.width + tolerance
@@ -950,9 +986,10 @@ export class CanvasEditor {
};
if (!this.drag.corner) {
const selected = this.items();
this.drag.targets = [...item.surface.querySelectorAll(".edit-object")]
const targets = [...item.surface.querySelectorAll(".edit-object")]
.map(node => this.nodes.get(node)).filter(member => !selected.some(item => item === member || item.id.startsWith(member.id + "/") || member.id.startsWith(item.id + "/")));
this.drag.targets.push({ x: 0, y: 0, ...item.page });
targets.push({ x: 0, y: 0, ...item.page });
this.drag.alignment = new AlignmentIndex(targets);
}
if (lineShape(item.shape) && this.drag.corner) {
Object.assign(this.drag, this.createPreview(item.surface, item.page, item.shape, {
@@ -1133,7 +1170,7 @@ export class CanvasEditor {
snap(drag, bypass = false) {
const { item, rect, rotation, change } = drag;
const { x, y, path } = bypass ? { x: 0, y: 0, path: "" } : alignmentSnap({ ...item, x: item.x + change.x, y: item.y + change.y }, drag.targets, {
const { x, y, path } = bypass ? { x: 0, y: 0, path: "" } : drag.alignment.snap({ ...item, x: item.x + change.x, y: item.y + change.y }, {
x: drag.axis === "y" ? 0 : 4 * item.page.width / (rotation % 180 ? rect.height : rect.width),
y: drag.axis === "x" ? 0 : 4 * item.page.height / (rotation % 180 ? rect.width : rect.height),
});
@@ -1362,11 +1399,9 @@ export class CanvasEditor {
paintShape(drag.node, "rectangle", box);
if (Math.hypot(event.clientX-drag.clientX, event.clientY-drag.clientY) < 3) return;
const items = [...drag.base];
for (const node of drag.surface.querySelectorAll(".edit-object")) {
const item = this.nodes.get(node);
if (!item.parent && !items.includes(item) && item.x >= box.x && item.x+item.width <= box.x+box.width && item.y >= box.y && item.y+item.height <= box.y+box.height) {
items.push(item);
}
const selected = new Set(items);
for (const item of drag.page.hitIndex.queryBox(box)) {
if (!selected.has(item)) items.push(item);
}
this.setSelection(items);
}
+88 -49
View File
@@ -38,8 +38,8 @@ import (
"github.com/xiaoqidun/ofdgo"
)
// sessionTextCacheLimit 限制会话保留的页面文字索引数量
const sessionTextCacheLimit = 32
// sessionTextCacheBytes 限制会话文字索引的内存预算
const sessionTextCacheBytes = 32 << 20
// OpenOptions 打开OFD文档选项
type OpenOptions struct {
@@ -63,8 +63,7 @@ type Session struct {
doc *ofdgo.Document
pageCache map[int]*ofdgo.PageContent
boxCache []pageBoxInfo
textCache map[int]*ofdgo.PageText
textOrder []int
textCache *ofdgo.PageTextCache
svgFonts map[string][]byte
fontScan *ofdgo.FontInfoScanner
fontInfos []FontInfo
@@ -355,10 +354,11 @@ type apiResult struct {
ReasonCode ofdgo.EditReason `json:"reasonCode,omitempty"`
}
// editorClipboard 当前编辑文档中的对象快照与剪贴板标识
// editorClipboard 保存选区标识及同文档快照或跨文档副本
type editorClipboard struct {
token string
selection *ofdgo.ObjectSelection
packet *ofdgo.Editor
}
// editorInfo 编辑文档信息与操作状态
@@ -451,23 +451,27 @@ type pendingPDF struct {
font platformValue
operation *outputContext
report func(string, int, int) error
stage string
completed int
total int
}
// businessEngine 独立文档、编辑、剪贴板及操作上下文
type businessEngine struct {
resources *ofdgo.Resources
currentSession *Session
currentEditor *ofdgo.Editor
pdf *pendingPDF
pdfWarnings []byte
saveSnapshot *ofdgo.Editor
printDocument *ofdgo.PrintDocument
pendingImport *ofdgo.Reader
copiedObjects *editorClipboard
copiedStyle *ofdgo.GraphicObject
ctx context.Context
resolveInput func(context.Context, any) (io.ReaderAt, int64, error)
conversionPool *ofdgo.ConversionPool
resources *ofdgo.Resources
currentSession *Session
currentEditor *ofdgo.Editor
pdf *pendingPDF
pdfWarnings []byte
saveSnapshot *ofdgo.Editor
printDocument *ofdgo.PrintDocument
pendingImport *ofdgo.Reader
copiedObjects *editorClipboard
portableObjects *editorClipboard
copiedStyle *ofdgo.GraphicObject
ctx context.Context
resolveInput func(context.Context, any) (io.ReaderAt, int64, error)
conversionPool *ofdgo.ConversionPool
}
// Open 打开浏览器内存中的OFD文档
@@ -596,8 +600,7 @@ func (s *Session) SetFonts(fonts []FontFile) error {
s.Renderer.SetFontFS()
s.fontFS = nil
}
clear(s.textCache)
s.textOrder = nil
s.textCache.Clear()
clear(s.svgFonts)
s.resetFontInfo()
return nil
@@ -623,24 +626,13 @@ func (s *Session) PageText(index int) (*ofdgo.PageText, error) {
// 入参: index 页面索引
// 返回: *ofdgo.PageText 文字索引,未缓存时为nil
func (s *Session) cachedPageText(index int) *ofdgo.PageText {
text := s.textCache[index]
if text != nil {
s.cachePageText(index, text)
}
return text
return s.textCache.Get(index)
}
// cachePageText 保留最近访问的文字索引,避免全文搜索持续占用内存
// cachePageText 按内存预算保留页面文字索引,复用阅读与搜索结果
// 入参: index 页面索引, text 文字索引
func (s *Session) cachePageText(index int, text *ofdgo.PageText) {
if position := slices.Index(s.textOrder, index); position >= 0 {
s.textOrder = slices.Delete(s.textOrder, position, position+1)
} else if len(s.textOrder) == sessionTextCacheLimit {
delete(s.textCache, s.textOrder[0])
s.textOrder = slices.Delete(s.textOrder, 0, 1)
}
s.textOrder = append(s.textOrder, index)
s.textCache[index] = text
s.textCache.Put(index, text)
}
// PageTextString 获取页面原文,不为全文复制保留额外的页面和文字索引
@@ -879,8 +871,7 @@ func (s *Session) SetRenderBackend(name string) error {
}
previous, next := s.Renderer.Backends().Info(), backends.Info()
if previous.Compiler != next.Compiler || previous.Fonts != next.Fonts || previous.Geometry != next.Geometry {
clear(s.textCache)
s.textOrder = nil
s.textCache.Clear()
}
ofdgo.WithRenderBackends(backends)(s.Renderer)
return nil
@@ -984,7 +975,7 @@ func newSession(reader *ofdgo.Reader, opts OpenOptions) (*Session, error) {
doc: doc,
pageCache: make(map[int]*ofdgo.PageContent),
boxCache: make([]pageBoxInfo, len(doc.Pages.Page)),
textCache: make(map[int]*ofdgo.PageText),
textCache: ofdgo.NewPageTextCache(sessionTextCacheBytes),
svgFonts: make(map[string][]byte),
}, nil
}
@@ -1494,7 +1485,7 @@ func (e *businessEngine) openPDF(args []platformValue) (any, error) {
}
password = slices.Clone(password)
}
pending.operation, pending.report = e.newEditorOperationContext(pending.progress)
e.bindPDFOperation(pending)
options := ofdgo.PDFImportOptions{Resources: e.resources, PasswordUTF8: true, Password: password,
OnProgress: func(stage string, completed, total int) error {
return pending.report(stage, completed, total)
@@ -1624,13 +1615,27 @@ func (e *businessEngine) checkDocument(name string) error {
return nil
}
switch name {
case "ofdgoCheckDocument", "ofdgoPDFContext", "ofdgoOpenPDF", "ofdgoOpen", "ofdgoLoadPDF", "ofdgoConfigure", "ofdgoDocumentInfo", "ofdgoSVGFontData", "ofdgoRenderBackends", "ofdgoOutputFormats", "ofdgoExportFormats", "ofdgoParsePageRange", "ofdgoMatchFontFiles", "ofdgoOutlineLinks", "ofdgoDocumentNavigation", "ofdgoInvoices", "ofdgoRenderPage", "ofdgoSearchPage", "ofdgoPageTextString", "ofdgoOpenLinks", "ofdgoDiscardPrint", "ofdgoDiscardSave", "ofdgoDetectFormat", "ofdgoConvertFile", "ofdgoPackFiles", "ofdgoLoadImport", "ofdgoCreateDocument", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF":
case "ofdgoDiscardObjects", "ofdgoExportObjects", "ofdgoCheckDocument", "ofdgoPDFContext", "ofdgoOpenPDF", "ofdgoOpen", "ofdgoLoadPDF", "ofdgoConfigure", "ofdgoDocumentInfo", "ofdgoSVGFontData", "ofdgoRenderBackends", "ofdgoOutputFormats", "ofdgoExportFormats", "ofdgoParsePageRange", "ofdgoMatchFontFiles", "ofdgoOutlineLinks", "ofdgoDocumentNavigation", "ofdgoInvoices", "ofdgoRenderPage", "ofdgoSearchPage", "ofdgoPageTextString", "ofdgoOpenLinks", "ofdgoDiscardPrint", "ofdgoDiscardSave", "ofdgoDetectFormat", "ofdgoConvertFile", "ofdgoPackFiles", "ofdgoLoadImport", "ofdgoCreateDocument", "ofdgoLayoutPrint", "ofdgoPrintSide", "ofdgoPrintPDF":
return nil
default:
return ofdgo.ErrIncompleteDocument
}
}
// bindPDFOperation 更新取消上下文并保留转换阶段,时间片检查不重置进度
// 入参: pending 当前转换任务
func (e *businessEngine) bindPDFOperation(pending *pendingPDF) {
operation, report := e.newEditorOperationContext(pending.progress)
pending.operation = operation
pending.report = func(stage string, completed, total int) error {
pending.stage, pending.completed, pending.total = stage, completed, total
return report(stage, completed, total)
}
operation.checkpoint = func() error {
return report(pending.stage, pending.completed, pending.total)
}
}
// preparePDF 只为页面读取和后台加载绑定转换上下文,不补全其他命令
// 入参: name 命令名称, args 命令参数
// 返回: error 转换或取消错误
@@ -1643,7 +1648,7 @@ func (e *businessEngine) preparePDF(name string, args []platformValue) error {
default:
return nil
}
e.pdf.operation, e.pdf.report = e.newEditorOperationContext(e.pdf.progress)
e.bindPDFOperation(e.pdf)
e.pdf.operation.checked = time.Now()
e.pdf.operation.interval = 255
e.currentSession.Renderer.OutputContext = e.pdf.operation
@@ -1922,8 +1927,7 @@ func (e *businessEngine) configureDocument(args []platformValue) (any, error) {
}
if e.currentSession.Renderer.RenderAnnotations != args[1].Bool() {
e.currentSession.Renderer.RenderAnnotations = args[1].Bool()
clear(e.currentSession.textCache)
e.currentSession.textOrder = nil
e.currentSession.textCache.Clear()
}
return e.currentSession.Summary(), nil
}
@@ -5435,7 +5439,7 @@ func (e *businessEngine) copyObjects(args []platformValue) (any, error) {
// captureObjects 保存独立选区快照,不修改文档或历史
// 入参: args 页面索引、对象路径数组和剪贴板标识
// 返回: any 可跨文档导入的选区包, error 错误信息
// 返回: any 捕获结果, error 错误信息
func (e *businessEngine) captureObjects(args []platformValue) (any, error) {
if e.currentEditor == nil {
return nil, fmt.Errorf("no document is being edited")
@@ -5449,14 +5453,46 @@ func (e *businessEngine) captureObjects(args []platformValue) (any, error) {
if err != nil {
return nil, err
}
var output bytes.Buffer
if _, err := e.currentEditor.WriteTargetsTo(context.Background(), &output, args[0].Int(), paths, true); err != nil {
packet, err := e.currentEditor.PrepareTargets(e.operationContext(), args[0].Int(), paths, true)
if err != nil {
return nil, err
}
e.copiedObjects = clipboard
e.portableObjects = &editorClipboard{token: clipboard.token, packet: packet}
return successResult(nil), nil
}
// exportObjects 按需压缩已准备的独立选区,切换文档或版本不影响结果
// 入参: args 剪贴板标识
// 返回: any 可跨文档导入的选区包, error 标识或写入错误
func (e *businessEngine) exportObjects(args []platformValue) (any, error) {
clipboard := e.portableObjects
if clipboard == nil || clipboard.token != args[0].String() {
return nil, fmt.Errorf("object clipboard is no longer available")
}
var output bytes.Buffer
if _, err := clipboard.packet.WriteToWithOptions(e.operationContext(), &output, ofdgo.WriteOptions{}); err != nil {
return nil, err
}
e.portableObjects = nil
clipboard.packet = nil
return successResult(map[string]any{"bytes": bytesToJS(output.Bytes())}), nil
}
// discardObjects 释放被新剪贴板替代的选区,不影响后续捕获
// 入参: args 剪贴板标识
// 返回: any 清理结果, error 始终为nil
func (e *businessEngine) discardObjects(args []platformValue) (any, error) {
token := args[0].String()
if e.portableObjects != nil && e.portableObjects.token == token {
e.portableObjects = nil
}
if e.copiedObjects != nil && e.copiedObjects.token == token {
e.copiedObjects = nil
}
return successResult(nil), nil
}
// pasteObjects 将当前文档的对象快照粘贴到目标页
// 入参: args 目标页、剪贴板标识和横纵位移
// 返回: any 文档信息及新选区, error 错误信息
@@ -6847,7 +6883,7 @@ func (e *businessEngine) syncEditorPageIDs(index int, content *ofdgo.PageContent
before.PathObject.ID = object.PathObject.ID
before.ImageObject.ID = object.ImageObject.ID
before.CompositeGraphicUnit.ID = object.CompositeGraphicUnit.ID
delete(e.currentSession.textCache, index)
e.currentSession.textCache.Delete(index)
}
}
}
@@ -6890,11 +6926,12 @@ func (e *businessEngine) previewEditor(editor *ofdgo.Editor, annotations bool) (
session.pageCache[index] = page
}
}
for _, index := range e.currentSession.textOrder {
e.currentSession.textCache.Range(func(index int, text *ofdgo.PageText) bool {
if !slices.Contains(changed, index) {
session.cachePageText(index, e.currentSession.textCache[index])
session.cachePageText(index, text)
}
}
return true
})
for index, box := range e.currentSession.boxCache {
if !slices.Contains(changed, index) {
session.boxCache[index] = box
@@ -7978,6 +8015,8 @@ func (e *businessEngine) businessCommands() map[string]func([]platformValue) (an
"ofdgoEraseObjectsPath": e.eraseObjectsPath,
"ofdgoCopyObjects": e.copyObjects,
"ofdgoCaptureObjects": e.captureObjects,
"ofdgoExportObjects": e.exportObjects,
"ofdgoDiscardObjects": e.discardObjects,
"ofdgoPasteObjects": e.pasteObjects,
"ofdgoImportObjects": e.importObjects,
"ofdgoOrderObjects": e.orderObjects,
+3 -4
View File
@@ -227,8 +227,7 @@ func (e *NativeEngine) Call(ctx context.Context, name string, args []any, emit f
if result != nil && result["ok"] == true && ctx.Err() == nil {
e.currentSession.svgFonts = maps.Clone(worker.svgFonts)
clear(worker.pageCache)
clear(worker.textCache)
worker.textOrder = nil
worker.textCache.Clear()
clear(worker.svgFonts)
e.renderIdle = append(e.renderIdle, worker)
}
@@ -380,7 +379,7 @@ func (e *NativeEngine) Close() error {
if e.pendingImport != nil {
err = errors.Join(err, e.pendingImport.Close())
}
e.currentSession, e.currentEditor, e.pendingImport, e.copiedObjects, e.copiedStyle = nil, nil, nil, nil, nil
e.currentSession, e.currentEditor, e.pendingImport, e.copiedObjects, e.portableObjects, e.copiedStyle = nil, nil, nil, nil, nil, nil
return err
}
@@ -431,7 +430,7 @@ func (e *NativeEngine) renderSession(ctx context.Context) (*Session, error) {
}
return &Session{Reader: renderer.Reader, Renderer: renderer, doc: source.doc, fontFS: source.fontFS,
pageCache: make(map[int]*ofdgo.PageContent), boxCache: append([]pageBoxInfo(nil), source.boxCache...),
textCache: make(map[int]*ofdgo.PageText), svgFonts: make(map[string][]byte), editing: source.editing}, nil
textCache: ofdgo.NewPageTextCache(sessionTextCacheBytes), svgFonts: make(map[string][]byte), editing: source.editing}, nil
}
// prepareRender 同步当前编辑页面的图元标识,避免并行访问编辑器缓存
+24 -12
View File
@@ -962,20 +962,20 @@ func (e *Editor) formatTargetsText(page int, paths []ObjectPath, patch TextForma
})
}
// WriteTargetsTo 将选区及其关联资源写成独立单页OFD,来源关闭后仍可导入
// PrepareTargets 将选区及其关联资源复制为独立单页编辑器,来源关闭后仍可使用
// 不修改来源、历史或组合结构,加密来源须显式允许明文导出
// 不包含页面模板和签名,指向其他页面的跳转随单页导出移除
// 入参: ctx 取消上下文, writer 输出流, page 来源页, paths 完整对象路径, allowDecrypted 允许明文导出
// 返回: int64 写入字节数, error 路径、资源或写入错误
func (e *Editor) WriteTargetsTo(ctx context.Context, writer io.Writer, page int, paths []ObjectPath, allowDecrypted bool) (int64, error) {
// 入参: ctx 取消上下文, page 来源页, paths 完整对象路径, allowDecrypted 允许明文导出
// 返回: *Editor 独立选区,可直接导出或继续编辑, error 路径或资源错误
func (e *Editor) PrepareTargets(ctx context.Context, page int, paths []ObjectPath, allowDecrypted bool) (*Editor, error) {
if err := ctx.Err(); err != nil {
return 0, err
return nil, err
}
if e.encryption != nil && !allowDecrypted {
return 0, ErrEncryptionPolicyRequired
return nil, ErrEncryptionPolicyRequired
}
if len(paths) == 0 {
return 0, fmt.Errorf("no objects selected")
return nil, fmt.Errorf("no objects selected")
}
snapshot := e.transactionSnapshot()
snapshot.history, snapshot.historyIndex, snapshot.historyLimit = nil, 0, 0
@@ -989,19 +989,19 @@ func (e *Editor) WriteTargetsTo(ctx context.Context, writer io.Writer, page int,
}
selection, err := snapshot.CaptureTargets(page, paths)
if err != nil {
return 0, err
return nil, err
}
content, err := snapshot.page(page)
if err != nil {
return 0, err
return nil, err
}
objects, annotations, err := snapshot.PasteSelection(page, selection, 0, 0)
if err != nil {
return 0, err
return nil, err
}
box, err := creationBox(content.Area.PhysicalBox)
if err != nil {
return 0, err
return nil, err
}
packet := NewEditor()
packet.SetRenderBackends(e.backends)
@@ -1010,10 +1010,22 @@ func (e *Editor) WriteTargetsTo(ctx context.Context, writer io.Writer, page int,
packet.SetFontFS(e.fontFS...)
packet.OnWriteProgress = e.OnWriteProgress
if _, err := packet.AddPage(box.W, box.H); err != nil {
return 0, err
return nil, err
}
packet.pages[0].Area = content.Area
if _, _, err := packet.importSelection(ctx, &snapshot, page, objects, annotations, 0, ObjectImportOptions{AllowDecrypted: true, OnProgress: e.OnWriteProgress}, false); err != nil {
return nil, err
}
packet.history, packet.historyIndex = nil, 0
return packet, nil
}
// WriteTargetsTo 将选区及关联资源写成独立单页OFD,不修改来源与历史
// 入参: ctx 取消上下文, writer 输出流, page 来源页, paths 完整对象路径, allowDecrypted 允许明文导出
// 返回: int64 写入字节数, error 路径、资源或写入错误
func (e *Editor) WriteTargetsTo(ctx context.Context, writer io.Writer, page int, paths []ObjectPath, allowDecrypted bool) (int64, error) {
packet, err := e.PrepareTargets(ctx, page, paths, allowDecrypted)
if err != nil {
return 0, err
}
return packet.WriteToWithOptions(ctx, writer, WriteOptions{})
+84
View File
@@ -24,8 +24,15 @@ import (
"strings"
"unicode"
"unicode/utf8"
"unsafe"
)
// PageTextCache 按内存预算保留最近访问的页面文字索引
// 缓存后不得修改PageText,页面或字体变化时须删除对应条目
type PageTextCache struct {
cache *renderCache[int, *PageText]
}
// textPageTokens 保留文字及其复合图元和裁剪,跳过独立路径与图片
type textPageTokens struct {
*xml.Decoder
@@ -76,6 +83,83 @@ type TextMatchPart struct {
End int `json:"end"`
}
// NewPageTextCache 创建文字索引缓存,预算包含字符区域和条目开销
// 入参: bytes 内存预算,非正数禁用缓存
// 返回: *PageTextCache 独立缓存
func NewPageTextCache(bytes int) *PageTextCache {
return &PageTextCache{cache: resourceCache[int, *PageText](nil, max(0, bytes))}
}
// MemorySize 估算文字索引占用,包含切片容量和字符串内容
// 返回: int64 估算字节数,共享字符串按各次引用计费
func (p *PageText) MemorySize() int64 {
size := int64(unsafe.Sizeof(*p)) + int64(cap(p.Runs))*int64(unsafe.Sizeof(TextRun{}))
for _, run := range p.Runs {
size += int64(len(run.ID)) + int64(len(run.Text))
size += int64(cap(run.Boxes))*int64(unsafe.Sizeof(Box{})) + int64(cap(run.Spans))*int64(unsafe.Sizeof(TextSpan{}))
}
return size
}
// Get 获取文字索引并更新最近访问顺序
// 入参: index 页面索引
// 返回: *PageText 文字索引,未命中时为nil
func (c *PageTextCache) Get(index int) *PageText {
text, _ := c.cache.get(index)
return text
}
// Put 缓存完整文字索引,超出预算的单页不驱逐其他页面
// 入参: index 页面索引, text 文字索引,nil删除已有条目
func (c *PageTextCache) Put(index int, text *PageText) {
if text == nil {
c.Delete(index)
return
}
size := text.MemorySize() + 128
if size > int64(c.cache.limit) {
c.Delete(index)
return
}
c.cache.put(index, text, int(size))
}
// Delete 删除页面索引并释放预算
// 入参: index 页面索引
func (c *PageTextCache) Delete(index int) {
c.cache.mu.Lock()
defer c.cache.mu.Unlock()
if entry := c.cache.entries[index]; entry != nil {
c.cache.remove(entry)
}
}
// Clear 清空缓存和计费状态
func (c *PageTextCache) Clear() {
c.cache.clear()
}
// Len 返回缓存页面数
// 返回: int 已缓存页面数
func (c *PageTextCache) Len() int {
c.cache.mu.Lock()
defer c.cache.mu.Unlock()
return len(c.cache.entries)
}
// Range 按访问时间从旧到新遍历,遍历期间不得访问当前缓存
// 入参: visit 访问函数,返回false停止遍历
func (c *PageTextCache) Range(visit func(int, *PageText) bool) {
c.cache.mu.Lock()
defer c.cache.mu.Unlock()
for entry := c.cache.order.Back(); entry != nil; entry = entry.Prev() {
value := entry.Value.(renderCacheEntry[int, *PageText])
if !visit(value.key, value.value) {
return
}
}
}
// Token 读取文字页面所需的XML节点
// 返回: xml.Token XML节点, error 错误信息
func (d *textPageTokens) Token() (xml.Token, error) {