feat(编辑能力): 完善搜索编辑打印与色彩处理
build.yaml / build (push) Successful in 4m37s

This commit is contained in:
xiaoqidun committed 2026-10-26 21:01:38 +08:00
1 parent f1864952ad
commit 41a50d9e96
17 files changed
+531 -83

No files matched your search

+44 -17
View File
@@ -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;
}));
});
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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=
+76 -20
View File
@@ -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)
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
}
text := s.cachedPageText(index)
if text == nil {
var err error
text, err = s.readPageText(ctx, index)
if err != nil {
return nil, err
}
return text.Search(query), nil
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
}
+1 -1
View File
@@ -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) }
}
+2
View File
@@ -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
+28 -5
View File
@@ -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, &copy)
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,6 +143,7 @@ func (e *Editor) PasteCompositeObjects(page int, path ObjectPath, selection *Com
return result, err
}
var result []int
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
@@ -191,6 +201,11 @@ func (e *Editor) PasteCompositeObjects(page int, path ObjectPath, selection *Com
}
return nil
})
if err != nil {
return err
}
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...)
}
+26
View File
@@ -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()
+18
View File
@@ -17,6 +17,7 @@ package ofdgo
import (
"bytes"
"context"
"errors"
"fmt"
"maps"
"slices"
@@ -45,6 +46,8 @@ func (e *Editor) CopyCompositeObjects(page int, path ObjectPath, indexes []int,
return nil, fmt.Errorf("copy requires finite offsets")
}
var result []int
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 {
@@ -53,6 +56,16 @@ func (e *Editor) CopyCompositeObjects(page int, path ObjectPath, indexes []int,
}
}
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
@@ -71,6 +84,11 @@ func (e *Editor) CopyCompositeObjects(page int, path ObjectPath, indexes []int,
}
return nil
})
if err != nil {
return err
}
return e.putCompositeRichTextCopies(page, path, result, metadata)
})
if err != nil {
return nil, err
}
+1
View File
@@ -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
+204 -3
View File
@@ -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 {
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)
+2 -1
View File
@@ -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)
+9 -3
View File
@@ -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)
}
+16 -1
View File
@@ -15,6 +15,7 @@
package ofdgo
import (
"bytes"
"context"
"fmt"
"io"
@@ -313,11 +314,26 @@ func NewPDFImport(ctx context.Context, input *Input, options PDFImportOptions) (
return nil, fmt.Errorf("import PDF output intents: %w", err)
}
if len(outputIntents) != 0 {
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.pageIndexes = make(map[pdfgo.Reference]int)
importer.resolveFile = options.ResolveFile
importer.resolveReference = options.ResolveReference
@@ -475,7 +491,6 @@ func (p *PDFImport) ConvertPage(ctx context.Context, index int) (err error) {
return err
}
}
p.ready[index] = true
return nil
}
+21 -13
View File
@@ -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, `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%gmm" height="%gmm" viewBox="0 0 %g %g"><rect width="100%%" height="100%%" fill="white"/>`, side.Width, side.Height, side.Width, side.Height)
out := bufio.NewWriter(convertWriter{ctx: ctx, writer: writer, count: new(int64)})
fmt.Fprintf(out, `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="%gmm" height="%gmm" viewBox="0 0 %g %g"><rect width="100%%" height="100%%" fill="white"/>`, 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, `<image width="%g" height="%g" xlink:href="data:image/png;base64,`, side.Width, side.Height)
encoder := base64.NewEncoder(base64.StdEncoding, out)
if err := png.Encode(encoder, img); err != nil {
return err
}
fmt.Fprintf(&out, `<image width="%g" height="%g" xlink:href="data:image/png;base64,%s"/>`, 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, `<defs><clipPath id="print%d"><rect x="%g" y="%g" width="%g" height="%g"/></clipPath></defs><g clip-path="url(#print%d)"><image width="%g" height="%g" transform="matrix(%g %g %g %g %g %g)" xlink:href="data:image/svg+xml;base64,`, 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])
encoder := base64.NewEncoder(base64.StdEncoding, out)
if err := p.renderer.RenderToSVG(page, encoder); err != nil {
return err
}
c, m := placement.Clip, placement.Transform
fmt.Fprintf(&out, `<defs><clipPath id="print%d"><rect x="%g" y="%g" width="%g" height="%g"/></clipPath></defs><g clip-path="url(#print%d)"><image width="%g" height="%g" transform="matrix(%g %g %g %g %g %g)" xlink:href="data:image/svg+xml;base64,%s"/></g>`, 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(`"/></g>`)
}
for _, line := range p.printLines(side) {
fmt.Fprintf(&out, `<path d="M %g %g L %g %g" fill="none" stroke="black" stroke-width="0.15"/>`, line[0], line[1], line[2], line[3])
fmt.Fprintf(out, `<path d="M %g %g L %g %g" fill="none" stroke="black" stroke-width="0.15"/>`, line[0], line[1], line[2], line[3])
}
if side.Label != "" {
fmt.Fprintf(&out, `<text x="%g" y="%g" font-family="sans-serif" font-size="2.8" fill="black">%s</text>`, side.LabelX, side.LabelY, html.EscapeString(side.Label))
fmt.Fprintf(out, `<text x="%g" y="%g" font-family="sans-serif" font-size="2.8" fill="black">%s</text>`, side.LabelX, side.LabelY, html.EscapeString(side.Label))
}
}
out.WriteString(`</svg>`)
_, err = io.Copy(convertWriter{ctx: ctx, writer: writer, count: new(int64)}, &out)
return err
return out.Flush()
}
// RenderImage 按指定DPI输出一个纸面的独立白底图像,dpi为0时采用任务配置
+79 -15
View File
@@ -20,6 +20,7 @@ import (
"fmt"
"io"
"math"
"sort"
"strings"
"unicode"
"unicode/utf8"
@@ -53,7 +54,8 @@ 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"`
@@ -63,6 +65,15 @@ type TextMatch struct {
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,29 +323,33 @@ 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)]),
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(start, len(run.Boxes)):min(end, len(run.Boxes))] {
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 {
return err
}
offset += len(query)
codeOffset = end
}
first = last
}
return ctx.Err()
}
@@ -315,7 +358,13 @@ 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 {
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')
@@ -327,7 +376,22 @@ func foldText(text string) string {
folded = min(folded, next)
}
return folded
}, text)
}
// 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原文