mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
842 lines
28 KiB
Go
842 lines
28 KiB
Go
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package ofdgo
|
|
|
|
import (
|
|
"fmt"
|
|
"image/color"
|
|
"math"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"unicode/utf8"
|
|
)
|
|
|
|
// TextSelection 指定同页同一直接容器的文字,Root为空使用IDs,否则使用Indexes
|
|
// 分析返回的选区绑定文档和修订,结构或内容改变后需重新分析
|
|
type TextSelection struct {
|
|
Root *ObjectPath
|
|
IDs []string
|
|
Indexes []int
|
|
editor *Editor
|
|
document editorDocumentIdentity
|
|
revision uint64
|
|
page int
|
|
}
|
|
|
|
// TextSelectionEdit 指定码点范围替换,空Text表示删除,Font覆盖所有剩余文字
|
|
// Reflow非空时使用完整富文本显式重排并忽略范围和Text,Width为可选页面段落宽度,单位为毫米
|
|
// Size为页面毫米字号,零值保持原字号,Color为空保持原颜色,FontData优先于Font
|
|
type TextSelectionEdit struct {
|
|
Start, End int
|
|
Text string
|
|
Font string
|
|
FontData []byte
|
|
Size float64
|
|
Color string
|
|
Reflow *RichText
|
|
Width float64
|
|
}
|
|
|
|
// TextSelectionPart 保存源图元及合并文本码点范围,Matrix将TextObject局部坐标映射至页面
|
|
// Deleted表示提交时删除源图元,重排生成项以Sources标识全部来源
|
|
// Points为不含换行的局部码点原点,Color为解析后的RGB分量,Start和End使用原合并文本范围
|
|
type TextSelectionPart struct {
|
|
ID string
|
|
Index int
|
|
Start, End int
|
|
TextObject TextObject
|
|
Matrix Matrix
|
|
Deleted bool
|
|
Sources []int
|
|
Points []Point
|
|
Color string
|
|
points []Point
|
|
position ObjectPosition
|
|
raw TextObject
|
|
container *editorXML
|
|
}
|
|
|
|
// TextSelectionAnalysis 保存阅读顺序、格式和可执行能力,不插入推测空格
|
|
type TextSelectionAnalysis struct {
|
|
Selection TextSelection
|
|
Text string
|
|
RichText RichText
|
|
Parts []TextSelectionPart
|
|
Bounds Box
|
|
Replace, Delete, Reflow bool
|
|
}
|
|
|
|
// TextSelectionPreview 保存实际更新后的文字和源映射,删除项保留原范围
|
|
type TextSelectionPreview struct {
|
|
Selection TextSelection
|
|
Font string
|
|
Text string
|
|
Parts []TextSelectionPart
|
|
Bounds Box
|
|
analysis TextSelectionAnalysis
|
|
}
|
|
|
|
// AnalyzeTextSelection 分析同一容器内的横排文字,拒绝重叠及无法可靠映射的布局
|
|
// 入参: page 页面索引, selection 文字选区
|
|
// 返回: TextSelectionAnalysis 阅读顺序与能力, error 选择或布局错误
|
|
func (e *Editor) AnalyzeTextSelection(page int, selection TextSelection) (TextSelectionAnalysis, error) {
|
|
if selection.editor != nil && (selection.editor != e || selection.page != page || !e.documentIdentity().matches(selection.document) || selection.revision != e.revision) {
|
|
return TextSelectionAnalysis{}, fmt.Errorf("stale text selection")
|
|
}
|
|
copy := e.transactionSnapshot()
|
|
result, err := copy.analyzeTextSelection(page, selection)
|
|
if err == nil {
|
|
result.Selection.editor, result.Selection.document, result.Selection.revision = e, e.documentIdentity(), e.revision
|
|
result.Selection.page = page
|
|
}
|
|
return cloneEditorData(result), err
|
|
}
|
|
|
|
// analyzeTextSelection 在隔离资源视图中计算选区和逐字坐标
|
|
// 入参: page 页面索引, selection 文字选区
|
|
// 返回: TextSelectionAnalysis 分析快照, error 选择或布局错误
|
|
func (e *Editor) analyzeTextSelection(page int, selection TextSelection) (TextSelectionAnalysis, error) {
|
|
result := TextSelectionAnalysis{Selection: selection, Replace: true, Delete: true, Reflow: true}
|
|
result.Selection.IDs = slices.Clone(selection.IDs)
|
|
result.Selection.Indexes = slices.Clone(selection.Indexes)
|
|
if selection.Root != nil {
|
|
root := cloneEditorData(*selection.Root)
|
|
result.Selection.Root = &root
|
|
if len(selection.IDs) != 0 || len(selection.Indexes) == 0 {
|
|
return result, fmt.Errorf("composite selection requires indexes only")
|
|
}
|
|
members, err := e.CompositeObjectsAt(page, root, selection.Indexes)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
reader, _, _, nodes, err := e.compositeScope(page, root)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
defer reader.Close()
|
|
for i, member := range members {
|
|
if member.Object.Type != "TextObject" || !member.Capabilities.RewriteText {
|
|
return result, fmt.Errorf("selection member is not editable text")
|
|
}
|
|
node := nodes[selection.Indexes[i]]
|
|
result.Parts = append(result.Parts, TextSelectionPart{ID: member.Object.TextObject.ID, Index: selection.Indexes[i], TextObject: member.Object.TextObject, raw: node.object.TextObject, Matrix: member.Matrix, position: member.Position, container: node.span.parent})
|
|
}
|
|
} else {
|
|
if len(selection.Indexes) != 0 || len(selection.IDs) == 0 {
|
|
return result, fmt.Errorf("page selection requires IDs only")
|
|
}
|
|
objects, _, err := e.selectedObjects(page, selection.IDs)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
for _, object := range objects {
|
|
if object.Type != "TextObject" {
|
|
return result, fmt.Errorf("selection member is not text")
|
|
}
|
|
t := object.TextObject
|
|
capability, err := e.ObjectCapabilities(page, t.ID)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if !capability.RewriteText {
|
|
return result, capability.editError()
|
|
}
|
|
position, err := e.ObjectPosition(page, t.ID)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
layer, _, err := e.findObject(page, t.ID)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
resolved, err := e.resolveEditorStyle(object, layer.DrawParam)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
box, err := ParseBox(t.Boundary)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
container, _ := e.objectOrderIndexes(page, layer, t.ID)
|
|
result.Parts = append(result.Parts, TextSelectionPart{ID: t.ID, Index: position.Index, TextObject: resolved.TextObject, raw: t, Matrix: TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(t.CTM)), position: position, container: container})
|
|
}
|
|
}
|
|
positions := make([]int, len(result.Parts))
|
|
for i, part := range result.Parts {
|
|
positions[i] = part.position.Index
|
|
}
|
|
slices.Sort(positions)
|
|
for i := 1; i < len(positions); i++ {
|
|
if positions[i] != positions[i-1]+1 {
|
|
return result, fmt.Errorf("text selection skips unselected container members")
|
|
}
|
|
}
|
|
for i := range result.Parts {
|
|
p := &result.Parts[i]
|
|
if p.container != result.Parts[0].container || p.position.Container != result.Parts[0].position.Container || p.position.Layer != result.Parts[0].position.Layer {
|
|
return result, fmt.Errorf("text selection crosses containers")
|
|
}
|
|
t := p.TextObject
|
|
m := p.Matrix
|
|
if math.Abs(m.a-m.d) > 1e-9*math.Max(m.a, m.d) {
|
|
result.Reflow = false
|
|
}
|
|
if t.ReadDirection != 0 || t.CharDirection != 0 || t.VScale != 0 || t.Decoration != "" || math.Abs(m.b) > 1e-9 || math.Abs(m.c) > 1e-9 || m.a <= 0 || m.d <= 0 {
|
|
return result, fmt.Errorf("text selection requires unambiguous horizontal character mapping")
|
|
}
|
|
if err := validateTextGlyphs(t, 1<<16); err != nil {
|
|
return result, err
|
|
}
|
|
for _, transform := range t.CGTransform {
|
|
if transform.CodeCount != transform.GlyphCount {
|
|
return result, fmt.Errorf("combined glyphs require independent explicit layout")
|
|
}
|
|
}
|
|
points, err := e.selectionTextPoints(t)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
p.points = points
|
|
chars := []rune(t.Text())
|
|
var previous Point
|
|
hasPrevious := false
|
|
for j, point := range points {
|
|
if chars[j] == '\n' {
|
|
continue
|
|
}
|
|
p.Points = append(p.Points, point)
|
|
x, y := m.Transform(point.X, point.Y)
|
|
if !finite(x) || !finite(y) || hasPrevious && (point.Y < previous.Y-1e-7 || point.Y > previous.Y+1e-7 && chars[j-1] != '\n' || math.Abs(point.Y-previous.Y) < 1e-7 && point.X <= previous.X) {
|
|
return result, fmt.Errorf("ambiguous text reading order")
|
|
}
|
|
previous, hasPrevious = point, true
|
|
result.Bounds = unionTextBox(result.Bounds, m.TransformBox(Box{X: point.X, Y: point.Y - t.Size, W: t.Size, H: t.Size}))
|
|
}
|
|
if t.Clips != nil || len(t.Actions) != 0 || t.DrawParam != "" || t.Alpha != nil && *t.Alpha != 255 || t.Visible != nil && !*t.Visible || t.Stroke != nil && *t.Stroke || t.Fill != nil && !*t.Fill || t.HScale != 0 && t.HScale != 1 || t.Weight != 0 && t.Weight != 400 && t.Weight != 700 {
|
|
result.Reflow = false
|
|
}
|
|
}
|
|
slices.SortFunc(result.Parts, func(a, b TextSelectionPart) int {
|
|
ax, ay := a.Matrix.Transform(a.points[0].X, a.points[0].Y)
|
|
bx, by := b.Matrix.Transform(b.points[0].X, b.points[0].Y)
|
|
if ay < by {
|
|
return -1
|
|
}
|
|
if ay > by {
|
|
return 1
|
|
}
|
|
if ax < bx {
|
|
return -1
|
|
}
|
|
if ax > bx {
|
|
return 1
|
|
}
|
|
return 0
|
|
})
|
|
for start := 0; start < len(result.Parts); {
|
|
first := result.Parts[start]
|
|
_, anchor := first.Matrix.Transform(first.points[0].X, first.points[0].Y)
|
|
end := start + 1
|
|
for end < len(result.Parts) {
|
|
part := result.Parts[end]
|
|
_, y := part.Matrix.Transform(part.points[0].X, part.points[0].Y)
|
|
if y-anchor > 1e-6 {
|
|
break
|
|
}
|
|
end++
|
|
}
|
|
slices.SortStableFunc(result.Parts[start:end], func(a, b TextSelectionPart) int {
|
|
ax, _ := a.Matrix.Transform(a.points[0].X, a.points[0].Y)
|
|
bx, _ := b.Matrix.Transform(b.points[0].X, b.points[0].Y)
|
|
if ax < bx {
|
|
return -1
|
|
}
|
|
if ax > bx {
|
|
return 1
|
|
}
|
|
return 0
|
|
})
|
|
start = end
|
|
}
|
|
var baseline, lastEnd, lastOrigin, previousSize, rowStart float64
|
|
var text strings.Builder
|
|
offset := 0
|
|
for i := range result.Parts {
|
|
p := &result.Parts[i]
|
|
x, y := p.Matrix.Transform(p.points[0].X, p.points[0].Y)
|
|
if i > 0 && (y < baseline-1e-6 || math.Abs(y-baseline) < 1e-6 && (x <= lastOrigin+1e-7 || x < lastEnd-1e-7)) {
|
|
return result, fmt.Errorf("text objects overlap or interleave")
|
|
}
|
|
size := p.TextObject.Size * p.Matrix.d
|
|
if i > 0 {
|
|
limit := math.Max(size, previousSize)
|
|
if math.Abs(y-baseline) < 1e-6 && x-lastEnd > limit*8 || y > baseline+1e-6 && (y-baseline > limit*3 || math.Abs(x-rowStart) > limit*2) {
|
|
return result, fmt.Errorf("text selection has ambiguous column or paragraph gaps")
|
|
}
|
|
}
|
|
if i == 0 || y > baseline+1e-6 {
|
|
rowStart = x
|
|
}
|
|
if i > 0 && y > baseline+1e-6 {
|
|
text.WriteByte('\n')
|
|
offset++
|
|
result.RichText.Runs[len(result.RichText.Runs)-1].Text += "\n"
|
|
}
|
|
lastOrigin, baseline = p.Matrix.Transform(p.points[len(p.points)-1].X, p.points[len(p.points)-1].Y)
|
|
font, err := e.editorFont(p.TextObject.Font)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
chars := []rune(strings.ReplaceAll(p.TextObject.Text(), "\n", ""))
|
|
glyph := font.GlyphIndex(chars[len(chars)-1])
|
|
for _, mapping := range p.TextObject.CGTransform {
|
|
if len(chars)-1 >= mapping.CodePosition && len(chars)-1 < mapping.CodePosition+mapping.CodeCount {
|
|
glyph = uint16(parseInts(mapping.Glyphs)[len(chars)-1-mapping.CodePosition])
|
|
}
|
|
}
|
|
scale := p.TextObject.HScale
|
|
if scale == 0 {
|
|
scale = 1
|
|
}
|
|
lastEnd = lastOrigin + float64(font.GlyphAdvance(glyph))*p.TextObject.Size/float64(font.UnitsPerEm())*scale*p.Matrix.a
|
|
previousSize = size
|
|
value := p.TextObject.Text()
|
|
p.Start = offset
|
|
text.WriteString(value)
|
|
offset += utf8.RuneCountInString(value)
|
|
p.End = offset
|
|
format := TextFormat{Font: p.TextObject.Font, Size: p.TextObject.Size * p.Matrix.d, Color: "0 0 0", Bold: p.TextObject.Weight == 700, Italic: p.TextObject.Italic}
|
|
if p.TextObject.FillColor != nil {
|
|
c := p.TextObject.FillColor
|
|
if c.Pattern != nil || c.AxialShd != nil || c.RadialShd != nil || c.GouraudShd != nil || c.LaGouraudShd != nil || c.unsupported {
|
|
result.Reflow = false
|
|
}
|
|
color, err := e.Color(p.TextObject.FillColor)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
r, g, b, a := color.RGBA()
|
|
if a > 0 && a < 65535 {
|
|
r = r * 65535 / a
|
|
g = g * 65535 / a
|
|
b = b * 65535 / a
|
|
}
|
|
format.Color = fmt.Sprintf("%d %d %d", r>>8, g>>8, b>>8)
|
|
if a != 65535 {
|
|
result.Reflow = false
|
|
}
|
|
}
|
|
result.RichText.Runs = append(result.RichText.Runs, RichTextRun{Text: value, Format: format})
|
|
p.Color = format.Color
|
|
}
|
|
result.Text = text.String()
|
|
if result.Reflow && e.source != nil && (e.source.document.CustomTags.Path != "" || len(e.source.document.CustomTags.CustomTag) != 0) {
|
|
structures, err := e.Structure()
|
|
if err != nil {
|
|
result.Reflow = false
|
|
} else {
|
|
ids := make(map[string]bool, len(result.Parts)+1)
|
|
for _, part := range result.Parts {
|
|
ids[editorResourceID(part.ID)] = true
|
|
}
|
|
if selection.Root != nil {
|
|
ids[editorResourceID(selection.Root.ID)] = true
|
|
for i, index := range selection.Root.Children {
|
|
parent := *selection.Root
|
|
parent.Children = parent.Children[:i]
|
|
member, err := e.CompositeObject(page, parent, index)
|
|
if err != nil {
|
|
result.Reflow = false
|
|
break
|
|
}
|
|
ids[editorResourceID(editorObjectID(member.Object))] = true
|
|
}
|
|
}
|
|
pageID := editorResourceID(e.pages[page].ID)
|
|
for _, structure := range structures {
|
|
if structure.Error != "" || structure.Root == nil || !structure.CanBind {
|
|
result.Reflow = false
|
|
break
|
|
}
|
|
pending := []*StructureNode{structure.Root}
|
|
for len(pending) != 0 && result.Reflow {
|
|
node := pending[len(pending)-1]
|
|
pending = pending[:len(pending)-1]
|
|
for _, ref := range node.References {
|
|
if editorResourceID(ref.PageID) != pageID {
|
|
continue
|
|
}
|
|
for _, id := range ref.ObjectIDs {
|
|
if ids[editorResourceID(id)] {
|
|
result.Reflow = false
|
|
}
|
|
}
|
|
}
|
|
for i := range node.Children {
|
|
pending = append(pending, &node.Children[i])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// selectionTextPoints 读取标准逐字原点,不推断缺失的字符映射
|
|
// 入参: t 文字对象
|
|
// 返回: []Point 含换行占位的码点原点, error 字体或映射错误
|
|
func (e *Editor) selectionTextPoints(t TextObject) ([]Point, error) {
|
|
font, err := e.editorFont(t.Font)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ascent, _, _ := font.VerticalMetrics()
|
|
var points []Point
|
|
lines := newTextLineScanner(t)
|
|
mappings := make(map[int]CGTransform, len(t.CGTransform))
|
|
for _, mapping := range t.CGTransform {
|
|
mappings[mapping.CodePosition] = mapping
|
|
}
|
|
offset := 0
|
|
for _, code := range textCodeOrigins(t.TextCode, float64(ascent)*t.Size/float64(font.UnitsPerEm())) {
|
|
if lines.next(code) {
|
|
points = append(points, Point{})
|
|
}
|
|
if code.Index != "" {
|
|
return nil, fmt.Errorf("glyph-index text has no character mapping")
|
|
}
|
|
position := NewTextPositioner(code)
|
|
chars := textCodeRunes(code.Value)
|
|
for i := 0; i < len(chars); {
|
|
mapping, ok := mappings[offset+i]
|
|
if !ok {
|
|
points = append(points, position.Next())
|
|
i++
|
|
continue
|
|
}
|
|
if mapping.CodeCount <= 0 || mapping.GlyphCount <= 0 || i+mapping.CodeCount > len(chars) {
|
|
return nil, fmt.Errorf("invalid text character mapping")
|
|
}
|
|
glyphPoints := make([]Point, mapping.GlyphCount)
|
|
for j := range glyphPoints {
|
|
glyphPoints[j] = position.Next()
|
|
}
|
|
for j := 0; j < mapping.CodeCount; j++ {
|
|
points = append(points, glyphPoints[min(j, len(glyphPoints)-1)])
|
|
}
|
|
i += mapping.CodeCount
|
|
}
|
|
offset += len(chars)
|
|
}
|
|
if len(points) == 0 || len(points) != utf8.RuneCountInString(t.Text()) {
|
|
return nil, fmt.Errorf("text selection requires a nonempty character mapping")
|
|
}
|
|
return points, nil
|
|
}
|
|
|
|
// PreviewTextSelection 在隔离快照生成与提交相同的结果,不分配文档资源或修改历史
|
|
// 入参: page 页面索引, selection 文字选区, edit 编辑范围与样式
|
|
// 返回: TextSelectionPreview 更新文字与来源映射, error 校验错误
|
|
func (e *Editor) PreviewTextSelection(page int, selection TextSelection, edit TextSelectionEdit) (TextSelectionPreview, error) {
|
|
analysis, err := e.AnalyzeTextSelection(page, selection)
|
|
if err != nil {
|
|
return TextSelectionPreview{}, err
|
|
}
|
|
copy := e.transactionSnapshot()
|
|
if err := copy.prepareTextSelectionEdit(&edit); err != nil {
|
|
return TextSelectionPreview{}, err
|
|
}
|
|
result, err := copy.previewTextSelection(analysis, edit)
|
|
return cloneEditorData(result), err
|
|
}
|
|
|
|
// previewTextSelection 统一生成定位更新或显式富文本布局
|
|
// 入参: analysis 选区快照, edit 编辑参数
|
|
// 返回: TextSelectionPreview 更新预览, error 编辑错误
|
|
func (e *Editor) previewTextSelection(analysis TextSelectionAnalysis, edit TextSelectionEdit) (TextSelectionPreview, error) {
|
|
result := TextSelectionPreview{analysis: analysis, Bounds: analysis.Bounds, Font: edit.Font}
|
|
if edit.Reflow != nil {
|
|
if !analysis.Reflow {
|
|
return result, fmt.Errorf("selection appearance cannot be safely reflowed")
|
|
}
|
|
value := cloneEditorData(*edit.Reflow)
|
|
for i := range value.Runs {
|
|
if edit.Font != "" {
|
|
value.Runs[i].Format.Font = edit.Font
|
|
}
|
|
if edit.Size != 0 {
|
|
value.Runs[i].Format.Size = edit.Size
|
|
}
|
|
if edit.Color != "" {
|
|
value.Runs[i].Format.Color = edit.Color
|
|
}
|
|
}
|
|
width := edit.Width
|
|
if width == 0 {
|
|
width = analysis.Bounds.W
|
|
}
|
|
layout, err := e.layoutRichText(value, width)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
result.Text = value.Text()
|
|
for _, object := range layout.objects {
|
|
if object.Type != "TextObject" {
|
|
continue
|
|
}
|
|
box, err := ParseBox(object.TextObject.Boundary)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
part := TextSelectionPart{Index: -1, TextObject: object.TextObject, Matrix: TranslationMatrix(analysis.Bounds.X+box.X, analysis.Bounds.Y+box.Y).Multiply(NewMatrix(object.TextObject.CTM))}
|
|
part.Color = "0 0 0"
|
|
if object.TextObject.FillColor != nil {
|
|
part.Color = object.TextObject.FillColor.Value
|
|
}
|
|
points, err := e.selectionTextPoints(object.TextObject)
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
for i, char := range []rune(object.TextObject.Text()) {
|
|
if char != '\n' {
|
|
part.Points = append(part.Points, points[i])
|
|
}
|
|
}
|
|
for i := range analysis.Parts {
|
|
part.Sources = append(part.Sources, i)
|
|
}
|
|
result.Parts = append(result.Parts, part)
|
|
}
|
|
result.Selection = selectionAfterTextEdit(analysis, nil, true)
|
|
return result, nil
|
|
}
|
|
if !utf8.ValidString(edit.Text) || strings.ContainsAny(edit.Text, "\r\t") || edit.Start < 0 || edit.End < edit.Start || edit.End > utf8.RuneCountInString(analysis.Text) {
|
|
return result, fmt.Errorf("invalid text selection range")
|
|
}
|
|
boundaries := richTextBoundaries(analysis.Text)
|
|
if !boundaries[edit.Start] || !boundaries[edit.End] {
|
|
return result, fmt.Errorf("text range splits a grapheme")
|
|
}
|
|
replacement := []rune(edit.Text)
|
|
originalText := []rune(analysis.Text)
|
|
wantedText := string(originalText[:edit.Start]) + edit.Text + string(originalText[edit.End:])
|
|
if len(replacement) != 0 && len(replacement) != edit.End-edit.Start {
|
|
return result, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("positioned replacement must have equal length or be a deletion")}
|
|
}
|
|
for _, source := range analysis.Parts {
|
|
p := source
|
|
t := cloneEditorData(source.TextObject)
|
|
old := []rune(t.Text())
|
|
var next []rune
|
|
var codes []TextCode
|
|
var transforms []CGTransform
|
|
glyphs := make(map[int]string)
|
|
for _, mapping := range t.CGTransform {
|
|
for j, glyph := range strings.Fields(mapping.Glyphs) {
|
|
glyphs[mapping.CodePosition+j] = glyph
|
|
}
|
|
}
|
|
glyphPosition, retained := 0, 0
|
|
p.Points = nil
|
|
for j, char := range old {
|
|
at := source.Start + j
|
|
original := char
|
|
if char != '\n' {
|
|
glyphPosition++
|
|
}
|
|
if at >= edit.Start && at < edit.End {
|
|
if len(replacement) == 0 {
|
|
continue
|
|
}
|
|
char = replacement[at-edit.Start]
|
|
}
|
|
if (original == '\n') != (char == '\n') {
|
|
return result, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing line breaks requires reflow")}
|
|
}
|
|
next = append(next, char)
|
|
if char == '\n' {
|
|
continue
|
|
}
|
|
point := source.points[j]
|
|
p.Points = append(p.Points, point)
|
|
codes = append(codes, TextCode{X: ofdNumber(point.X), Y: ofdNumber(point.Y), Value: escapeOFDText(string(char))})
|
|
if glyph, ok := glyphs[glyphPosition-1]; ok {
|
|
transforms = append(transforms, CGTransform{CodePosition: retained, CodeCount: 1, GlyphCount: 1, Glyphs: glyph})
|
|
}
|
|
retained++
|
|
}
|
|
p.Deleted = len(codes) == 0
|
|
if !p.Deleted {
|
|
if len(next) == len(old) && (string(next) != string(old) || edit.Font != "" && edit.Font != t.Font) {
|
|
if err := e.RewriteTextWithFont(&t, string(next), edit.Font); err != nil {
|
|
return result, err
|
|
}
|
|
} else if len(next) != len(old) {
|
|
t.TextCode, t.CGTransform, t.layout = codes, transforms, nil
|
|
if err := e.RewriteTextWithFont(&t, t.Text(), edit.Font); err != nil {
|
|
return result, err
|
|
}
|
|
}
|
|
if edit.Size != 0 {
|
|
t.Size = edit.Size / source.Matrix.d
|
|
}
|
|
if edit.Color != "" {
|
|
p.Color = edit.Color
|
|
var r, g, b float64
|
|
fmt.Sscan(edit.Color, &r, &g, &b)
|
|
fill, err := e.RGBColor(color.NRGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255})
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
t.FillColor = editorStyleColor(fill, t.FillColor)
|
|
}
|
|
if err := validateEditorGeometry(GraphicObject{Type: "TextObject", TextObject: t}); err != nil {
|
|
return result, err
|
|
}
|
|
if result.Text != "" && source.Start > 0 && []rune(analysis.Text)[source.Start-1] == '\n' {
|
|
result.Text += "\n"
|
|
}
|
|
result.Text += t.Text()
|
|
}
|
|
p.TextObject = t
|
|
result.Parts = append(result.Parts, p)
|
|
}
|
|
result.Selection = selectionAfterTextEdit(analysis, result.Parts, false)
|
|
if result.Text != wantedText {
|
|
return result, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing positioned line breaks requires explicit reflow")}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// prepareTextSelectionEdit 在当前隔离视图校验覆盖样式并注册替换字体
|
|
// 入参: edit 编辑参数
|
|
// 返回: error 字体或样式错误
|
|
func (e *Editor) prepareTextSelectionEdit(edit *TextSelectionEdit) error {
|
|
if !finite(edit.Size) || edit.Size < 0 || !finite(edit.Width) || edit.Width < 0 {
|
|
return fmt.Errorf("invalid text selection dimensions")
|
|
}
|
|
if edit.Color != "" {
|
|
if err := creationColor(&FillColor{Value: edit.Color}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(edit.FontData) > 0 {
|
|
id, err := e.AddFont(FontFile{Data: edit.FontData}, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
edit.Font = id
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// selectionAfterTextEdit 计算删除及重排后的选中位置,不保留旧修订绑定
|
|
// 入参: analysis 原选区, parts 更新项, reflow 是否合并为富文本
|
|
// 返回: TextSelection 提交后可用于重新枚举的选择
|
|
func selectionAfterTextEdit(analysis TextSelectionAnalysis, parts []TextSelectionPart, reflow bool) TextSelection {
|
|
result := TextSelection{Root: analysis.Selection.Root}
|
|
deleted := make(map[int]bool)
|
|
if reflow {
|
|
for _, part := range analysis.Parts[1:] {
|
|
deleted[part.Index] = true
|
|
}
|
|
parts = analysis.Parts[:1]
|
|
} else {
|
|
for _, part := range parts {
|
|
if part.Deleted {
|
|
deleted[part.Index] = true
|
|
}
|
|
}
|
|
}
|
|
for _, part := range parts {
|
|
if part.Deleted {
|
|
continue
|
|
}
|
|
if result.Root == nil {
|
|
result.IDs = append(result.IDs, part.ID)
|
|
continue
|
|
}
|
|
index := part.Index
|
|
for removed := range deleted {
|
|
if removed < part.Index {
|
|
index--
|
|
}
|
|
}
|
|
result.Indexes = append(result.Indexes, index)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// UpdateTextSelection 原子提交联合文字修改,失败不改变资源、ID或撤销重做
|
|
// 入参: page 页面索引, selection 文字选区, edit 编辑范围与样式
|
|
// 返回: error 校验或提交错误
|
|
func (e *Editor) UpdateTextSelection(page int, selection TextSelection, edit TextSelectionEdit) error {
|
|
analysis, err := e.AnalyzeTextSelection(page, selection)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return e.Transaction(func(e *Editor) error {
|
|
if err := e.prepareTextSelectionEdit(&edit); err != nil {
|
|
return err
|
|
}
|
|
preview, err := e.previewTextSelection(analysis, edit)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if edit.Reflow != nil {
|
|
return e.reflowTextSelection(page, selection, edit, preview)
|
|
}
|
|
if selection.Root != nil {
|
|
return e.editCompositeObjects(page, *selection.Root, selection.Indexes, func(_ *Renderer, nodes []*editorCompositeNode, _ []CompositeMember) error {
|
|
for i, node := range nodes {
|
|
for _, part := range preview.Parts {
|
|
if part.Index != selection.Indexes[i] {
|
|
continue
|
|
}
|
|
if part.Deleted {
|
|
node.owner.patches = append(node.owner.patches, editorXMLPatch{node.span.start, node.span.end, nil})
|
|
continue
|
|
}
|
|
object := cloneEditorData(node.object)
|
|
object.TextObject.TextCode, object.TextObject.CGTransform = part.TextObject.TextCode, part.TextObject.CGTransform
|
|
if edit.Font != "" {
|
|
object.TextObject.Font = part.TextObject.Font
|
|
}
|
|
if edit.Size != 0 {
|
|
object.TextObject.Size = part.TextObject.Size
|
|
}
|
|
if edit.Color != "" {
|
|
object.TextObject.FillColor = part.TextObject.FillColor
|
|
}
|
|
if reflect.DeepEqual(object, node.object) {
|
|
continue
|
|
}
|
|
if err := node.update(object); err != nil {
|
|
return err
|
|
}
|
|
node.setState(editorCompositeState{})
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
var updates []GraphicObject
|
|
var deleted []string
|
|
for _, part := range preview.Parts {
|
|
if part.Deleted {
|
|
deleted = append(deleted, part.ID)
|
|
continue
|
|
}
|
|
next := selectionUpdatedText(part, edit)
|
|
if !reflect.DeepEqual(next, part.raw) {
|
|
updates = append(updates, GraphicObject{Type: "TextObject", TextObject: next})
|
|
}
|
|
}
|
|
if len(updates) > 0 {
|
|
if err := e.updateObjects(page, updates, true); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if len(deleted) > 0 {
|
|
return e.DeleteObjects(page, deleted)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// selectionUpdatedText 仅更新请求字段,保留源对象继承样式与排版状态
|
|
// 入参: part 预览项, edit 编辑参数
|
|
// 返回: TextObject 可提交的原始对象
|
|
func selectionUpdatedText(part TextSelectionPart, edit TextSelectionEdit) TextObject {
|
|
next := cloneEditorData(part.raw)
|
|
next.TextCode, next.CGTransform, next.layout = part.TextObject.TextCode, part.TextObject.CGTransform, part.TextObject.layout
|
|
if edit.Font != "" {
|
|
next.Font = part.TextObject.Font
|
|
}
|
|
if edit.Size != 0 {
|
|
next.Size = part.TextObject.Size
|
|
}
|
|
if edit.Color != "" {
|
|
next.FillColor = part.TextObject.FillColor
|
|
}
|
|
return next
|
|
}
|
|
|
|
// reflowTextSelection 将富文本排版结果替换至首个源位置,其他源成员一并删除
|
|
// 入参: page 页面索引, selection 原选择, edit 编辑参数, preview 布局预览
|
|
// 返回: error 排版或提交错误
|
|
func (e *Editor) reflowTextSelection(page int, selection TextSelection, edit TextSelectionEdit, preview TextSelectionPreview) error {
|
|
value := cloneEditorData(*edit.Reflow)
|
|
for i := range value.Runs {
|
|
if edit.Font != "" {
|
|
value.Runs[i].Format.Font = edit.Font
|
|
}
|
|
if edit.Size != 0 {
|
|
value.Runs[i].Format.Size = edit.Size
|
|
}
|
|
if edit.Color != "" {
|
|
value.Runs[i].Format.Color = edit.Color
|
|
}
|
|
}
|
|
box := preview.Bounds
|
|
if edit.Width != 0 {
|
|
box.W = edit.Width
|
|
}
|
|
object, meta, err := e.prepareRichText(box, value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
first := preview.analysis.Parts[0]
|
|
if selection.Root == nil {
|
|
object.CompositeGraphicUnit.ID = first.ID
|
|
if err := e.updateObjects(page, []GraphicObject{object}, true); err != nil {
|
|
return err
|
|
}
|
|
var deleted []string
|
|
for _, part := range preview.analysis.Parts[1:] {
|
|
deleted = append(deleted, part.ID)
|
|
}
|
|
if len(deleted) > 0 {
|
|
if err := e.DeleteObjects(page, deleted); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return e.putRichTextMetadata(page, first.ID, meta, -1)
|
|
}
|
|
return e.editCompositeObjects(page, *selection.Root, selection.Indexes, func(_ *Renderer, nodes []*editorCompositeNode, _ []CompositeMember) error {
|
|
for i, node := range nodes {
|
|
if selection.Indexes[i] != first.Index {
|
|
node.owner.patches = append(node.owner.patches, editorXMLPatch{node.span.start, node.span.end, nil})
|
|
continue
|
|
}
|
|
inverse, ok := node.parent.Invert()
|
|
if !ok {
|
|
return fmt.Errorf("invalid composite parent transform")
|
|
}
|
|
local := transformEditorMatrix(object, inverse)
|
|
local.CompositeGraphicUnit.ID = node.object.TextObject.ID
|
|
if !node.boundaryInCTM {
|
|
local = compositeBoundary(local, node.parent, false)
|
|
}
|
|
if err := node.update(local); err != nil {
|
|
return err
|
|
}
|
|
node.setState(editorCompositeState{})
|
|
}
|
|
return nil
|
|
})
|
|
}
|