// 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 ( "crypto/sha256" "encoding/json" "encoding/xml" "errors" "fmt" "image/color" "strconv" "strings" "unicode/utf8" "github.com/go-text/typesetting/segmenter" ) // MissingGlyphError 字体缺字错误,FontID为OFD字体资源标识,Characters按首次出现顺序保留不重复的缺失字符 // 可通过errors.As从编辑错误中取得,不表示字体缺失或文件损坏 type MissingGlyphError struct { FontID string `json:"fontID"` Characters string `json:"characters"` } // TextLayout 本地横向段落选项 // Shape显式启用FontShaper,保存为标准TextCode和CGTransform,不改变阅读时的原文定位 // Shaping提供可比较的塑形选项,非零值也会启用塑形,并随排版元数据保存 // Wrap按CTM变换前的边界宽度折行,Align为left、center、right、justify或distributed // LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负 // LeftIndent和RightIndent为左右缩进,FirstLineIndent为每段首行相对左缩进的偏移,单位为毫米 // AutoBidi自动识别双向文字并启用塑形,零值保持显式换行和左对齐 type TextLayout struct { AutoBidi bool Shape bool Shaping TextShapeOptions Wrap bool Align string LineHeight float64 LetterSpacing float64 LeftIndent float64 RightIndent float64 FirstLineIndent float64 } // TextStyle 文字样式增量 // Font为空时保留原字体,Size单位为毫米且为0时保留原字号 // Color为OFD的RGB分量字符串,空值保留原颜色,修改时保留原透明度 // Reflow显式重新排版,默认保留原文定位 type TextStyle struct { Font string Size float64 Color string Reflow bool } // textLayout 保存可恢复的原文与排版选项,不参与阅读渲染 type textLayout struct { value string options TextLayout autoWidth bool } // textNamespace 标识普通文字编辑语义的命名空间,版本仅随不兼容的语义变更升级 const textNamespace = "https://ofdgo.aite.me/ns/text/1" // textMetadataAttribute 判断无资源引用的普通文字编辑元数据属性 // 入参: node 所属节点, attr 属性 // 返回: bool 是否为本库普通文字元数据 func textMetadataAttribute(node xml.Name, attr xml.Attr) bool { return classifyOFDNamespace(node.Space) != ofdXMLUnknown && (node.Local == "TextObject" || node.Local == "Text") && attr.Name.Space == textNamespace && attr.Name.Local == "Text" } // textMetadata 保存原文、选项与标准定位摘要 type textMetadata struct { Text string `json:"text"` Layout TextLayout `json:"layout"` AutoWidth bool `json:"autoWidth"` Digest string `json:"digest"` } // textDigest 计算与字体编号无关的文字定位摘要 // 返回: string 摘要 func (obj TextObject) textDigest() string { codes := append([]TextCode(nil), obj.TextCode...) for i := range codes { codes[i].Value = string(textCodeRunes(codes[i].Value)) } data, _ := json.Marshal(codes) return fmt.Sprintf("%x", sha256.Sum256(data)) } // textMetadata 编码普通文字的排版语义,与标准定位一起保存 // 返回: string 元数据, error 编码错误 func (obj TextObject) textMetadata() (string, error) { if obj.layout == nil { return "", nil } data, err := json.Marshal(textMetadata{Text: obj.layout.value, Layout: obj.layout.options, AutoWidth: obj.layout.autoWidth, Digest: obj.textDigest()}) return string(data), err } // Error 返回缺失字符的Unicode码点 // 返回: string 错误描述 func (e *MissingGlyphError) Error() string { var codes []string for _, char := range e.Characters { codes = append(codes, fmt.Sprintf("U+%04X", char)) } return fmt.Sprintf("font %q does not contain %s", e.FontID, strings.Join(codes, ", ")) } // StyleText 原子更新同页文字样式,全部校验通过后提交一次撤销记录 // 已知段落重新排版;原布局未知时替换字体和颜色保留坐标,不允许自动调整字号 // 入参: page 页面索引, ids 文字对象标识, style 样式增量 // 返回: error 错误信息 func (e *Editor) StyleText(page int, ids []string, style TextStyle) (err error) { objects, _, err := e.selectedObjects(page, ids) if err != nil { return err } count, maximum := len(e.resources), e.maxID ready := e.source != nil && e.source.idsReady defer func() { if err != nil { clear(e.resources[count:]) e.resources, e.maxID = e.resources[:count], maximum if e.source != nil { e.source.idsReady = ready } } }() updates, err := e.styleTextObjects(objects, style) if err != nil { return err } return e.UpdateObjects(page, updates) } // UpdateTextContent 修改文字内容及样式,原文保留定位,已知段落按排版选项重排 // 原文等长替换保留全部位移,单个TextCode内增删仅调整局部步进,跨定位段增删需显式重排 // 入参: page 页面索引, id 文字对象标识, value 新内容, style 样式增量 // 返回: error 错误信息 func (e *Editor) UpdateTextContent(page int, id, value string, style TextStyle) (err error) { capability, err := e.ObjectCapabilities(page, id) if err != nil { return err } if !capability.ReplaceFont && (style != (TextStyle{}) || !capability.Transform) { return capability.editError() } object, err := e.Object(page, id) if err != nil { return err } if !capability.LayoutKnown && style == (TextStyle{}) { if object.Type != "TextObject" { return fmt.Errorf("object %q is not text", id) } if err := e.RewriteText(&object.TextObject, value); err != nil { return err } return e.updateObjects(page, []GraphicObject{object}, true) } count, maximum := len(e.resources), e.maxID ready := e.source != nil && e.source.idsReady defer func() { if err != nil { clear(e.resources[count:]) e.resources, e.maxID = e.resources[:count], maximum if e.source != nil { e.source.idsReady = ready } } }() reflow := style.Reflow if reflow { if object.TextObject.ReadDirection != 0 || object.TextObject.CharDirection != 0 { return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("paragraph layout requires horizontal text")} } if style.Font != "" { object.TextObject.Font = style.Font } if style.Size != 0 { object.TextObject.Size = style.Size } if err := e.LayoutText(&object.TextObject, value, TextLayout{AutoBidi: true}); err != nil { return err } style.Font, style.Size = "", 0 style.Reflow = false } if !capability.LayoutKnown && !reflow && style.Font != "" { if err := e.RewriteTextWithFont(&object.TextObject, value, style.Font); err != nil { return err } style.Font = "" } updates, err := e.styleTextObjects([]GraphicObject{object}, style) if err != nil { return err } text := &updates[0].TextObject if reflow { return e.updateObjects(page, updates, true) } if capability.LayoutKnown { _, layout := text.TextLayout() err = e.LayoutText(text, value, layout) } else { err = e.RewriteText(text, value) } if err != nil { return err } return e.updateObjects(page, updates, true) } // RewriteText 保留原文的字形定位,不修改对象样式或文档 // 等长替换保留各字原位;单段增删保留前缀和后缀步进,新增字符沿用局部字距 // 方向、缩放和修饰原样保留;单段纵排沿用现有纵向步进,其他复杂布局仅支持等长替换,合并字形需显式重排 // 首段缺失坐标仅在临时校验中恢复,不改写源坐标 // 入参: obj 原文字对象, value 新内容 // 返回: error 错误信息 func (e *Editor) RewriteText(obj *TextObject, value string) error { value = strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n") if !utf8.ValidString(value) || strings.Contains(value, "\t") || strings.Trim(value, "\n") == "" { return fmt.Errorf("text must contain UTF-8 characters without tabs") } check := cloneEditorData(*obj) check.VScale, check.Decoration = 0, "" check.TextCode = textCodeOrigins(check.TextCode, 0) if err := e.prepareText(&check); err != nil { var missing *MissingGlyphError if !errors.As(err, &missing) { return err } } sfnt, err := e.editorFont(obj.Font) if err != nil { return err } old, next := []rune(obj.Text()), []rune(value) 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")} } var missing []rune for i, char := range next { if len(old) == len(next) && old[i] == char { continue } if char != '\n' && sfnt.GlyphIndex(char) == 0 { missing = append(missing, char) } } if err := missingGlyphError(obj.Font, missing); err != nil { return err } codes := append([]TextCode(nil), obj.TextCode...) transforms := append([]CGTransform(nil), obj.CGTransform...) lines := newTextLineScanner(*obj) if len(old) == len(next) { offset := 0 for i := range codes { if lines.next(codes[i]) { if next[offset] != '\n' { return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing text lines requires explicit layout")} } offset++ } end := offset + len(textCodeRunes(codes[i].Value)) content := string(next[offset:end]) if strings.Contains(content, "\n") { return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing text lines requires explicit layout")} } if content != string(textCodeRunes(codes[i].Value)) { codes[i].Value = escapeOFDText(content) } offset = end } } else { start := 0 for start < min(len(old), len(next)) && old[start] == next[start] { start++ } end, nextEnd := len(old), len(next) for end > start && nextEnd > start && old[end-1] == next[nextEnd-1] { end-- nextEnd-- } if strings.ContainsAny(string(old[start:end])+string(next[start:nextEnd]), "\n") { return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing text lines requires explicit layout")} } offset, changed := 0, false for i := range codes { if lines.next(codes[i]) { offset++ } runes := textCodeRunes(codes[i].Value) limit := offset + len(runes) if start >= offset && end <= limit { from, to := start-offset, end-offset replacement := next[start:nextEnd] content := append(append(append([]rune(nil), runes[:from]...), replacement...), runes[to:]...) if len(content) == 0 { return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("removing a positioned text run requires explicit layout")} } unit := obj.Size / float64(sfnt.UnitsPerEm()) if obj.HScale != 0 { unit *= obj.HScale } 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) } if codes[i].DeltaY != "" { codes[i].DeltaY = rewriteTextDeltas(codes[i].DeltaY, runes, replacement, from, to, func(rune) float64 { return 0 }) } codes[i].Value = escapeOFDText(string(content)) changed = true break } offset = limit } if !changed { return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("editing across positioned text runs requires explicit layout")} } } check = *obj check.TextCode = codes position, mapping := 0, 0 for i, code := range codes { before, after := textCodeRunes(obj.TextCode[i].Value), textCodeRunes(code.Value) if len(before) == len(after) { for j, char := range after { if before[j] == char { continue } for mapping < len(transforms) && position+j >= transforms[mapping].CodePosition+transforms[mapping].CodeCount { mapping++ } if mapping < len(transforms) && position+j >= transforms[mapping].CodePosition { transform := &transforms[mapping] if transform.CodeCount != transform.GlyphCount { return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing a combined glyph requires explicit layout")} } glyphs := strings.Fields(transform.Glyphs) glyphs[position+j-transform.CodePosition] = strconv.Itoa(int(sfnt.GlyphIndex(char))) transform.Glyphs = strings.Join(glyphs, " ") } } } position += len(after) } check.CGTransform = transforms if err := validateEditorGeometry(GraphicObject{Type: "TextObject", TextObject: check}); err != nil { return err } obj.TextCode, obj.CGTransform, obj.layout = codes, transforms, nil return nil } // RewriteTextWithFont 更换字体并修改定位文字,保留原始坐标及逐字映射,失败时不改变对象 // 入参: obj 文字对象, value 新内容, fontID 目标字体,空值沿用原字体 // 返回: error 字体、定位或字形错误 func (e *Editor) RewriteTextWithFont(obj *TextObject, value, fontID string) error { value = strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n") if fontID == "" || fontID == obj.Font { return e.RewriteText(obj, value) } text := cloneEditorData(*obj) if err := validateTextGlyphs(text, 1<<16); err != nil { return err } for _, transform := range text.CGTransform { if transform.CodeCount != transform.GlyphCount { return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing the font of combined glyphs requires explicit layout")} } } if len(text.CGTransform) != 0 && len([]rune(text.Text())) != len([]rune(value)) { return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing positioned text length requires explicit layout")} } transforms := text.CGTransform text.Font, text.CGTransform = fontID, nil if err := e.RewriteText(&text, value); err != nil { return err } font, err := e.editorFont(fontID) if err != nil { return err } var missing []rune for _, char := range text.Text() { if char != '\n' && font.GlyphIndex(char) == 0 { missing = append(missing, char) } } if err := missingGlyphError(fontID, missing); err != nil { return err } text.Font, text.CGTransform = obj.Font, transforms if err := e.replaceTextFont(&text, fontID); err != nil { return err } *obj = text return nil } // TextLayout 获取最近一次排版的原文与选项,区分软换行和显式换行 // 信息仅在当前编辑过程保留,读取OFD时返回定位后的文字和默认选项 // 返回: string 原文, TextLayout 排版选项 func (obj TextObject) TextLayout() (string, TextLayout) { if obj.layout != nil { return obj.layout.value, obj.layout.options } return obj.Text(), TextLayout{} } // missingGlyphError 汇总缺失字符,无缺字时返回nil // 入参: id 字体资源标识, characters 缺失字符 // 返回: error 缺字诊断 func missingGlyphError(id string, characters []rune) error { if len(characters) == 0 { return nil } seen := make(map[rune]bool) var value strings.Builder for _, char := range characters { if !seen[char] { seen[char] = true value.WriteRune(char) } } return &MissingGlyphError{FontID: id, Characters: value.String()} } // rewriteTextDeltas 按需读取并局部替换字符步进,保留末尾附加位移及其重复编码 // 入参: value 原位移, old 原字符, replacement 替换字符, start 起点, end 终点, advance 字体步进 // 返回: string 更新后的位移数组 func rewriteTextDeltas(value string, old, replacement []rune, start, end int, advance func(rune) float64) string { deltas := textPositionValues{data: value, compressed: true} steps := make([]float64, len(old)) complete := len(old) == 0 for i, char := range old { steps[i] = advance(char) delta, ok := deltas.next() if ok || i < len(old)-1 && deltas.present { steps[i] = delta } complete = ok } tracking := 0.0 if len(old) > 0 && (len(old) > 1 || deltas.present) { index := min(start, max(0, len(old)-2)) tracking = steps[index] - advance(old[index]) } result := append([]float64(nil), steps[:start]...) for _, char := range replacement { result = append(result, advance(char)+tracking) } result = append(result, steps[end:]...) if !complete { result = result[:len(result)-1] } var output strings.Builder write := func(field string) { if output.Len() != 0 { output.WriteByte(' ') } output.WriteString(field) } for _, delta := range result { write(ofdNumber(delta)) } if deltas.repeat > 0 { if deltas.repeat > 1 { write("g") write(strconv.Itoa(deltas.repeat)) } write(ofdNumber(deltas.value)) } if tail := strings.TrimSpace(deltas.data); tail != "" { write(tail) } return output.String() } // styleTextObjects 生成文字样式副本,不提交文档或历史 // 入参: objects 原文字对象, style 样式增量 // 返回: []GraphicObject 更新对象, error 错误信息 func (e *Editor) styleTextObjects(objects []GraphicObject, style TextStyle) ([]GraphicObject, error) { updates := make([]GraphicObject, len(objects)) for i, object := range objects { id := editorObjectID(object) if object.Type != "TextObject" { return nil, fmt.Errorf("object %q is not text", id) } updates[i] = cloneEditorData(object) text := &updates[i].TextObject value, layout := text.TextLayout() known := e.objectOrigin(id) == nil || text.layout != nil if style.Reflow { if text.ReadDirection != 0 || text.CharDirection != 0 { return nil, &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("paragraph layout requires horizontal text")} } known = true layout.AutoBidi = true } fontChanged := style.Font != "" && style.Font != text.Font sizeChanged := style.Size != 0 && style.Size != text.Size if sizeChanged && !known { return nil, &EditError{Code: EditUnsupportedObject, Err: fmt.Errorf("object %q requires explicit paragraph layout before changing size", id)} } if fontChanged { if known { text.Font = style.Font } else if err := e.replaceTextFont(text, style.Font); err != nil { return nil, err } } if sizeChanged { text.Size = style.Size } if known && (fontChanged || sizeChanged || style.Reflow) { if err := e.LayoutText(text, value, layout); err != nil { return nil, err } } if style.Color != "" { if err := creationColor(&FillColor{Value: style.Color}); err != nil { return nil, err } var r, g, b float64 fmt.Sscan(style.Color, &r, &g, &b) value, err := e.RGBColor(color.NRGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}) if err != nil { return nil, err } if text.FillColor != nil { value.Alpha = text.FillColor.Alpha } text.FillColor = value } } return updates, nil } // replaceTextFont 更换字体并重建字形索引,保留原文字定位 // 入参: text 文字对象, id 目标字体标识 // 返回: error 错误信息 func (e *Editor) replaceTextFont(text *TextObject, id string) error { if text.Font == id { return nil } if len(text.CGTransform) == 0 { text.Font = id return nil } if err := validateTextGlyphs(*text, 1<<16); err != nil { return err } font, err := e.editorFont(id) if err != nil { return err } var chars []rune for _, code := range text.TextCode { chars = append(chars, textCodeRunes(code.Value)...) } transforms := append([]CGTransform(nil), text.CGTransform...) var missing []rune for i := range transforms { transform := &transforms[i] if transform.CodeCount != transform.GlyphCount { return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing the font of combined glyphs requires explicit layout")} } glyphs := make([]string, transform.CodeCount) for j := range glyphs { char := chars[transform.CodePosition+j] glyph := font.GlyphIndex(char) if glyph == 0 { missing = append(missing, char) } glyphs[j] = strconv.Itoa(int(glyph)) } transform.Glyphs = strings.Join(glyphs, " ") } if err := missingGlyphError(id, missing); err != nil { return err } text.Font, text.CGTransform = id, transforms return nil } // breakTextLines 优先使用Unicode断行机会,过长词仅在字素边界折行,不丢弃空白 // 入参: runes 段落字符, advances 含字距的字符步进, width 本地排版宽度, wrap 是否自动折行, spacing 附加字距, indent 首行缩进 // 返回: [][2]int 各行的字符起止索引,左闭右开 func breakTextLines(runes []rune, advances []float64, width float64, wrap bool, spacing, indent float64) [][2]int { if !wrap || len(runes) == 0 { return [][2]int{{0, len(runes)}} } var seg segmenter.Segmenter seg.Init(runes) breaks := make([]bool, len(runes)+1) for it := seg.LineIterator(); it.Next(); { line := it.Line() breaks[line.Offset+len(line.Text)] = true } var ends []int for it := seg.GraphemeIterator(); it.Next(); { cluster := it.Grapheme() ends = append(ends, cluster.Offset+len(cluster.Text)) } sums := make([]float64, len(runes)+1) for i, advance := range advances { sums[i+1] = sums[i] + advance } var lines [][2]int for start, cursor := 0, 0; start < len(runes); { available := width if start == 0 { available -= indent } end, legal, next := start, start, cursor for next < len(ends) { candidate := ends[next] visible := candidate for visible > start && runes[visible-1] == ' ' { visible-- } length := sums[visible] - sums[start] if visible > start { length -= spacing } if length > available && end > start { break } end = candidate next++ if breaks[end] { legal = end } if length > available { break } } if end < len(runes) && legal > start { end = legal } lines = append(lines, [2]int{start, end}) start = end for cursor < len(ends) && ends[cursor] <= start { cursor++ } } return lines } // alignTextLine 将段落对齐转换为标准X和DeltaX,两端对齐保留段落末行左对齐 // 入参: runes 行内字符, advances 含字距的字符步进, width 本地排版宽度, alignment 对齐方式, justify 是否允许本行两端对齐, spacing 附加字距, clusters 可选塑形簇起点 // 返回: float64 行首X坐标, string 字符间的DeltaX序列 func alignTextLine(runes []rune, advances []float64, width float64, alignment string, justify bool, spacing float64, clusters map[int]bool) (float64, string) { end := len(runes) for end > 0 && runes[end-1] == ' ' { end-- } length := 0.0 for _, advance := range advances[:end] { length += advance } if end > 0 { length -= spacing } x, extra := 0.0, max(0, width-length) if alignment == "center" { x = extra / 2 } else if alignment == "right" { x = extra } gaps := make(map[int]bool) if alignment == "justify" && justify && extra > 0 { var seg segmenter.Segmenter seg.Init(runes[:end]) for it := seg.LineIterator(); it.Next(); { line := it.Line() at := line.Offset + len(line.Text) if at < end { gaps[at-1] = true } } } if alignment == "distributed" && extra > 0 { var seg segmenter.Segmenter seg.Init(runes[:end]) for it := seg.GraphemeIterator(); it.Next(); { cluster := it.Grapheme() at := cluster.Offset + len(cluster.Text) if at < end && (clusters == nil || clusters[at]) { gaps[at-1] = true } } } deltas := make([]string, max(0, len(runes)-1)) for i := range deltas { advance := advances[i] if gaps[i] { advance += extra / float64(len(gaps)) } deltas[i] = ofdNumber(advance) } return x, strings.Join(deltas, " ") } // spaceTextAdvances 将字距加入各字素末尾,不拆散组合字符 // 入参: runes 段落字符, advances 待更新的字符步进, spacing 附加毫米字距 func spaceTextAdvances(runes []rune, advances []float64, spacing float64) { if spacing == 0 { return } var seg segmenter.Segmenter seg.Init(runes) for it := seg.GraphemeIterator(); it.Next(); { cluster := it.Grapheme() advances[cluster.Offset+len(cluster.Text)-1] += spacing } }