mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 17:27:08 +08:00
668 lines
22 KiB
Go
668 lines
22 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 (
|
|
"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 {
|
|
width float64
|
|
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
|
|
if value.AutoWidth && !options.Wrap {
|
|
width = max(1, options.LeftIndent+options.RightIndent+max(0, options.FirstLineIndent)+1)
|
|
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{}, err
|
|
}
|
|
}
|
|
_, advances, err := richShapeLine(paragraph, owners[start:end], value, metrics, local, face)
|
|
if err != nil {
|
|
return richLayout{}, err
|
|
}
|
|
length := 0.0
|
|
for _, advance := range advances {
|
|
length += advance
|
|
}
|
|
width = max(width, local.LeftIndent+local.RightIndent+local.FirstLineIndent+length)
|
|
if end == len(chars) {
|
|
break
|
|
}
|
|
start = end + 1
|
|
}
|
|
available = width - options.LeftIndent - options.RightIndent
|
|
}
|
|
result.width = width
|
|
var underlays []GraphicObject
|
|
var shadows []GraphicObject
|
|
var y float64
|
|
for start, paragraphIndex := 0, 0; start <= len(chars); paragraphIndex++ {
|
|
end := start
|
|
for end < len(chars) && chars[end] != '\n' {
|
|
end++
|
|
}
|
|
paragraph := chars[start:end]
|
|
local := options
|
|
if len(value.Paragraphs) > 0 && value.Paragraphs[paragraphIndex].Align != "" {
|
|
local.Align = value.Paragraphs[paragraphIndex].Align
|
|
}
|
|
local = richParagraphOptions(paragraph, local)
|
|
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)
|
|
var clusters map[int]bool
|
|
if local.Align == "distributed" {
|
|
clusters = make(map[int]bool)
|
|
for _, span := range spans {
|
|
for _, glyph := range span.glyphs {
|
|
clusters[span.start+glyph.Cluster] = true
|
|
}
|
|
}
|
|
}
|
|
x, delta := alignTextLine(chars[from:to], steps, available-indent, local.Align, lineIndex+1 < len(lines), local.LetterSpacing, clusters)
|
|
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))}}
|
|
}
|