From 41a50d9e964a49bf35eb686239592ae899283082 Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Mon, 26 Oct 2026 21:01:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=90=9C=E7=B4=A2=E7=BC=96=E8=BE=91=E6=89=93?= =?UTF-8?q?=E5=8D=B0=E4=B8=8E=E8=89=B2=E5=BD=A9=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.js | 61 ++++++--- assets/webui/ofdgo_print.js | 2 +- go.mod | 2 +- go.sum | 4 +- internal/webui/webui.go | 98 +++++++++++--- ofdgo_editor_action.go | 2 +- ofdgo_editor_composite.go | 2 + ofdgo_editor_composite_copy.go | 135 +++++++++++-------- ofdgo_editor_composite_insert.go | 26 ++++ ofdgo_editor_composite_ops.go | 60 ++++++--- ofdgo_editor_open.go | 1 + ofdgo_editor_rich_text.go | 209 +++++++++++++++++++++++++++++- ofdgo_editor_structure_content.go | 3 +- ofdgo_editor_text.go | 12 +- ofdgo_pdf.go | 23 +++- ofdgo_print_output.go | 34 +++-- ofdgo_search.go | 136 ++++++++++++++----- 17 files changed, 629 insertions(+), 181 deletions(-) diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 842521a..bcb24ac 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -126,6 +126,7 @@ function createDocumentState() { searchMatches: [], searchIndex: -1, searchRunning: false, + searchTruncated: false, navigationScroll: new Map(), fontSyncPending: false, fontCatalogLoading: false, @@ -4254,7 +4255,7 @@ async function beginCanvasText(index, box, surface, page) { const model = richTextModel(item); const key = `temp-font-${crypto.randomUUID()}`; model.runs[0].format.font = key; - canvasEditor.editText(item, face, null, state.composite ? null : { model, faces: new Map([[key, face]]), fonts: [{ key, data }] }); + canvasEditor.editText(item, face, null, { model, faces: new Map([[key, face]]), fonts: [{ key, data }] }); } catch (err) { if (isForegroundDocument(tab) && openSeq === state.openSeq) showError(err, false); } finally { @@ -4704,13 +4705,13 @@ async function changeDocument(name, item, ...args) { const annotated = !scoped && ids.some(id => id.startsWith("annotation:")); const inserting = ["ofdgoInsertShape", "ofdgoInsertText", "ofdgoInsertImage", "ofdgoInsertInk"].includes(name) || name === "ofdgoSaveRichText" && !item; const editingAnnotation = name === "ofdgoWriteAnnotation" && Boolean(state.annotationCreate?.item); - if (scope && inserting && name === "ofdgoSaveRichText") throw new Error("组合内部暂不支持新增富文本"); - if (scope && inserting) args.push(scope.key); + const scopedRichText = scoped && name === "ofdgoSaveRichText"; + if (scope && (inserting || scopedRichText)) args.push(scope.key); const textSelection = name === "ofdgoUpdateTextSelection"; - if (scoped && !operation && !textSelection) throw new Error("内部对象暂不支持此操作"); + if (scoped && !operation && !textSelection && !scopedRichText) throw new Error("内部对象暂不支持此操作"); const doc = textSelection ? await callWASM(name, item.index, item.selectionIDs, ...args) - : scoped + : scoped && !scopedRichText ? await callWASM("ofdgoChangeCompositeObjects", item.index, scope.key, Array.isArray(item.id) ? item.id : members.map(member => member.id), operation, ...args) : annotated && operation ? await callWASM("ofdgoChangeSelection", item.index, ids, operation, ...args) : await callWASM(name, ...(item ? [item.index, item.id] : []), ...args); @@ -9797,7 +9798,7 @@ async function runBatch() { try { const result = await batchCall(slot, "ofdgoConvertFile", [item.file, options, fonts, destination], progress => batchProgress(item, position, jobs.length, progress)); results[position] = result.files; - item.details = (result.pdf?.Warnings || []).map(warning => `${warning.Page ? `第${warning.Page}页:` : ""}${warning.Message}`); + item.details = (result.pdf?.Warnings || []).map(conversionWarningText); for (const font of result.fonts || []) item.details.push(`${font.name}:${font.changed ? options.fontMode === 1 ? "已嵌入" : "已外置" : `未处理 · ${fontBindingError(font.reason)}`}`); if (result.unchanged && !result.bytes) { batchStatus(item, "unchanged", "原样"); @@ -10760,7 +10761,11 @@ function renderOutlines(reset = true) { showNavigation(selected && !selected.hidden ? selected : el.pagesTab); } +const SEARCH_MATCH_LIMIT = 10000; + function resetSearch(clearInput = true) { + for (const [id, request] of wasmRequests) if (request.tab === documentTab() && request.name === "ofdgoSearchPage") cancelOperation(id); + state.searchTruncated = false; state.searchRunning = false; state.searchSeq += 1; state.searchQuery = ""; @@ -10777,16 +10782,20 @@ function resetSearch(clearInput = true) { async function refreshSearchPages(pages, openSeq) { const tab = documentTab(), target = tab.state, query = target.searchQuery; - if (!query || target.searchRunning) { resetSearch(false); return; } + if (!query || target.searchRunning || target.searchTruncated) { resetSearch(false); return; } const seq = ++target.searchSeq; target.searchRunning = true; const selected = target.searchMatches[target.searchIndex]; try { const matches = target.searchMatches.filter(match => !pages.includes(match.page)); for (const page of pages) { - const result = await callTabWASM(tab, "ofdgoSearchPage", page, query); + const result = await callTabWASM(tab, "ofdgoSearchPage", page, query, SEARCH_MATCH_LIMIT - matches.length + 1); if (tab.closed || target.openSeq !== openSeq || target.searchSeq !== seq) return; - for (const match of result || []) matches.push({...match, page}); + for (const match of result || []) { + if (matches.length === SEARCH_MATCH_LIMIT) { target.searchTruncated = true; break; } + matches.push({...match, page}); + } + if (target.searchTruncated) break; } matches.sort((a,b) => a.page - b.page); target.searchMatches = matches; @@ -10794,7 +10803,7 @@ async function refreshSearchPages(pages, openSeq) { if (isForegroundDocument(tab)) { el.searchResults.replaceChildren(); clearSearchHighlights(); - el.searchStatus.textContent = matches.length ? "搜索完成" : "暂无结果"; + el.searchStatus.textContent = target.searchTruncated ? "已达结果上限" : matches.length ? "搜索完成" : "暂无结果"; renderSearchResults(); if (target.searchIndex >= 0) renderSearchHighlights(matches[target.searchIndex].page); } @@ -10829,8 +10838,8 @@ async function searchDocument() { target.searchRunning = true; try { for (let page = 0; page < pageCount; page += 1) { - const matches = await callWASM("ofdgoSearchPage", page, query); - if (seq !== state.searchSeq || openSeq !== state.openSeq) { + const matches = await callWASM("ofdgoSearchPage", page, query, SEARCH_MATCH_LIMIT - target.searchMatches.length + 1); + if (target !== state || seq !== target.searchSeq || openSeq !== target.openSeq) { return; } el.searchStatus.textContent = `正在搜索文字 · ${page + 1} / ${pageCount} 页`; @@ -10838,16 +10847,18 @@ async function searchDocument() { continue; } for (const match of matches) { + if (target.searchMatches.length === SEARCH_MATCH_LIMIT) { target.searchTruncated = true; break; } state.searchMatches.push({ ...match, page }); } renderSearchResults(); + if (target.searchTruncated) break; } - el.searchStatus.textContent = state.searchMatches.length ? "搜索完成" : "暂无结果"; + el.searchStatus.textContent = target.searchTruncated ? "已达结果上限" : state.searchMatches.length ? "搜索完成" : "暂无结果"; if (state.searchIndex < 0 && state.searchMatches.length && !el.searchPanel.hidden && state.showPages) { await selectSearchMatch(0); } } catch (err) { - if (seq === state.searchSeq && openSeq === state.openSeq) { + if (target === state && seq === target.searchSeq && openSeq === target.openSeq) { state.searchQuery = ""; el.searchStatus.textContent = "搜索失败"; showError(err, false); @@ -10900,6 +10911,7 @@ async function selectSearchMatch(index) { index = (index + count) % count; state.searchIndex = index; const match = state.searchMatches[index]; + const target = state; const seq = state.searchSeq; const openSeq = state.openSeq; clearSearchHighlights(); @@ -10908,7 +10920,7 @@ async function selectSearchMatch(index) { return; } await nextFrame(); - if (seq !== state.searchSeq || openSeq !== state.openSeq || state.searchIndex !== index || state.pageIndex !== match.page) { + if (target !== state || seq !== state.searchSeq || openSeq !== state.openSeq || state.searchIndex !== index || state.pageIndex !== match.page) { return; } renderSearchHighlights(match.page); @@ -10928,7 +10940,7 @@ async function selectSearchMatch(index) { if (state.editing) { const node = [...pageView.pageShell(match.page).querySelectorAll(".edit-object")].find(node => canvasEditor.nodes.get(node)?.id === match.id); const item = node && canvasEditor.nodes.get(node); - if (item?.type === "TextObject") { + if (item?.type === "TextObject" && !match.parts?.length) { state.selectObjects = true; pageView.setPan(false); canvasEditor.setTool(""); @@ -11379,13 +11391,28 @@ function enablePageDrag(button, index) { }); } +function conversionWarningText(warning) { + const location = []; + if (warning.Page > 0) location.push(`第${warning.Page}页`); + if (warning.Offset > 0) location.push(`位置${warning.Offset}`); + const messages = [ + [/^PDF output intents preserved as metadata; not applied to rendering$/, "输出配置仅保留元数据"], + [/^PDF annotation has no usable static appearance$/, "注解缺少可用外观"], + [/^invalid PDF widget appearance replaced from field properties$/, "控件外观已按属性重建"], + [/^PDF 3D rendering unavailable; normal appearance retained:/, "三维内容使用静态外观"], + [/^PDF 3D markup default view visibility applied; view switching not transferred$/, "三维视图仅保留默认状态"] + ]; + const message = messages.find(([pattern]) => pattern.test(warning.Message))?.[1] || warning.Message; + return `${location.length ? location.join(" · ") + ":" : ""}${message}`; +} + function renderMeta(keepDetails = false) { renderSecurity(); el.conversionNotice.hidden = !state.conversionWarnings.length; renderMetaContent(el.conversionWarnings, state.conversionWarnings, () => { el.conversionWarnings.replaceChildren(...state.conversionWarnings.map(warning => { const item = document.createElement("p"); - item.textContent = `第${warning.Page}页 · 位置${warning.Offset}:${warning.Message}`; + item.textContent = conversionWarningText(warning); return item; })); }); diff --git a/assets/webui/ofdgo_print.js b/assets/webui/ofdgo_print.js index 639d1a1..33510b7 100644 --- a/assets/webui/ofdgo_print.js +++ b/assets/webui/ofdgo_print.js @@ -382,6 +382,7 @@ export class PrintDialog { this.root = document.querySelector("#ofdgoPrintDocument") || this.createRoot(); this.root.replaceChildren(); const style = document.createElement("style"); + style.textContent = this.layout.sides.map((side, i) => `@page ofdgoPrint${i}{size:${side.width}mm ${side.height}mm;margin:0}`).join(""); this.root.append(style); this.status("正在准备纸面", true, 0, this.layout.sides.length); for (let i = 0; i < this.layout.sides.length; i++) { @@ -394,7 +395,6 @@ export class PrintDialog { img.src = url; section.append(img); section.style.cssText = `page:ofdgoPrint${i};width:${side.width}mm;height:${side.height}mm`; - style.textContent += `@page ofdgoPrint${i}{size:${side.width}mm ${side.height}mm;margin:0}`; this.root.append(section); await img.decode(); if (this.current(sequence)) this.status("正在准备纸面", true, i + 1, this.layout.sides.length); diff --git a/go.mod b/go.mod index 7cc5e7d..b9c1284 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/xiaoqidun/guigo v0.0.0-20261026011429-36a970cd3fb6 github.com/xiaoqidun/j2kgo v1.0.2 github.com/xiaoqidun/jbig2 v1.1.8 - github.com/xiaoqidun/pdfgo v0.0.0-20261023171626-3473b442eed5 + github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0 golang.org/x/crypto v0.58.0 golang.org/x/image v0.47.0 golang.org/x/sys v0.49.0 diff --git a/go.sum b/go.sum index 0c580b1..16aa632 100644 --- a/go.sum +++ b/go.sum @@ -66,8 +66,8 @@ github.com/xiaoqidun/j2kgo v1.0.2 h1:OAjjEOfaBaQzBNcj9wx+eM68UI5pewR6SzNZa1R1DIw github.com/xiaoqidun/j2kgo v1.0.2/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA= github.com/xiaoqidun/jbig2 v1.1.8 h1:dERPW7F8gs18a/DOD9gXHR3fP1Vl6KhCNsLnq5l3BIo= github.com/xiaoqidun/jbig2 v1.1.8/go.mod h1:9jromqVwMqlptUDEt6Yip8vqYA5I1YjhG7FgMl/Z2dA= -github.com/xiaoqidun/pdfgo v0.0.0-20261023171626-3473b442eed5 h1:TudqqH4zfCt7jrBTN/yhMQqa7BvkozYJoF9q+T6FE6s= -github.com/xiaoqidun/pdfgo v0.0.0-20261023171626-3473b442eed5/go.mod h1:AaabzsObZ8Z76AcaWbo6swRROrJsblyIGB7HbieeJA4= +github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0 h1:fBOCrYQedZ3ZxAlpI5bXeVs+/kWxWbVrt0iFlmTYMpU= +github.com/xiaoqidun/pdfgo v0.0.0-20261026125713-90454771d5d0/go.mod h1:AaabzsObZ8Z76AcaWbo6swRROrJsblyIGB7HbieeJA4= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA= diff --git a/internal/webui/webui.go b/internal/webui/webui.go index fc6e26d..957ac4a 100644 --- a/internal/webui/webui.go +++ b/internal/webui/webui.go @@ -611,7 +611,7 @@ func (s *Session) PageText(index int) (*ofdgo.PageText, error) { text := s.cachedPageText(index) if text == nil { var err error - text, err = s.readPageText(index) + text, err = s.readPageText(context.Background(), index) if err != nil { return nil, err } @@ -651,7 +651,7 @@ func (s *Session) PageTextString(index int) (string, error) { text := s.cachedPageText(index) if text == nil { var err error - text, err = s.readPageText(index) + text, err = s.readPageText(context.Background(), index) if err != nil { return "", err } @@ -663,11 +663,37 @@ func (s *Session) PageTextString(index int) (string, error) { // 入参: index 页面索引, query 搜索文字 // 返回: []ofdgo.TextMatch 匹配结果, error 错误信息 func (s *Session) SearchPage(index int, query string) ([]ofdgo.TextMatch, error) { - text, err := s.PageText(index) - if err != nil { + return s.SearchPageContext(context.Background(), index, query, 0) +} + +// SearchPageContext 搜索页面文字并限制累计结果,零上限返回全部,取消时不返回部分结果 +// 入参: ctx 取消上下文, index 页面索引, query 文字, limit 结果上限 +// 返回: []ofdgo.TextMatch 匹配结果, error 取消或读取错误 +func (s *Session) SearchPageContext(ctx context.Context, index int, query string, limit int) ([]ofdgo.TextMatch, error) { + if err := ctx.Err(); err != nil { return nil, err } - return text.Search(query), nil + text := s.cachedPageText(index) + if text == nil { + var err error + text, err = s.readPageText(ctx, index) + if err != nil { + return nil, err + } + s.cachePageText(index, text) + } + var matches []ofdgo.TextMatch + err := text.SearchEach(ctx, query, func(match ofdgo.TextMatch) error { + matches = append(matches, match) + if limit > 0 && len(matches) >= limit { + return io.EOF + } + return nil + }) + if err != nil && err != io.EOF { + return nil, err + } + return matches, nil } // Summary 获取首屏所需信息,不解析页面图元或执行验签 @@ -1018,13 +1044,13 @@ func (s *Session) scanFontInfoBatch() bool { } // readPageText 复用已加载页面,否则只解析文字所需图元 -// 入参: index 页面索引 +// 入参: ctx 取消上下文, index 页面索引 // 返回: *ofdgo.PageText 页面文字, error 错误信息 -func (s *Session) readPageText(index int) (*ofdgo.PageText, error) { +func (s *Session) readPageText(ctx context.Context, index int) (*ofdgo.PageText, error) { if page := s.pageCache[index]; page != nil { - return s.Renderer.PageText(page) + return s.Renderer.PageTextContext(ctx, page) } - return s.Renderer.PageTextByIndex(index) + return s.Renderer.PageTextByIndexContext(ctx, index) } // pageInfos 获取全部真实页面尺寸,首屏、配置及详情共用区域缓存 @@ -1549,7 +1575,6 @@ func (e *businessEngine) finishPDF() ([]byte, error) { if err != nil { return nil, e.pdfError(err) } - reader, err := editor.Reader(e.pdf.operation) if err != nil { return nil, err @@ -3001,7 +3026,11 @@ func (e *businessEngine) searchPage(args []platformValue) (any, error) { if e.currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } - return e.currentSession.SearchPage(args[0].Int(), args[1].String()) + limit := 10001 + if len(args) > 2 { + limit = max(1, min(limit, args[2].Int())) + } + return e.currentSession.SearchPageContext(e.operationContext(), args[0].Int(), args[1].String(), limit) } // exportFormats 获取导出格式 @@ -3387,7 +3416,9 @@ func (e *businessEngine) editorObjects(index int, text *ofdgo.PageText) ([]any, } if object.Type == "CompositeObject" { if value, richErr := e.currentEditor.RichText(index, id); richErr == nil { - e.editorRichText(index, item, object, value, fontNames) + if local, matrix, err := e.currentEditor.RichTextFrame(index, id); err == nil { + e.editorRichText(item, object, value, fontNames, local, matrix) + } } } objects = append(objects, item) @@ -3975,6 +4006,11 @@ func (e *businessEngine) compositeObjects(args []platformValue) (any, error) { item["imageBounds"] = e.editorBox(box) item["cropped"] = member.Cropped } + if member.Object.Type == "CompositeObject" { + if value, frame, matrix, err := e.currentEditor.CompositeRichText(args[0].Int(), member); err == nil { + e.editorRichText(item, member.Object, value, fontNames, frame, matrix) + } + } objects = append(objects, item) } return objects, nil @@ -4831,13 +4867,9 @@ func (e *businessEngine) styleText(args []platformValue) (any, error) { } // editorRichText 将富文本复合图元作为一个文本框提供给编辑器 -// 入参: page 页面索引, item 浏览器对象, object 标准复合图元, value 富文本, fonts 字体名称 -func (e *businessEngine) editorRichText(page int, item map[string]any, object ofdgo.GraphicObject, value ofdgo.RichText, fonts map[string]string) { - if len(value.Runs) == 0 { - return - } - local, matrix, err := e.currentEditor.RichTextFrame(page, object.CompositeGraphicUnit.ID) - if err != nil { +// 入参: item 浏览器对象, object 标准复合图元, value 富文本, fonts 字体名称, local 局部编辑框, matrix 页面变换 +func (e *businessEngine) editorRichText(item map[string]any, object ofdgo.GraphicObject, value ofdgo.RichText, fonts map[string]string, local ofdgo.Box, matrix ofdgo.Matrix) { + if len(value.Runs) == 0 || object.CompositeGraphicUnit.Clips != nil { return } width, height := local.W, local.H @@ -4957,7 +4989,7 @@ func (e *businessEngine) readRichText(value platformValue) (ofdgo.RichText, erro } // saveRichText 原子保存文字、样式和新增字体,空标识创建文本框 -// 入参: args 页码、标识、富文本JSON、新建边界和可选字体集合 +// 入参: args 页码、标识、富文本JSON、新建边界、可选字体集合和父路径 // 返回: any 文档信息及文字选区, error 错误信息 func (e *businessEngine) saveRichText(args []platformValue) (any, error) { value, err := e.readRichText(args[2]) @@ -4971,6 +5003,31 @@ func (e *businessEngine) saveRichText(args []platformValue) (any, error) { return err } } + if len(args) > 5 && args[5].String() != "" { + key := args[5].String() + path, err := e.compositePath(key) + if err != nil { + return err + } + box := args[3] + if id != "" { + indexes, err := e.compositeIndexes(key, []string{id}) + if err != nil { + return err + } + width, offset := 0.0, 0.0 + if !box.IsNull() && !box.IsUndefined() && box.Get("frameWidth").Type() == platformTypeNumber { + width = box.Get("frameWidth").Float() + if box.Get("frameOffset").Type() == platformTypeNumber { + offset = box.Get("frameOffset").Float() + } + } + return e.currentEditor.UpdateCompositeRichText(page, path, indexes[0], value, offset, width) + } + index, err := e.currentEditor.AddCompositeRichText(page, path, ofdgo.Box{X: box.Get("x").Float(), Y: box.Get("y").Float(), W: box.Get("width").Float(), H: box.Get("height").Float()}, value) + id = fmt.Sprintf("%s/%d", key, index) + return err + } if id != "" { if err := e.currentEditor.UpdateRichText(page, id, value); err != nil { return err @@ -7893,6 +7950,5 @@ func failureResult(err error) apiResult { if errors.As(err, &result.MissingGlyphs) { result.ReasonCode = ofdgo.EditMissingGlyphs } - return result } diff --git a/ofdgo_editor_action.go b/ofdgo_editor_action.go index 8fdc2ce..00e61a8 100644 --- a/ofdgo_editor_action.go +++ b/ofdgo_editor_action.go @@ -101,7 +101,7 @@ func (e *Editor) SetPageActions(index int, actions []Action) error { } before, after := cloneEditorData(page.Actions), cloneEditorData(actions) page.Actions = cloneEditorData(after) - if change := e.recordChange(); change != nil { + if change := e.recordChange(index); change != nil { change.undo = func(e *Editor) { e.pages[index].Actions = cloneEditorData(before) } change.redo = func(e *Editor) { e.pages[index].Actions = cloneEditorData(after) } } diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go index 6dc2fa4..a941290 100644 --- a/ofdgo_editor_composite.go +++ b/ofdgo_editor_composite.go @@ -418,6 +418,7 @@ func (e *Editor) compositeScope(page int, path ObjectPath) (*Reader, *Renderer, if err != nil { return nil, nil, nil, nil, err } + root.node.parent = &editorXML{name: xml.Name{Local: "Layer"}, attrs: []xml.Attr{{Name: xml.Name{Local: "ID"}, Value: layer.ID}, {Name: xml.Name{Local: "DrawParam"}, Value: layer.DrawParam}}, parent: &editorXML{name: xml.Name{Local: "Content"}}} root.setStates(object.CompositeGraphicUnit.states) reader, err := e.reader(nil) if err != nil { @@ -848,6 +849,7 @@ func (e *Editor) editCompositeScope(page int, path ObjectPath, edit func(*Render next.setStates(root.states) next.object.CompositeGraphicUnit.states = next.states origin := e.objectOrigin(path.ID) + next.node.parent = root.node.parent updated := &editorObjectOrigin{data: data, node: next.node, object: next.object} if origin != nil { next.node.parent = origin.node.parent diff --git a/ofdgo_editor_composite_copy.go b/ofdgo_editor_composite_copy.go index be3fe47..0f181b1 100644 --- a/ofdgo_editor_composite_copy.go +++ b/ofdgo_editor_composite_copy.go @@ -16,12 +16,13 @@ package ofdgo import ( "bytes" - "encoding/xml" + "errors" "fmt" - basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" "maps" "slices" + + basegeom "github.com/xiaoqidun/ofdgo/internal/base/geom" ) // CompositeSelection 同一内部容器的独立选区快照,共享不可变字体和图片资源 @@ -36,6 +37,7 @@ type CompositeSelection struct { containers []int nodes []*editorCompositeNode source *editorSourcePage + richText map[int]richTextMetadata } // PasteCompositeSelection 将内部对象快照粘贴到页面顶层,保持嵌套结构、源继承外观和裁剪 @@ -73,9 +75,11 @@ func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []in slices.Sort(indexes) var owner *editorCompositeNode var container *editorXML - selection := &CompositeSelection{editor: e, document: e.documentIdentity(), page: e.pages[page].ID, path: ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: slices.Clone(path.Children)}} + selection := &CompositeSelection{richText: make(map[int]richTextMetadata), editor: e, document: e.documentIdentity(), page: e.pages[page].ID, path: ObjectPath{ID: path.ID, Annotation: path.Annotation, Children: slices.Clone(path.Children)}} if path.Annotation == "" { - selection.source = e.objectOrigin(path.ID).page + if origin := e.objectOrigin(path.ID); origin != nil { + selection.source = origin.page + } } else if e.source != nil { selection.source = e.source.pages[e.pages[page].ID] } @@ -95,6 +99,11 @@ func (e *Editor) CaptureCompositeObjects(page int, path ObjectPath, indexes []in copy := *node copy.owner, copy.span = nil, nil selection.nodes = append(selection.nodes, ©) + if entry, err := e.richTextEntry(page, editorObjectID(node.object)); err == nil { + selection.richText[j] = cloneEditorData(entry.metadata) + } else if !errors.Is(err, ErrNotRichText) { + return nil, err + } } scope := root.loadedScope(path.Children) for scope != owner { @@ -134,62 +143,68 @@ func (e *Editor) PasteCompositeObjects(page int, path ObjectPath, selection *Com return result, err } var result []int - err := e.editCompositeScope(page, path, func(renderer *Renderer, root *editorCompositeNode, _ []*editorCompositeNode) error { - scope := root.loadedScope(path.Children) - owner := scope - for i := 0; i < selection.references; i++ { - owner = owner.ref - if owner == nil { - return fmt.Errorf("composite resource container no longer exists") + err := e.Transaction(func(e *Editor) error { + err := e.editCompositeScope(page, path, func(renderer *Renderer, root *editorCompositeNode, _ []*editorCompositeNode) error { + scope := root.loadedScope(path.Children) + owner := scope + for i := 0; i < selection.references; i++ { + owner = owner.ref + if owner == nil { + return fmt.Errorf("composite resource container no longer exists") + } } - } - container := owner.node - for _, index := range selection.containers { - containers := compositeContainers(container) - if index >= len(containers) { - return fmt.Errorf("composite page block no longer exists") + container := owner.node + for _, index := range selection.containers { + containers := compositeContainers(container) + if index >= len(containers) { + return fmt.Errorf("composite page block no longer exists") + } + container = containers[index] } - container = containers[index] - } - box, _ := ParseBox(owner.object.CompositeGraphicUnit.Boundary) - parent := owner.parent.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(NewMatrix(owner.object.CompositeGraphicUnit.CTM)) - nodes := make([]*editorCompositeNode, len(selection.nodes)) - for i, node := range selection.nodes { - copy := *node - copy.parent, copy.boundaryInCTM = parent, owner.boundaryInCTM - nodes[i] = © - } - data, states, err := e.copyCompositeNodes(renderer, nodes, dx, dy) + box, _ := ParseBox(owner.object.CompositeGraphicUnit.Boundary) + parent := owner.parent.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(NewMatrix(owner.object.CompositeGraphicUnit.CTM)) + nodes := make([]*editorCompositeNode, len(selection.nodes)) + for i, node := range selection.nodes { + copy := *node + copy.parent, copy.boundaryInCTM = parent, owner.boundaryInCTM + nodes[i] = © + } + data, states, err := e.copyCompositeNodes(renderer, nodes, dx, dy) + if err != nil { + return err + } + maps.Copy(owner.states, states) + next, reached := 0, false + var count func(*editorCompositeNode) + count = func(node *editorCompositeNode) { + if node.ref != nil { + count(node.ref) + } + if reached { + return + } + for _, child := range node.children { + if node != owner || child.span.start < container.close { + next++ + } + } + reached = node == owner + } + count(scope) + patch := editorXMLPatch{container.close, container.close, data} + if container.open == container.end { + patch = editorXMLContent(owner.data, container, data) + } + owner.patches = append(owner.patches, patch) + for i := range nodes { + result = append(result, next+i) + } + return nil + }) if err != nil { return err } - maps.Copy(owner.states, states) - next, reached := 0, false - var count func(*editorCompositeNode) - count = func(node *editorCompositeNode) { - if node.ref != nil { - count(node.ref) - } - if reached { - return - } - for _, child := range node.children { - if node != owner || child.span.start < container.close { - next++ - } - } - reached = node == owner - } - count(scope) - patch := editorXMLPatch{container.close, container.close, data} - if container.open == container.end { - patch = editorXMLContent(owner.data, container, data) - } - owner.patches = append(owner.patches, patch) - for i := range nodes { - result = append(result, next+i) - } - return nil + return e.putCompositeRichTextCopies(page, path, result, selection.richText) }) if err != nil { return nil, err @@ -226,7 +241,7 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu renderer := e.newRenderer(reader) var objects []GraphicObject var remaps []map[string]string - for _, source := range selection.nodes { + for i, source := range selection.nodes { copy, err := newEditorCompositeNode(source.data) if err != nil { return err @@ -329,6 +344,14 @@ func (e *Editor) pasteCompositeSelection(selection *CompositeSelection, paste fu copy.object.CompositeGraphicUnit.states = object.CompositeGraphicUnit.states } object.origin = &editorObjectOrigin{editor: e, document: e.documentIdentity(), page: selection.source, data: copy.data, node: copy.node, object: copy.object} + if meta, ok := selection.richText[i]; ok { + unit, err := e.compositeDefinition(object.CompositeGraphicUnit.ResourceID) + if err != nil { + return err + } + meta.Digest = richTextDigest(unit.object.CompositeGraphicUnit.Objects) + object.richText = &meta + } objects = append(objects, object) remaps = append(remaps, copy.remaps...) } diff --git a/ofdgo_editor_composite_insert.go b/ofdgo_editor_composite_insert.go index f77fa45..f0cdfc7 100644 --- a/ofdgo_editor_composite_insert.go +++ b/ofdgo_editor_composite_insert.go @@ -40,6 +40,29 @@ func (e *Editor) AddCompositeObject(page int, path ObjectPath, object GraphicObj // 入参: page 页面索引, path 父路径, objects 当前文档快照或基本对象, dx 页面横向位移, dy 页面纵向位移 // 返回: []int 新成员序号, error 错误信息 func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []GraphicObject, dx, dy float64) ([]int, error) { + metadata, err := e.copiedRichTextMetadata(objects) + if err != nil { + return nil, err + } + if len(metadata) == 0 { + return e.copyObjectsToComposite(page, path, objects, dx, dy) + } + var indexes []int + err = e.Transaction(func(e *Editor) error { + var err error + indexes, err = e.copyObjectsToComposite(page, path, objects, dx, dy) + if err != nil { + return err + } + return e.putCompositeRichTextCopies(page, path, indexes, metadata) + }) + return indexes, err +} + +// copyObjectsToComposite 插入对象原文,由调用方同步富文本语义 +// 入参: page 页面索引, path 父路径, objects 对象快照, dx 横向位移, dy 纵向位移 +// 返回: []int 成员序号, error 校验或提交错误 +func (e *Editor) copyObjectsToComposite(page int, path ObjectPath, objects []GraphicObject, dx, dy float64) ([]int, error) { if !finite(dx) || !finite(dy) { return nil, fmt.Errorf("copy requires finite offsets") } @@ -49,6 +72,9 @@ func (e *Editor) CopyObjectsToComposite(page int, path ObjectPath, objects []Gra return nil } owner := root.loadedScope(path.Children) + if owner.ref != nil { + owner = owner.ref + } box, _ := ParseBox(owner.object.CompositeGraphicUnit.Boundary) parent := owner.parent.Multiply(TranslationMatrix(box.X, box.Y)).Multiply(NewMatrix(owner.object.CompositeGraphicUnit.CTM)) inverse, ok := parent.Invert() diff --git a/ofdgo_editor_composite_ops.go b/ofdgo_editor_composite_ops.go index 359daef..196b12a 100644 --- a/ofdgo_editor_composite_ops.go +++ b/ofdgo_editor_composite_ops.go @@ -17,6 +17,7 @@ package ofdgo import ( "bytes" "context" + "errors" "fmt" "maps" "slices" @@ -45,31 +46,48 @@ func (e *Editor) CopyCompositeObjects(page int, path ObjectPath, indexes []int, return nil, fmt.Errorf("copy requires finite offsets") } var result []int - err := e.editCompositeObjects(page, path, indexes, func(renderer *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { - container := nodes[0].span.parent - for i, node := range nodes { - if !members[i].Capabilities.Copy || node.span.parent != container { - return fmt.Errorf("copy requires editable members in the same container") + metadata := make(map[int]richTextMetadata) + err := e.Transaction(func(e *Editor) error { + err := e.editCompositeObjects(page, path, indexes, func(renderer *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + container := nodes[0].span.parent + for i, node := range nodes { + if !members[i].Capabilities.Copy || node.span.parent != container { + return fmt.Errorf("copy requires editable members in the same container") + } } - } - slices.SortFunc(nodes, func(a, b *editorCompositeNode) int { return a.index - b.index }) - data, states, err := e.copyCompositeNodes(renderer, nodes, dx, dy) + slices.SortFunc(nodes, func(a, b *editorCompositeNode) int { return a.index - b.index }) + for i, node := range nodes { + if node.object.Type == "CompositeObject" { + entry, err := e.richTextEntry(page, editorObjectID(node.object)) + if err == nil { + metadata[i] = entry.metadata + } else if !errors.Is(err, ErrNotRichText) { + return err + } + } + } + data, states, err := e.copyCompositeNodes(renderer, nodes, dx, dy) + if err != nil { + return err + } + owner := nodes[0].owner + maps.Copy(owner.states, states) + owner.patches = append(owner.patches, editorXMLPatch{container.close, container.close, data}) + next := 0 + for _, member := range owner.children { + if container.start < member.span.start && member.span.end <= container.close { + next = member.index + 1 + } + } + for i := range nodes { + result = append(result, next+i) + } + return nil + }) if err != nil { return err } - owner := nodes[0].owner - maps.Copy(owner.states, states) - owner.patches = append(owner.patches, editorXMLPatch{container.close, container.close, data}) - next := 0 - for _, member := range owner.children { - if container.start < member.span.start && member.span.end <= container.close { - next = member.index + 1 - } - } - for i := range nodes { - result = append(result, next+i) - } - return nil + return e.putCompositeRichTextCopies(page, path, result, metadata) }) if err != nil { return nil, err diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 0414cee..54c73db 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -711,6 +711,7 @@ func editorPreservedObject(before, after GraphicObject) bool { } before = cloneEditorData(before) before.state = after.state + before.richText = after.richText switch before.Type { case "TextObject": a, b := &before.TextObject, after.TextObject diff --git a/ofdgo_editor_rich_text.go b/ofdgo_editor_rich_text.go index aa0657c..b7c4976 100644 --- a/ofdgo_editor_rich_text.go +++ b/ofdgo_editor_rich_text.go @@ -56,6 +56,7 @@ type richTextEntry struct { type richTextIndex struct { source *editorSource revision uint64 + remaps map[string]int entries map[string]richTextEntry err error } @@ -204,13 +205,20 @@ func (e *Editor) RichText(page int, id string) (RichText, error) { if err != nil { return RichText{}, err } + return e.richTextObject(page, object) +} + +// richTextObject 读取对象的编辑标引并校验标准内容 +// 入参: page 页面索引, object 当前对象 +// 返回: RichText 编辑内容, error 识别或持久化错误 +func (e *Editor) richTextObject(page int, object GraphicObject) (RichText, error) { if object.Type == "TextObject" { return e.promoteRichText(page, object) } if object.Type != "CompositeObject" { return RichText{}, ErrNotRichText } - entry, err := e.richTextEntry(page, id) + entry, err := e.richTextEntry(page, editorObjectID(object)) if err != nil { return RichText{}, err } @@ -275,6 +283,163 @@ func (e *Editor) RichTextFrame(page int, id string) (Box, Matrix, error) { return entry.metadata.Frame, TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(object.CompositeGraphicUnit.CTM)).Multiply(TranslationMatrix(entry.metadata.Offset[0], entry.metadata.Offset[1])), nil } +// AddCompositeRichText 向内部范围添加富文本,使用页面坐标并保存编辑标引 +// 入参: page 页面索引, path 父路径, box 页面编辑框, value 富文本 +// 返回: int 新成员序号, error 排版或提交错误 +func (e *Editor) AddCompositeRichText(page int, path ObjectPath, box Box, value RichText) (int, error) { + var index int + err := e.Transaction(func(e *Editor) error { + if _, err := creationBox(editorBoxString(box)); err != nil { + return err + } + object, meta, err := e.prepareRichText(box, value) + if err != nil { + return err + } + index, err = e.AddCompositeObject(page, path, object) + if err != nil { + return err + } + member, err := e.CompositeObject(page, path, index) + if err != nil { + return err + } + return e.putCompositeRichTextMetadata(page, member.Object, meta, -1) + }) + return index, err +} + +// CompositeRichText 读取内部富文本值及局部编辑框,不提升未标引的普通文字 +// 入参: page 页面索引, member 当前内部成员快照 +// 返回: RichText 编辑内容, Box 局部编辑框, Matrix 页面变换, error 识别或持久化错误 +func (e *Editor) CompositeRichText(page int, member CompositeMember) (RichText, Box, Matrix, error) { + if _, err := e.page(page); err != nil { + return RichText{}, Box{}, Matrix{}, err + } + if member.Object.Type != "CompositeObject" { + return RichText{}, Box{}, Matrix{}, ErrNotRichText + } + value, err := e.richTextObject(page, member.Object) + if err != nil { + return RichText{}, Box{}, Matrix{}, err + } + entry, err := e.richTextEntry(page, editorObjectID(member.Object)) + if err != nil { + return RichText{}, Box{}, Matrix{}, err + } + return value, entry.metadata.Frame, member.Matrix.Multiply(TranslationMatrix(entry.metadata.Offset[0], entry.metadata.Offset[1])), nil +} + +// UpdateCompositeRichText 原子重排内部富文本,保留父变换并隔离共享实例 +// 入参: page 页面索引, path 父路径, index 成员序号, value 富文本, offset 局部横向偏移, width 新段落宽度,零值保留 +// 返回: error 校验或提交错误 +func (e *Editor) UpdateCompositeRichText(page int, path ObjectPath, index int, value RichText, offset, width float64) error { + if !finite(offset) || !finite(width) || width < 0 || width == 0 && offset != 0 { + return fmt.Errorf("invalid rich text frame") + } + return e.Transaction(func(e *Editor) error { + var metadata richTextMetadata + entryIndex := -1 + changed := false + err := e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + node := nodes[0] + current, frame, _, err := e.CompositeRichText(page, members[0]) + if err != nil { + return fmt.Errorf("read composite member %s: %w", editorObjectID(members[0].Object), err) + } + if err := value.Validate(); err != nil { + return err + } + if offset == 0 && (width == 0 || width == frame.W) && reflect.DeepEqual(normalizeRichText(current), normalizeRichText(value)) { + return nil + } + if node.object.CompositeGraphicUnit.Clips != nil { + return fmt.Errorf("clipped rich text requires explicit clip removal before reflow") + } + entry, err := e.richTextEntry(page, editorObjectID(node.object)) + if err != nil { + return fmt.Errorf("read composite node %s: %w", editorObjectID(node.object), err) + } + object := cloneEditorData(node.object) + if !node.boundaryInCTM { + object = compositeBoundary(object, node.parent, true) + } + unit := &object.CompositeGraphicUnit + box, err := creationBox(unit.Boundary) + if err != nil { + return err + } + matrix := TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(unit.CTM)).Multiply(TranslationMatrix(entry.metadata.Offset[0]+offset, entry.metadata.Offset[1])) + if width > 0 { + frame.W = width + } + next, meta, err := e.prepareRichText(frame, value) + if err != nil { + return err + } + placement := richTextPlacement(meta, matrix) + unit.ResourceID, unit.Boundary, unit.CTM = next.CompositeGraphicUnit.ResourceID, placement.Boundary, placement.CTM + if !node.boundaryInCTM { + object = compositeBoundary(object, node.parent, false) + } + metadata, entryIndex, changed = meta, entry.index, true + return node.update(object) + }) + if err != nil || !changed { + return err + } + member, err := e.CompositeObject(page, path, index) + if err != nil { + return err + } + if entry, err := e.richTextEntry(page, editorObjectID(member.Object)); err == nil { + entryIndex = entry.index + } else if !errors.Is(err, ErrNotRichText) { + return err + } + return e.putCompositeRichTextMetadata(page, member.Object, metadata, entryIndex) + }) +} + +// putCompositeRichTextMetadata 在容器坐标规范化后更新标准内容摘要和编辑标引 +// 入参: page 页面索引, object 当前成员, meta 编辑信息, index 原入口或-1 +// 返回: error 资源或提交错误 +func (e *Editor) putCompositeRichTextMetadata(page int, object GraphicObject, meta richTextMetadata, index int) error { + unit, err := e.compositeDefinition(object.CompositeGraphicUnit.ResourceID) + if err != nil { + return err + } + meta.Digest = richTextDigest(unit.object.CompositeGraphicUnit.Objects) + return e.putRichTextMetadata(page, editorObjectID(object), meta, index) +} + +// putCompositeRichTextCopies 一次读取复制后的选区,按对象顺序恢复富文本标引 +// 入参: page 页面索引, path 父路径, indexes 新成员序号, metadata 选区位置对应的编辑语义 +// 返回: error 读取或提交错误 +func (e *Editor) putCompositeRichTextCopies(page int, path ObjectPath, indexes []int, metadata map[int]richTextMetadata) error { + if len(metadata) == 0 { + return nil + } + selected := make([]int, 0, len(metadata)) + values := make([]richTextMetadata, 0, len(metadata)) + for i, index := range indexes { + if meta, ok := metadata[i]; ok { + selected = append(selected, index) + values = append(values, meta) + } + } + members, err := e.CompositeObjectsAt(page, path, selected) + if err != nil { + return err + } + for i, member := range members { + if err := e.putCompositeRichTextMetadata(page, member.Object, values[i], -1); err != nil { + return err + } + } + return nil +} + // prepareRichText 构造独立组合资源和不含字体编号的编辑标引 // 入参: box 页面或局部编辑框, value 富文本 // 返回: GraphicObject 标准组合, richTextMetadata 标引, error 准备错误 @@ -385,9 +550,15 @@ func (e *Editor) richTextEntry(page int, id string) (richTextEntry, error) { // 返回: *richTextIndex 只读缓存 func (e *Editor) richTextMetadataIndex() *richTextIndex { if index := e.richIndex; index != nil && index.source == e.source && index.revision == e.revision { - return index + valid := len(index.remaps) == len(e.structureRemaps) + for page, remaps := range e.structureRemaps { + valid = valid && index.remaps[page] == len(remaps) + } + if valid { + return index + } } - index := &richTextIndex{source: e.source, revision: e.revision, entries: make(map[string]richTextEntry)} + index := &richTextIndex{source: e.source, revision: e.revision, remaps: e.structureRemapStarts(), entries: make(map[string]richTextEntry)} e.richIndex = index if e.source == nil { return index @@ -434,6 +605,17 @@ func (e *Editor) richTextMetadataIndex() *richTextIndex { } } } + for page, remaps := range e.structureRemaps { + for _, ids := range remaps { + for from, to := range ids { + if _, exists := index.entries[page+"/"+to]; !exists { + if entry, ok := index.entries[page+"/"+from]; ok { + index.entries[page+"/"+to] = entry + } + } + } + } + } return index } @@ -447,7 +629,26 @@ func (e *Editor) putRichTextMetadata(page int, id string, meta richTextMetadata, } root := StructureNode{Name: "RichText", Namespace: richTextNamespace, Text: string(data)} if index >= 0 { - return e.UpdateStructureNode(index, nil, root) + separate := false + err := e.editStructure(index, func(data []byte, node *editorXML, tag CustomTag) ([]byte, error) { + updated, err := rewriteStructureReferences(context.Background(), data, tag, func(pageID string, ids []string) (string, []string, error) { + var remaining []string + for _, objectID := range ids { + if editorResourceID(pageID) != editorResourceID(e.pages[page].ID) || editorResourceID(objectID) != editorResourceID(id) { + remaining = append(remaining, objectID) + separate = true + } + } + return pageID, remaining, nil + }) + if err != nil || separate { + return updated, err + } + return updateStructureNodeXML(data, node, root) + }) + if err != nil || !separate { + return err + } } root.Children = []StructureNode{{Name: "ObjectRef", Namespace: richTextNamespace, Attributes: []StructureAttribute{{Name: "PageRef", Value: e.pages[page].ID}}, Text: id}} _, err = e.AddStructure(CustomTag{TypeID: richTextTagType, NameSpace: richTextNamespace}, root) diff --git a/ofdgo_editor_structure_content.go b/ofdgo_editor_structure_content.go index 907617f..b0a3cb8 100644 --- a/ofdgo_editor_structure_content.go +++ b/ofdgo_editor_structure_content.go @@ -20,6 +20,7 @@ import ( "encoding/xml" "fmt" "maps" + "path" "slices" "strings" ) @@ -337,7 +338,7 @@ func editorStructureIndexContext(ctx context.Context, base *editorSource, parts if base.document.CustomTags.Path == "" { container := root.child("CustomTags") if container == nil || len(container.children) == 0 { - name = packageAvailableName(base.reader, nil, base.reader.RootDir+"/CustomTags.xml") + name = packageAvailableName(base.reader, nil, path.Join(base.reader.RootDir, "CustomTags.xml")) data, err = root.containerXML("CustomTags", nil, nil) if err == nil { root, err = parseEditorXMLContext(ctx, data) diff --git a/ofdgo_editor_text.go b/ofdgo_editor_text.go index d5dd8ac..7cb057e 100644 --- a/ofdgo_editor_text.go +++ b/ofdgo_editor_text.go @@ -187,7 +187,7 @@ func (e *Editor) UpdateTextContent(page int, id, value string, style TextStyle) // RewriteText 保留原文的字形定位,不修改对象样式或文档 // 等长替换保留各字原位;单段增删保留前缀和后缀步进,新增字符沿用局部字距 -// 方向、缩放和修饰原样保留;复杂布局仅支持等长替换,合并字形需显式重排 +// 方向、缩放和修饰原样保留;单段纵排沿用现有纵向步进,其他复杂布局仅支持等长替换,合并字形需显式重排 // 首段缺失坐标仅在临时校验中恢复,不改写源坐标 // 入参: obj 原文字对象, value 新内容 // 返回: error 错误信息 @@ -210,7 +210,8 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error { return err } old, next := []rune(obj.Text()), []rune(value) - complex := obj.ReadDirection != 0 || obj.CharDirection != 0 || len(obj.CGTransform) != 0 || obj.VScale != 0 || obj.Decoration != "" + vertical := (obj.ReadDirection == 90 || obj.ReadDirection == 270) && obj.CharDirection == 0 && len(obj.TextCode) == 1 && obj.TextCode[0].DeltaY != "" + complex := obj.ReadDirection != 0 && !vertical || obj.CharDirection != 0 || len(obj.CGTransform) != 0 || obj.VScale != 0 || obj.Decoration != "" if complex && len(old) != len(next) { return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing positioned text length requires explicit layout")} } @@ -279,7 +280,12 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error { if obj.HScale != 0 { unit *= obj.HScale } - advance := func(char rune) float64 { return float64(sfnt.GlyphAdvance(sfnt.GlyphIndex(char))) * unit } + advance := func(char rune) float64 { + if vertical { + return 0 + } + return float64(sfnt.GlyphAdvance(sfnt.GlyphIndex(char))) * unit + } if codes[i].DeltaX != "" { codes[i].DeltaX = rewriteTextDeltas(codes[i].DeltaX, runes, replacement, from, to, advance) } diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index e0fd274..bbd14ce 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -15,6 +15,7 @@ package ofdgo import ( + "bytes" "context" "fmt" "io" @@ -313,10 +314,25 @@ func NewPDFImport(ctx context.Context, input *Input, options PDFImportOptions) ( return nil, fmt.Errorf("import PDF output intents: %w", err) } if len(outputIntents) != 0 { - if options.Strict { - return nil, &pdfgo.UnsupportedError{Feature: "PDF output intent conversion"} + var profile []byte + applicable := true + for _, intent := range outputIntents { + if intent.Subtype != "GTS_PDFA1" && intent.Subtype != "GTS_PDFX" || intent.Space == nil || len(intent.Profile) == 0 || profile != nil && !bytes.Equal(profile, intent.Profile) { + applicable = false + break + } + profile = intent.Profile + } + if applicable { + if err := reader.SetDeviceColorProfile(profile); err != nil { + return nil, fmt.Errorf("import PDF device color profile: %w", err) + } + } else { + if options.Strict { + return nil, &pdfgo.UnsupportedError{Feature: "PDF output intent conversion"} + } + importer.report.Warnings = append(importer.report.Warnings, pdfgo.Diagnostic{Message: "PDF output intents preserved as metadata; not applied to rendering"}) } - importer.report.Warnings = append(importer.report.Warnings, pdfgo.Diagnostic{Message: "PDF output intents preserved as metadata; not applied to rendering"}) } importer.pageIndexes = make(map[pdfgo.Reference]int) importer.resolveFile = options.ResolveFile @@ -475,7 +491,6 @@ func (p *PDFImport) ConvertPage(ctx context.Context, index int) (err error) { return err } } - p.ready[index] = true return nil } diff --git a/ofdgo_print_output.go b/ofdgo_print_output.go index 3850487..1031ae6 100644 --- a/ofdgo_print_output.go +++ b/ofdgo_print_output.go @@ -15,6 +15,7 @@ package ofdgo import ( + "bufio" "bytes" "context" "encoding/base64" @@ -76,18 +77,22 @@ func (p *PrintDocument) renderPrintSVG(ctx context.Context, index int, writer io if writer == nil { return fmt.Errorf("missing print writer") } - var out bytes.Buffer - fmt.Fprintf(&out, ``, side.Width, side.Height, side.Width, side.Height) + out := bufio.NewWriter(convertWriter{ctx: ctx, writer: writer, count: new(int64)}) + fmt.Fprintf(out, ``, side.Width, side.Height, side.Width, side.Height) if p.options.Raster || p.options.Grayscale { img, err := p.renderPrintImage(ctx, index, p.options.DPI) if err != nil { return err } - var data bytes.Buffer - if err := png.Encode(&data, img); err != nil { + fmt.Fprintf(out, ``, side.Width, side.Height, base64.StdEncoding.EncodeToString(data.Bytes())) + if err := encoder.Close(); err != nil { + return err + } + out.WriteString(`"/>`) } else { for i, placement := range side.Placements { if err := ctx.Err(); err != nil { @@ -97,23 +102,26 @@ func (p *PrintDocument) renderPrintSVG(ctx context.Context, index int, writer io if err != nil { return err } - var data bytes.Buffer - if err := p.renderer.RenderToSVG(page, &data); err != nil { + c, m := placement.Clip, placement.Transform + fmt.Fprintf(out, ``, i, c.X, c.Y, c.W, c.H, i, placement.Width, placement.Height, m[0], m[1], m[2], m[3], m[4], m[5], base64.StdEncoding.EncodeToString(data.Bytes())) + if err := encoder.Close(); err != nil { + return err + } + out.WriteString(`"/>`) } for _, line := range p.printLines(side) { - fmt.Fprintf(&out, ``, line[0], line[1], line[2], line[3]) + fmt.Fprintf(out, ``, line[0], line[1], line[2], line[3]) } if side.Label != "" { - fmt.Fprintf(&out, `%s`, side.LabelX, side.LabelY, html.EscapeString(side.Label)) + fmt.Fprintf(out, `%s`, side.LabelX, side.LabelY, html.EscapeString(side.Label)) } } out.WriteString(``) - _, err = io.Copy(convertWriter{ctx: ctx, writer: writer, count: new(int64)}, &out) - return err + return out.Flush() } // RenderImage 按指定DPI输出一个纸面的独立白底图像,dpi为0时采用任务配置 diff --git a/ofdgo_search.go b/ofdgo_search.go index c18df94..a21659b 100644 --- a/ofdgo_search.go +++ b/ofdgo_search.go @@ -20,6 +20,7 @@ import ( "fmt" "io" "math" + "sort" "strings" "unicode" "unicode/utf8" @@ -53,16 +54,26 @@ type TextSpan struct { Matrix [6]float64 `json:"matrix"` } -// TextMatch 文字匹配结果,ID为对象标识,Run为文本对象索引,Start和End为原文Unicode字符区间 +// TextMatch 文字匹配结果,ID和Run定位首个对象,Start和End为该对象内的Unicode字符区间 +// 跨对象匹配通过Parts保留各对象区间,Text和Boxes包含完整命中 type TextMatch struct { - ID string `json:"id"` - Run int `json:"run"` - Start int `json:"start"` - End int `json:"end"` - Before string `json:"before"` - Text string `json:"text"` - After string `json:"after"` - Boxes []Box `json:"boxes"` + ID string `json:"id"` + Run int `json:"run"` + Start int `json:"start"` + End int `json:"end"` + Before string `json:"before"` + Text string `json:"text"` + After string `json:"after"` + Boxes []Box `json:"boxes"` + Parts []TextMatchPart `json:"parts,omitempty"` +} + +// TextMatchPart 跨对象匹配中的原文区间,Start和End为对象内Unicode字符偏移 +type TextMatchPart struct { + ID string `json:"id"` + Run int `json:"run"` + Start int `json:"start"` + End int `json:"end"` } // Token 读取文字页面所需的XML节点 @@ -172,7 +183,7 @@ func (p *PageText) WriteTo(writer io.Writer) (int64, error) { return p.writeTo(writer, false) } -// Search 按原文进行忽略大小写的字面匹配,不跨文本对象拼接或识别图像 +// Search 按原文进行忽略大小写的字面匹配,拼接同一行紧邻的文本对象,不识别图像 // 入参: query 搜索文字 // 返回: []TextMatch 匹配结果及上下文 func (p *PageText) Search(query string) []TextMatch { @@ -184,7 +195,7 @@ func (p *PageText) Search(query string) []TextMatch { return matches } -// SearchEach 逐项返回忽略大小写的字面匹配,不累积全部结果,在对象与匹配之间检查取消 +// SearchEach 逐项返回忽略大小写的字面匹配,拼接同一行紧邻对象,在索引构建及匹配之间检查取消 // 入参: ctx 取消上下文, query 搜索文字, visit 匹配访问函数,可返回错误停止 // 返回: error 取消或访问错误 func (p *PageText) SearchEach(ctx context.Context, query string, visit func(TextMatch) error) error { @@ -268,11 +279,39 @@ func (p *PageText) searchEach(ctx context.Context, query string, visit func(Text return ctx.Err() } length := utf8.RuneCountInString(query) - for index, run := range p.Runs { + for first := 0; first < len(p.Runs); { if err := ctx.Err(); err != nil { return err } - text := foldText(run.Text) + last := first + 1 + for last < len(p.Runs) && adjacentTextRuns(p.Runs[last-1], p.Runs[last]) { + if err := ctx.Err(); err != nil { + return err + } + last++ + } + var source strings.Builder + offsets := make([]int, last-first+1) + for index := first; index < last; index++ { + if err := ctx.Err(); err != nil { + return err + } + source.WriteString(p.Runs[index].Text) + offsets[index-first+1] = offsets[index-first] + utf8.RuneCountInString(p.Runs[index].Text) + } + var folded strings.Builder + folded.Grow(source.Len()) + polled := 0 + for _, char := range source.String() { + if polled%256 == 0 { + if err := ctx.Err(); err != nil { + return err + } + } + polled++ + folded.WriteRune(foldRune(char)) + } + text := folded.String() var runes []rune codeOffset := 0 for offset := 0; offset < len(text); { @@ -284,21 +323,24 @@ func (p *PageText) searchEach(ctx context.Context, query string, visit func(Text break } if runes == nil { - runes = []rune(run.Text) + runes = []rune(source.String()) } start := codeOffset + utf8.RuneCountInString(text[offset:offset+found]) offset += found end := start + length - match := TextMatch{ - ID: run.ID, - Run: index, Start: start, End: end, - Before: string(runes[max(0, start-16):start]), - Text: string(runes[start:end]), - After: string(runes[end:min(len(runes), end+16)]), - } - for _, box := range run.Boxes[min(start, len(run.Boxes)):min(end, len(run.Boxes))] { - if box.W > 0 && box.H > 0 && (len(match.Boxes) == 0 || match.Boxes[len(match.Boxes)-1] != box) { - match.Boxes = append(match.Boxes, box) + begin := sort.Search(len(offsets)-1, func(i int) bool { return offsets[i+1] > start }) + match := TextMatch{ID: p.Runs[first+begin].ID, Run: first + begin, Start: start - offsets[begin], End: min(end, offsets[begin+1]) - offsets[begin], + Before: string(runes[max(0, start-16):start]), Text: string(runes[start:end]), After: string(runes[end:min(len(runes), end+16)])} + for index := begin; index < last-first && offsets[index] < end; index++ { + run := p.Runs[first+index] + from, to := max(start, offsets[index])-offsets[index], min(end, offsets[index+1])-offsets[index] + if end > offsets[begin+1] { + match.Parts = append(match.Parts, TextMatchPart{ID: run.ID, Run: first + index, Start: from, End: to}) + } + for _, box := range run.Boxes[min(from, len(run.Boxes)):min(to, len(run.Boxes))] { + if box.W > 0 && box.H > 0 && (len(match.Boxes) == 0 || match.Boxes[len(match.Boxes)-1] != box) { + match.Boxes = append(match.Boxes, box) + } } } if err := visit(match); err != nil { @@ -307,6 +349,7 @@ func (p *PageText) searchEach(ctx context.Context, query string, visit func(Text offset += len(query) codeOffset = end } + first = last } return ctx.Err() } @@ -315,19 +358,40 @@ func (p *PageText) searchEach(ctx context.Context, query string, visit func(Text // 入参: text 原文 // 返回: string 匹配用文字 func foldText(text string) string { - return strings.Map(func(char rune) rune { - if char < utf8.RuneSelf { - if char >= 'a' && char <= 'z' { - return char - ('a' - 'A') - } - return char + return strings.Map(foldRune, text) +} + +// foldRune 统一单个Unicode大小写等价字符 +// 入参: char 原字符 +// 返回: rune 匹配字符 +func foldRune(char rune) rune { + if char < utf8.RuneSelf { + if char >= 'a' && char <= 'z' { + return char - ('a' - 'A') } - folded := char - for next := unicode.SimpleFold(char); next != char; next = unicode.SimpleFold(next) { - folded = min(folded, next) - } - return folded - }, text) + return char + } + folded := char + for next := unicode.SimpleFold(char); next != char; next = unicode.SimpleFold(next) { + folded = min(folded, next) + } + return folded +} + +// adjacentTextRuns 判断绘制顺序相邻的文字是否在同一行连续,缺少定位时保持独立 +// 入参: before 前对象, after 后对象 +// 返回: bool 是否可连续搜索 +func adjacentTextRuns(before, after TextRun) bool { + if before.Text == "" || after.Text == "" || len(before.Boxes) != utf8.RuneCountInString(before.Text) || len(after.Boxes) == 0 { + return false + } + a, b := before.Boxes[len(before.Boxes)-1], after.Boxes[0] + size := math.Min(a.H, b.H) + if size <= 0 || a.W <= 0 || b.W <= 0 { + return false + } + gap := b.X - a.X - a.W + return math.Abs(a.Y+a.H/2-b.Y-b.H/2) <= size*.25 && gap >= -size*.15 && gap <= size*.35 && b.X > a.X } // addRun 收集原始文本,字形索引不替代Unicode原文