// 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 ( "bytes" "fmt" "math" "slices" "strconv" "strings" "github.com/go-text/typesetting/di" typefont "github.com/go-text/typesetting/font" "github.com/go-text/typesetting/shaping" basefont "github.com/xiaoqidun/ofdgo/internal/base/font" "golang.org/x/text/unicode/bidi" ) // richLayout 保存局部标准对象、含墨迹的边界及各片段的字体锚点序号 type richLayout struct { objects []GraphicObject bounds Box anchors []int } // richMetrics 保存片段字体、共用基线度量和实际需要的合成样式 type richMetrics struct { font FontMetrics ascent, descent, height float64 bold, italic bool outlines map[uint16]GeometryPath } // richSpan 保存方向和格式均一致的逻辑区间及行内视觉坐标 type richSpan struct { start, end, owner int rtl bool glyphs []ShapedGlyph x, width float64 } // richContextShaper 将带上下文的区间塑形接入已有簇校验流程 type richContextShaper struct { FontShaperRun text []rune start, end int options TextShapeOptions } // LayoutRichText 生成局部坐标的标准文字和路径,不修改页面或编辑历史 // 边界包含内容的排版高度和溢出墨迹,不包含空余段落宽度,可能有负的X或Y // 非标准RGB文档按需复用或注册RGB颜色空间,返回对象可直接用于当前文档 // 注册不产生修订或撤销记录;失败时不保留新增资源和资源编号 // 提交对象后应创建新的Reader;已有Reader是独立快照,不包含新注册的资源 // 返回值的资源编号属于当前资源视图,切换文档、版本或替换资源快照后须重新排版 func (e *Editor) LayoutRichText(value RichText, width float64) ([]GraphicObject, Box, error) { result, err := e.layoutRichText(value, width) if err != nil { return nil, Box{}, err } err = e.Transaction(func(editor *Editor) error { for i := range result.objects { if err := editor.richTextColors(&result.objects[i]); err != nil { return err } } return nil }) if err != nil { return nil, Box{}, err } return result.objects, result.bounds, nil } // layoutRichText 统一计算段落换行、视觉顺序和基线,颜色资源由调用方绑定 func (e *Editor) layoutRichText(value RichText, width float64) (richLayout, error) { if err := value.Validate(); err != nil { return richLayout{}, err } options := value.Layout available := width - options.LeftIndent - options.RightIndent if !finite(width) || width <= 0 || !finite(available) || available <= 0 || !finite(available-options.FirstLineIndent) || available-options.FirstLineIndent <= 0 { return richLayout{}, fmt.Errorf("invalid rich text width or indents") } geometry, err := e.Geometry() if err != nil { return richLayout{}, err } metrics := make([]richMetrics, len(value.Runs)) result := richLayout{anchors: make([]int, len(value.Runs))} owners := make([]int, 0, value.Len()) for i, run := range value.Runs { font, err := e.editorFont(run.Format.Font) if err != nil { return richLayout{}, err } if font.UnitsPerEm() == 0 { return richLayout{}, fmt.Errorf("invalid font units") } a, d, g := font.VerticalMetrics() unit := run.Format.Size / float64(font.UnitsPerEm()) bold, italic := e.richSynthetic(run.Format) metrics[i] = richMetrics{font: font, ascent: float64(a) * unit, descent: float64(d) * unit, height: max(run.Format.Size, float64(int(a)+int(d)+int(g))*unit), bold: bold, italic: italic, outlines: make(map[uint16]GeometryPath)} if !finite(metrics[i].height) { return richLayout{}, fmt.Errorf("rich text metrics overflow") } result.anchors[i] = -1 for range []rune(run.Text) { owners = append(owners, i) } } chars := []rune(value.Text()) var face *typefont.Face var underlays []GraphicObject var shadows []GraphicObject var y float64 for start := 0; start <= len(chars); { end := start for end < len(chars) && chars[end] != '\n' { end++ } paragraph := chars[start:end] local := richParagraphOptions(paragraph, options) if local.Shaping.Bidi && face == nil && len(paragraph) > 0 { face, err = typefont.ParseTTF(bytes.NewReader(metrics[owners[start]].font.Write())) if err != nil { return richLayout{}, fmt.Errorf("rich text segmentation font: %w", err) } } _, advances, err := richShapeLine(paragraph, owners[start:end], value, metrics, local, face) if err != nil { return richLayout{}, err } lines := breakTextLines(paragraph, advances, available, local.Wrap, local.LetterSpacing, local.FirstLineIndent) for lineIndex := 0; lineIndex < len(lines); lineIndex++ { line := lines[lineIndex] indent := 0.0 if lineIndex == 0 { indent = local.FirstLineIndent } var spans []richSpan var steps []float64 for { spans, steps, err = richShapeLine(paragraph[line[0]:line[1]], owners[start+line[0]:start+line[1]], value, metrics, local, face) if err != nil { return richLayout{}, err } if !local.Wrap || shapedLineWidth(paragraph[line[0]:line[1]], steps, local.LetterSpacing) <= available-indent { break } previous := 0 for at := range richTextBoundaries(string(paragraph[line[0]:line[1]])) { if at < line[1]-line[0] { previous = max(previous, at) } } if previous == 0 { break } oldEnd := line[1] line[1] = line[0] + previous if lineIndex+1 < len(lines) { lines[lineIndex+1][0] = line[1] } else { lines = append(lines, [2]int{line[1], oldEnd}) } lines[lineIndex] = line } from, to := start+line[0], start+line[1] fallback := len(value.Runs) - 1 if from < len(owners) { fallback = owners[from] } ascent, descent, height := 0.0, 0.0, 0.0 for pos := from; pos < max(from+1, to); pos++ { index := fallback if pos < to { index = owners[pos] } m := metrics[index] ascent, descent, height = max(ascent, m.ascent), max(descent, m.descent), max(height, m.height) } height = max(height, ascent+descent) x, delta := alignTextLine(chars[from:to], steps, available-indent, local.Align, lineIndex+1 < len(lines), local.LetterSpacing) copy(steps, parseFloats(delta)) if local.Shaping.Direction == "rtl" { for j := to - from - 1; j >= 0 && chars[from+j] == ' '; j-- { x -= steps[j] } } x += local.LeftIndent + indent if err := richPositionSpans(spans, steps, local, x); err != nil { return richLayout{}, err } lineBounds := Box{X: x, Y: y, H: height} if result.bounds.H == 0 { result.bounds = lineBounds } else { result.bounds = richUnion(result.bounds, lineBounds) } for _, span := range spans { format := value.Runs[span.owner].Format for _, glyph := range span.glyphs { if !finite(y + ascent + glyph.Y) { return richLayout{}, fmt.Errorf("rich text position overflow") } } text := richSpanText(chars[from:to], span, format, y+ascent) ink, err := richSpanOutline(span, format, metrics[span.owner], geometry, y+ascent) if err != nil { return richLayout{}, err } if len(ink) > 0 { box, err := geometry.Bounds(ink) if err != nil { return richLayout{}, err } result.bounds = richUnion(result.bounds, box) } decorationWidth := max(0, span.width-local.LetterSpacing) if format.Background != "" && decorationWidth > 0 { underlays = append(underlays, richRectangle(Box{X: span.x, Y: y, W: decorationWidth, H: height}, format.Background)) } if format.Shadow && len(ink) > 0 { shift := format.Size * 0.06 shadow, err := geometry.Transform(ink, TranslationMatrix(shift, shift)) if err != nil { return richLayout{}, err } path, err := geometryClipPath(shadow) if err != nil { return richLayout{}, err } path.FillColor = &FillColor{Value: "128 128 128"} shadows = append(shadows, GraphicObject{Type: "PathObject", PathObject: path}) } anchor := len(result.objects) for _, owner := range owners[from+span.start : from+span.end] { if result.anchors[owner] < 0 { result.anchors[owner] = anchor } } result.objects = append(result.objects, GraphicObject{Type: "TextObject", TextObject: text}) thickness := max(0.05, format.Size*0.05) if format.Underline && decorationWidth > 0 { result.objects = append(result.objects, richRectangle(Box{X: span.x, Y: y + ascent + format.Size*0.1, W: decorationWidth, H: thickness}, richColor(format.Color))) } if format.Strikeout && decorationWidth > 0 { result.objects = append(result.objects, richRectangle(Box{X: span.x, Y: y + ascent - format.Size*0.3, W: decorationWidth, H: thickness}, richColor(format.Color))) } result.bounds = richUnion(result.bounds, Box{X: span.x, Y: y, W: max(0, span.width), H: height}) } advance := height if local.LineHeight > 0 { advance = local.LineHeight } y += advance if !finite(y) { return richLayout{}, fmt.Errorf("rich text layout overflow") } } if end == len(chars) { break } start = end + 1 } underlays = append(underlays, shadows...) for i, anchor := range result.anchors { if anchor >= 0 { result.anchors[i] += len(underlays) } } result.objects = append(underlays, result.objects...) for _, object := range result.objects { if object.Type == "PathObject" { box, err := ParseBox(object.PathObject.Boundary) if err != nil { return richLayout{}, err } result.bounds = richUnion(result.bounds, box) } } if !finite(result.bounds.X) || !finite(result.bounds.Y) || !finite(result.bounds.W) || !finite(result.bounds.H) || result.bounds.H <= 0 { return richLayout{}, fmt.Errorf("rich text layout overflow") } if result.bounds.W == 0 { result.bounds.W = min(width, value.Runs[0].Format.Size) } for i, anchor := range result.anchors { if anchor >= 0 { continue } text := richTextObject(value.Runs[i].Format) visible := false text.Visible = &visible text.TextCode = []TextCode{{X: "0", Y: ofdNumber(metrics[i].ascent), Value: " "}} result.anchors[i] = len(result.objects) result.objects = append(result.objects, GraphicObject{Type: "TextObject", TextObject: text}) } for i := range result.objects { if result.objects[i].Type != "TextObject" { continue } text := &result.objects[i].TextObject text.Boundary = editorBoxString(result.bounds) for j := range text.TextCode { code := &text.TextCode[j] x, _ := strconv.ParseFloat(code.X, 64) y, _ := strconv.ParseFloat(code.Y, 64) code.X = ofdNumber(x - result.bounds.X) code.Y = ofdNumber(y - result.bounds.Y) } } return result, nil } // richParagraphOptions 以整段原文检测双向排版,与普通文字的自动模式一致 func richParagraphOptions(chars []rune, options TextLayout) TextLayout { if options.AutoBidi && !options.Shaping.Bidi { for _, char := range chars { p, _ := bidi.LookupRune(char) switch p.Class() { case bidi.R, bidi.AL, bidi.RLE, bidi.RLO, bidi.RLI, bidi.FSI: options.Shaping.Bidi = true } } } return options } // richShapeLine 按方向、脚本和格式塑形整行,跨同格式片段合并,保留区间外的塑形上下文 func richShapeLine(chars []rune, owners []int, value RichText, metrics []richMetrics, options TextLayout, face *typefont.Face) ([]richSpan, []float64, error) { advances := make([]float64, len(chars)) if len(chars) == 0 { return nil, advances, nil } direction := di.DirectionLTR if options.Shaping.Direction == "rtl" { direction = di.DirectionRTL } parts := []shaping.Input{{Text: chars, RunEnd: len(chars), Direction: direction}} if options.Shaping.Bidi { var segmenter shaping.Segmenter parts = segmenter.Split(parts[0], shapingFace{face}) } var spans []richSpan for _, part := range parts { for start := part.RunStart; start < part.RunEnd; { owner := owners[start] format := value.Runs[owner].Format end := start + 1 for end < part.RunEnd && value.Runs[owners[end]].Format == format { end++ } font := metrics[owner].font span := richSpan{start: start, end: end, owner: owner, rtl: part.Direction.Progression() == di.TowardTopLeft} if options.Shape || options.Shaping != (TextShapeOptions{}) { settings := options.Shaping settings.Bidi = false if options.Shaping.Bidi { settings.Direction = "ltr" if span.rtl { settings.Direction = "rtl" } if settings.Script == "" { settings.Script = part.Script.String() } } shaper, ok := font.(FontShaper) if !ok { return nil, nil, fmt.Errorf("rich text shaping: %w", ErrBackendUnavailable) } if contextual, ok := font.(FontShaperRun); ok { shaper = richContextShaper{FontShaperRun: contextual, text: chars, start: start, end: end, options: settings} } else if settings != (TextShapeOptions{}) { configured, ok := font.(FontShaperOptions) if !ok { return nil, nil, fmt.Errorf("rich text shaping options: %w", ErrBackendUnavailable) } shaper = configuredTextShaper{FontShaperOptions: configured, options: settings} } glyphs, steps, err := shapeTextLine(shaper, chars[start:end], format.Font, format.Size, 1, 0) if err != nil { return nil, nil, err } span.glyphs = glyphs copy(advances[start:end], steps) } else { var missing []rune var x float64 for i, char := range chars[start:end] { id := font.GlyphIndex(char) if id == 0 { missing = append(missing, char) } step := float64(font.GlyphAdvance(id)) * format.Size / float64(font.UnitsPerEm()) span.glyphs = append(span.glyphs, ShapedGlyph{Glyph: id, Cluster: i, VisualOrder: i, X: x, Advance: step}) advances[start+i] = step x += step } if err := missingGlyphError(format.Font, missing); err != nil { return nil, nil, err } } spans = append(spans, span) start = end } } spaceTextAdvances(chars, advances, options.LetterSpacing) var sum float64 for _, advance := range advances { sum += advance if !finite(advance) || !finite(sum) { return nil, nil, fmt.Errorf("rich text advance overflow") } } return spans, advances, nil } // ShapeText 返回区间相对簇索引,同时保留完整行提供的上下文 func (s richContextShaper) ShapeText(_ string, size float64) ([]ShapedGlyph, error) { glyphs, err := s.ShapeTextRun(s.text, s.start, s.end, size, s.options) if err != nil { return nil, err } for i := range glyphs { glyphs[i].Cluster -= s.start } return glyphs, nil } // richPositionSpans 按核心排版相同的方向分组规则排列片段,按视觉簇分配字距及两端对齐 func richPositionSpans(spans []richSpan, steps []float64, options TextLayout, x float64) error { order := make([]int, len(spans)) for i := range order { order[i] = i } rtl := options.Shaping.Direction == "rtl" for start := 0; start < len(order); { end := start + 1 for end < len(order) && spans[end].rtl == spans[start].rtl { end++ } if spans[start].rtl != rtl { slices.Reverse(order[start:end]) } start = end } if rtl { slices.Reverse(order) } for _, index := range order { span := &spans[index] extra := make([]float64, span.end-span.start+1) raw := make([]float64, span.end-span.start) for _, glyph := range span.glyphs { raw[glyph.Cluster] += glyph.Advance } for j, step := range steps[span.start:span.end] { extra[j+1] = extra[j] + step - raw[j] span.width += step } shifts := bidiClusterShifts(span.glyphs, extra) span.x = x for j := range span.glyphs { glyph := &span.glyphs[j] glyph.X += x + shifts[glyph.Cluster] if !finite(glyph.X) { return fmt.Errorf("rich text position overflow") } } x += span.width if !finite(x) { return fmt.Errorf("rich text position overflow") } } return nil } // richSpanText 将逻辑原文和绝对视觉位置写为标准字符字形映射 func richSpanText(chars []rune, span richSpan, format TextFormat, baseline float64) TextObject { text := richTextObject(format) code := TextCode{Value: escapeOFDText(string(chars[span.start:span.end]))} dx, dy := make([]string, 0, len(span.glyphs)), make([]string, 0, len(span.glyphs)) for i, glyph := range span.glyphs { if i == 0 { code.X, code.Y = ofdNumber(glyph.X), ofdNumber(baseline+glyph.Y) } else { dx = append(dx, ofdNumber(glyph.X-span.glyphs[i-1].X)) dy = append(dy, ofdNumber(glyph.Y-span.glyphs[i-1].Y)) } } code.DeltaX, code.DeltaY = strings.Join(dx, " "), strings.Join(dy, " ") text.TextCode = []TextCode{code} for i := 0; i < len(span.glyphs); { end := i + 1 for end < len(span.glyphs) && span.glyphs[end].Cluster == span.glyphs[i].Cluster { end++ } next := span.end - span.start if end < len(span.glyphs) { next = span.glyphs[end].Cluster } ids := make([]string, end-i) for j := i; j < end; j++ { ids[j-i] = strconv.Itoa(int(span.glyphs[j].Glyph)) } text.CGTransform = append(text.CGTransform, CGTransform{CodePosition: span.glyphs[i].Cluster, CodeCount: next - span.glyphs[i].Cluster, GlyphCount: end - i, Glyphs: strings.Join(ids, " ")}) i = end } return text } // richSpanOutline 使用现有字体及几何后端生成与文字合成样式一致的墨迹 func richSpanOutline(span richSpan, format TextFormat, metrics richMetrics, geometry GeometryBackend, baseline float64) (GeometryPath, error) { font, ok := metrics.font.(FontOutlines) if !ok { return nil, fmt.Errorf("rich text outlines: %w", ErrBackendUnavailable) } var result GeometryPath for _, glyph := range span.glyphs { path, exists := metrics.outlines[glyph.Glyph] if !exists { var err error path, err = font.GlyphOutline(glyph.Glyph, format.Size) if err != nil { return nil, err } if metrics.bold && len(path) > 0 { stroke, err := geometry.Stroke(path, StrokeOptions{Width: format.Size * 0.04, Join: "Round", Cap: "Round"}) if err != nil { return nil, err } path, err = geometry.Combine(path, stroke, GeometryUnion) if err != nil { return nil, err } } metrics.outlines[glyph.Glyph] = path } if len(path) == 0 { continue } matrix := TranslationMatrix(glyph.X, baseline+glyph.Y) if metrics.italic { matrix = matrix.Multiply(Matrix{a: 1, c: -0.3, d: 1}) } placed, err := geometry.Transform(path, matrix) if err != nil { return nil, err } result = append(result, placed...) } return result, nil } // richSynthetic 与标准绘制一样避免对已加粗或倾斜的字体重复合成 func (e *Editor) richSynthetic(format TextFormat) (bool, bool) { bold, italic := format.Bold, format.Italic var definition *Font for _, resource := range e.resources { if resource.font != nil && resource.font.ID == format.Font { definition = resource.font break } } if definition == nil && e.source != nil { definition = e.source.reader.fontDefinition(format.Font) } if definition != nil { bold = bold && !definition.Bold italic = italic && !definition.Italic if definition.FontFile == "" && basefont.NoSyntheticBold(definition.FontName, definition.FamilyName) { bold = false } } return bold, italic } // richTextObject 构造具有显式填充和标准字重的文字对象 func richTextObject(format TextFormat) TextObject { fill, stroke := true, false weight := 400 if format.Bold { weight = 700 } return TextObject{Font: format.Font, Size: format.Size, Weight: weight, Italic: format.Italic, Fill: &fill, Stroke: &stroke, FillColor: &FillColor{Value: richColor(format.Color)}} } // richColor 将空前景色转换为标准黑色 func richColor(value string) string { if value == "" { return "0 0 0" } return value } // richUnion 合并排版范围和墨迹范围,保留负坐标及零宽空行 func richUnion(a, b Box) Box { x, y := math.Min(a.X, b.X), math.Min(a.Y, b.Y) return Box{X: x, Y: y, W: math.Max(a.X+a.W, b.X+b.W) - x, H: math.Max(a.Y+a.H, b.Y+b.H) - y} } // richRectangle 使用标准填充路径绘制背景或文字装饰 func richRectangle(box Box, color string) GraphicObject { fill, stroke := true, false return GraphicObject{Type: "PathObject", PathObject: PathObject{Boundary: editorBoxString(box), Fill: &fill, Stroke: &stroke, FillColor: &FillColor{Value: color}, AbbreviatedData: fmt.Sprintf("M 0 0 L %s 0 L %s %s L 0 %s C", ofdNumber(box.W), ofdNumber(box.W), ofdNumber(box.H), ofdNumber(box.H))}} }