From a710bc5f41205dc7501411cee02b14ce45ca4eaf Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Mon, 12 Oct 2026 10:47:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=B8=B2=E6=9F=93=E7=BC=96=E8=BE=91):=20?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=B8=B2=E6=9F=93=E7=BC=96=E8=BE=91=EF=BC=8C?= =?UTF-8?q?=E4=BC=98=E5=8C=96=E7=89=88=E6=9C=AC=E5=8E=8B=E7=BC=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/ofdgo.js | 6 +- go.mod | 2 +- go.sum | 4 +- internal/webui/wasm.go | 103 ++++++++----- ofdgo_annot.go | 40 ++++- ofdgo_compression.go | 27 ++-- ofdgo_compression_images.go | 9 +- ofdgo_compression_profiles.go | 46 +++--- ofdgo_compression_resources.go | 127 +++++++++++++--- ofdgo_doc.go | 3 +- ofdgo_drawparam.go | 3 +- ofdgo_editor.go | 30 +++- ofdgo_editor_composite.go | 39 ++++- ofdgo_editor_composite_image.go | 2 +- ofdgo_editor_composite_insert.go | 150 +++++++++++++++++- ofdgo_editor_composite_ops.go | 2 +- ofdgo_editor_composite_style.go | 21 ++- ofdgo_editor_composite_text.go | 6 +- ofdgo_editor_documents.go | 98 +++++++++--- ofdgo_editor_erase.go | 4 +- ofdgo_editor_font_binding.go | 112 +++++++------- ofdgo_editor_font_cleanup.go | 2 +- ofdgo_editor_import.go | 18 ++- ofdgo_editor_open.go | 21 ++- ofdgo_editor_package.go | 24 ++- ofdgo_editor_selection.go | 2 +- ofdgo_editor_style.go | 126 ++++++++++------ ofdgo_editor_text.go | 31 +++- ofdgo_editor_transform.go | 38 +++-- ofdgo_editor_versions.go | 151 ++++++++++++++++--- ofdgo_editor_xml.go | 4 +- ofdgo_extract_image.go | 27 +++- ofdgo_geom_backend.go | 7 +- ofdgo_geom_stroke.go | 2 +- ofdgo_mesh_sampler.go | 120 +++++++++++++++ ofdgo_object.go | 10 +- ofdgo_page.go | 64 ++++---- ofdgo_paint_mesh.go | 50 +++--- ofdgo_pdf.go | 35 ++++- ofdgo_pdf_composite.go | 16 +- ofdgo_pdf_composite_pattern.go | 2 +- ofdgo_pdf_mesh.go | 151 ++++++++++++++++--- ofdgo_pdf_object.go | 11 +- ofdgo_pdf_shading.go | 251 +++++++++++++++++++++++++++++-- ofdgo_pdf_text_replacement.go | 5 +- ofdgo_reader.go | 175 +++++++++++++++++---- ofdgo_reader_dependencies.go | 11 +- ofdgo_render_compile.go | 70 ++------- ofdgo_render_text.go | 5 +- ofdgo_renderer.go | 23 ++- ofdgo_renderer_canvas.go | 29 +++- ofdgo_renderer_clip_canvas.go | 10 +- ofdgo_renderer_geom_canvas.go | 3 +- ofdgo_renderer_paint_canvas.go | 2 +- ofdgo_renderer_path_canvas.go | 58 ++++--- ofdgo_renderer_text_canvas.go | 22 ++- ofdgo_res.go | 2 + ofdgo_search.go | 8 +- ofdgo_shape.go | 4 +- ofdgo_sign.go | 25 ++- ofdgo_sign_verify.go | 4 +- ofdgo_text.go | 142 +++-------------- ofdgo_text_lines.go | 151 +++++++++++++++++++ ofdgo_text_xml.go | 6 + ofdgo_version_edit.go | 30 ++-- ofdgo_version_extract.go | 14 +- ofdgo_version_reader.go | 12 +- ofdgo_writer.go | 65 ++++++-- 68 files changed, 2149 insertions(+), 724 deletions(-) create mode 100644 ofdgo_mesh_sampler.go create mode 100644 ofdgo_text_lines.go diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index ee014f0..2d12ef2 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -2225,7 +2225,7 @@ function updateVersionControls() { el.versionList.inert = busy || Boolean(manager?.loading); el.versionName.disabled = el.versionAdd.disabled = unavailable || !state.editing; el.versionRename.disabled = unavailable || !state.editing || !entry?.id || el.versionName.value.trim() === entry.name; - el.versionDelete.disabled = unavailable || !state.editing || !entry?.id; + el.versionDelete.disabled = unavailable || !state.editing || !entry?.id || manager.entries.length < 2; el.versionDefault.disabled = unavailable || !state.editing || Boolean(entry?.default); el.versionSelect.disabled = unavailable || entry?.id === (state.doc?.documentVersion || ""); } @@ -2695,7 +2695,7 @@ async function editCanvasObject(item) { } if (item.type === "TextObject") { if (!canEditObject(item, "textContent")) { - if (!item.scoped && canEditObject(item, "rewriteText")) { + if (canEditObject(item, "rewriteText")) { state.sourceText = { item, openSeq: state.openSeq }; el.sourceTextValue.value = item.text; el.sourceTextStatus.textContent = ""; @@ -9081,7 +9081,7 @@ function updateObjectControls(item, reset = false) { el.resetCropButton.disabled = canEditObject(item, "resetCrop") ? disabled : el.cropImageButton.disabled || !cropping && (item.scoped ? !item.cropped : !item.imageBounds || ["x", "y", "width", "height"].every(key => Math.abs(item[key] - item.imageBounds[key]) < 1e-9)); el.objectDistribute.disabled = el.objectAlign.disabled || !item.items || item.items.length < 3; el.editObjectButton.disabled = disabled || Boolean(item.items) || !canEditObject(item, "enter") && (item.type === "PathObject" - || !(item.type === "TextObject" && !item.scoped && canEditObject(item, "rewriteText")) && !canEditObject(item, item.type === "TextObject" ? "textContent" : item.type === "ImageObject" ? "replaceImage" : "update")); + || !(item.type === "TextObject" && canEditObject(item, "rewriteText")) && !canEditObject(item, item.type === "TextObject" ? "textContent" : item.type === "ImageObject" ? "replaceImage" : "update")); el.editObjectButton.textContent = canEditObject(item, "enter") ? "进入" : item?.type === "ImageObject" ? "替换" : "修改"; el.editObjectButton.title = canEditObject(item, "enter") ? item?.type === "Annotation" ? "进入注解" : "进入组合" : item?.type === "ImageObject" ? "替换图片" : item?.type === "Annotation" ? "修改注解" : "修改对象"; el.editObjectButton.setAttribute("aria-label", el.editObjectButton.title); diff --git a/go.mod b/go.mod index e0311b0..b8ac6c0 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/tdewolff/canvas v0.0.0-20260923214215-09804640d00c github.com/xiaoqidun/j2kgo v0.0.0-20261011021706-cedb3037ff6e github.com/xiaoqidun/jbig2 v1.1.4 - github.com/xiaoqidun/pdfgo v0.0.0-20261011164142-f98f64ee48bb + github.com/xiaoqidun/pdfgo v0.0.0-20261012024528-667f6fbbcb3f golang.org/x/crypto v0.58.0 golang.org/x/image v0.47.0 golang.org/x/text v0.43.0 diff --git a/go.sum b/go.sum index f8a7427..5accdb7 100644 --- a/go.sum +++ b/go.sum @@ -62,8 +62,8 @@ github.com/xiaoqidun/j2kgo v0.0.0-20261011021706-cedb3037ff6e h1:OuRcvG5g5AieBN3 github.com/xiaoqidun/j2kgo v0.0.0-20261011021706-cedb3037ff6e/go.mod h1:DCwAfuDzCDYqUiAlZ7BDwigTjaXw7o9L1FtEma/D1aA= github.com/xiaoqidun/jbig2 v1.1.4 h1:LF6J1si4JY+TszTh9+m4KAg9/ba0JjfXoGtQTQC2S8c= github.com/xiaoqidun/jbig2 v1.1.4/go.mod h1:9jromqVwMqlptUDEt6Yip8vqYA5I1YjhG7FgMl/Z2dA= -github.com/xiaoqidun/pdfgo v0.0.0-20261011164142-f98f64ee48bb h1:oxXcuj9fGKNLDYmSSiJjQXKkxSWQpmaNJwoF4SEtPZU= -github.com/xiaoqidun/pdfgo v0.0.0-20261011164142-f98f64ee48bb/go.mod h1:hkZn8gHOI94K0fE2ynbXaE8J5T0/jCvoTS/yroKgb+E= +github.com/xiaoqidun/pdfgo v0.0.0-20261012024528-667f6fbbcb3f h1:5fYd+Fv3MoSeS5cOEsSU7y4Oo2IXpDn14e1P/rZTvlY= +github.com/xiaoqidun/pdfgo v0.0.0-20261012024528-667f6fbbcb3f/go.mod h1:hkZn8gHOI94K0fE2ynbXaE8J5T0/jCvoTS/yroKgb+E= 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= diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index c4278a9..03ae12d 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -1869,6 +1869,12 @@ func editorAppearance(item map[string]any, object ofdgo.GraphicObject, paint boo if path.DashOffset != nil { item["dashOffset"] = *path.DashOffset } + if item["scoped"] == true { + item["dashPattern"] = editorDashPattern(path.DashPattern, scale) + if path.DashOffset != nil { + item["dashOffset"] = *path.DashOffset * scale + } + } if paint { item["fill"], item["stroke"] = path.Fill != nil && *path.Fill, path.Stroke == nil || *path.Stroke item["fillColor"], item["strokeColor"] = editorColorHex(path.FillColor), editorColorHex((*ofdgo.FillColor)(path.StrokeColor)) @@ -2259,35 +2265,39 @@ func editorFont(args []js.Value) (any, error) { return successResult(map[string]any{"bytes": bytesToJS(data)}), nil } +// editorObjectAt 读取页面对象或复合对象成员的有效样式快照 +// 入参: page 页面索引, key 界面对象路径 +// 返回: ofdgo.GraphicObject 对象快照, error 错误信息 +func editorObjectAt(page int, key string) (ofdgo.GraphicObject, error) { + if currentEditor == nil { + return ofdgo.GraphicObject{}, fmt.Errorf("no document is being edited") + } + if separator := strings.LastIndex(key, "/"); separator >= 0 { + path, err := compositePath(key[:separator]) + if err != nil { + return ofdgo.GraphicObject{}, err + } + member, err := strconv.Atoi(key[separator+1:]) + if err != nil { + return ofdgo.GraphicObject{}, err + } + members, err := currentEditor.CompositeObjectsAt(page, path, []int{member}) + if err != nil { + return ofdgo.GraphicObject{}, err + } + return members[0].Object, nil + } + return currentEditor.Object(page, key) +} + // previewText 生成保留原定位的输入预览,不修改文档或历史 // 入参: args 页码、对象路径、新文字、可选字体数据和字号 // 返回: any 定位段及字体样式, error 错误信息 func previewText(args []js.Value) (any, error) { - if currentEditor == nil { - return nil, fmt.Errorf("no document is being edited") - } - page, key := args[0].Int(), args[1].String() - var object ofdgo.GraphicObject - if separator := strings.LastIndex(key, "/"); separator >= 0 { - path, err := compositePath(key[:separator]) - if err != nil { - return nil, err - } - member, err := strconv.Atoi(key[separator+1:]) - if err != nil { - return nil, err - } - members, err := currentEditor.CompositeObjectsAt(page, path, []int{member}) - if err != nil { - return nil, err - } - object = members[0].Object - } else { - var err error - object, err = currentEditor.Object(page, key) - if err != nil { - return nil, err - } + key := args[1].String() + object, err := editorObjectAt(args[0].Int(), key) + if err != nil { + return nil, err } text := object.TextObject if object.Type != "TextObject" { @@ -2361,10 +2371,10 @@ func previewText(args []js.Value) (any, error) { } // previewPositionedText 预览保留定位的文字替换,不修改编辑器或撤销记录 -// 入参: args 页面、对象标识和内容 +// 入参: args 页面、对象路径和内容 // 返回: any SVG预览, error 错误信息 func previewPositionedText(args []js.Value) (any, error) { - object, err := currentEditor.Object(args[0].Int(), args[1].String()) + object, err := editorObjectAt(args[0].Int(), args[1].String()) if err != nil { return nil, err } @@ -3190,6 +3200,24 @@ func resolveStylePaint(paint *ofdgo.FillColor) error { return nil } +// editorDashPattern 将内部对象的虚线长度格式化为页面毫米 +// 入参: value 对象空间虚线长度, scale 页面描边倍率 +// 返回: string 页面空间虚线长度,无法解析时保留原文 +func editorDashPattern(value string, scale float64) string { + if scale == 1 || value == "" { + return value + } + fields := strings.Fields(value) + for i, field := range fields { + number, err := strconv.ParseFloat(field, 64) + if err != nil || math.IsNaN(number*scale) || math.IsInf(number*scale, 0) { + return value + } + fields[i] = strconv.FormatFloat(number*scale, 'f', -1, 64) + } + return strings.Join(fields, " ") +} + // objectStyle 读取高级面板提交的外观字段,省略字段保持原值 // 入参: value 前端样式 // 返回: ofdgo.ObjectStyle 库层样式 @@ -3453,21 +3481,26 @@ func documentVersions(args []js.Value) (any, error) { if currentSession == nil { return nil, fmt.Errorf("ofd document is not opened") } - var versions []ofdgo.DocumentVersionInfo - var err error + reader := currentSession.Reader if currentEditor != nil { - versions, err = currentEditor.DocumentVersions() - } else { - versions, err = currentSession.Reader.DocumentVersions() + var err error + reader, err = currentEditor.Reader() + if err != nil { + return nil, err + } } + versions, err := reader.DocumentVersions() if err != nil { return nil, err } - entries := make([]VersionEntry, 1, len(versions)+1) - entries[0].Default = true + entries := make([]VersionEntry, 0, len(versions)+1) + hasMain := reader.OFD.DocBody[reader.DocumentIndex()].DocRoot != "" + if hasMain { + entries = append(entries, VersionEntry{Default: true}) + } for _, version := range versions { entries = append(entries, VersionEntry{ID: version.ID, Index: version.Index, Name: version.Name, Default: version.Current}) - if version.Current { + if version.Current && hasMain { entries[0].Default = false } } diff --git a/ofdgo_annot.go b/ofdgo_annot.go index 426bc61..f897e16 100644 --- a/ofdgo_annot.go +++ b/ofdgo_annot.go @@ -15,6 +15,7 @@ package ofdgo import ( + "context" "encoding/xml" "fmt" ) @@ -149,13 +150,13 @@ func (r *Reader) annotationInfos(index int, pageID string) []AnnotationInfo { } // parseAnnotations 解析注释文件 -// 入参: doc 文档结构 +// 入参: ctx 取消上下文, doc 文档结构 // 返回: error 错误信息 -func (r *Reader) parseAnnotations(doc *Document) error { +func (r *Reader) parseAnnotations(ctx context.Context, doc *Document) error { if doc.Annotations == "" { return nil } - annotations, err := r.annotationIndex(doc) + annotations, err := r.annotationIndexContext(ctx, doc) if err != nil { return err } @@ -164,26 +165,39 @@ func (r *Reader) parseAnnotations(doc *Document) error { } annPath := r.ResPath(doc.Annotations) for _, page := range annotations.Page { + if err := ctx.Err(); err != nil { + return err + } annotPath := resolveResourcePath(annPath, "", page.FileLoc) af, err := r.openFile(annotPath) if err != nil { continue } var pageAnnot PageAnnot - err = xml.NewDecoder(af).Decode(&pageAnnot) + err = xml.NewDecoder(imageInput{ReadCloser: af, context: ctx}).Decode(&pageAnnot) _ = af.Close() if err != nil { continue } r.Annots[page.PageID] = append(r.Annots[page.PageID], pageAnnot.Annot...) } - return nil + return ctx.Err() } // annotationIndex 读取注解索引,将页引用对应到文档声明的标识,不解析页面外观 // 入参: doc 文档结构 // 返回: Annotations 注解索引, error 错误信息 func (r *Reader) annotationIndex(doc *Document) (Annotations, error) { + return r.annotationIndexContext(context.Background(), doc) +} + +// annotationIndexContext 读取注解索引并规范页引用,取消时不提交文件索引 +// 入参: ctx 取消上下文, doc 文档结构 +// 返回: Annotations 注解索引, error 读取或取消错误 +func (r *Reader) annotationIndexContext(ctx context.Context, doc *Document) (Annotations, error) { + if err := ctx.Err(); err != nil { + return Annotations{}, err + } if doc.Annotations == "" { return Annotations{}, nil } @@ -194,17 +208,23 @@ func (r *Reader) annotationIndex(doc *Document) (Annotations, error) { } defer f.Close() var annotations Annotations - if err := xml.NewDecoder(f).Decode(&annotations); err != nil { + if err := xml.NewDecoder(imageInput{ReadCloser: f, context: ctx}).Decode(&annotations); err != nil { return Annotations{}, err } pageIDs := make(map[string]string, len(doc.Pages.Page)) for _, page := range doc.Pages.Page { + if err := ctx.Err(); err != nil { + return Annotations{}, err + } if id := editorResourceID(page.ID); id != "" { pageIDs[id] = page.ID } } - r.annotationFiles = make(map[string][]string) + files := make(map[string][]string) for i := range annotations.Page { + if err := ctx.Err(); err != nil { + return Annotations{}, err + } page := &annotations.Page[i] if id := pageIDs[editorResourceID(page.PageID)]; id != "" { page.PageID = id @@ -213,7 +233,11 @@ func (r *Reader) annotationIndex(doc *Document) (Annotations, error) { if file, ok := r.packageFile(annotPath); ok { annotPath = cleanPackagePath(file.Name) } - r.annotationFiles[annotPath] = append(r.annotationFiles[annotPath], page.PageID) + files[annotPath] = append(files[annotPath], page.PageID) } + if err := ctx.Err(); err != nil { + return Annotations{}, err + } + r.annotationFiles = files return annotations, nil } diff --git a/ofdgo_compression.go b/ofdgo_compression.go index 0c80b2f..ea3043a 100644 --- a/ofdgo_compression.go +++ b/ofdgo_compression.go @@ -140,7 +140,7 @@ func (e *Editor) outputSnapshot(ctx context.Context, options WriteOptions) (*Edi return &snapshot, nil } if e.source != nil { - protected, err := e.source.reader.hasOutputSignatures() + protected, err := e.source.reader.hasOutputSignatures(ctx) if err != nil { return nil, err } @@ -285,28 +285,19 @@ func (r *Reader) compressionImageSafety(ctx context.Context, images []ImageInfo) return paths, safe, ctx.Err() } -// hasOutputSignatures 检查包中各文档,避免改写其他文档签名保护的共享资源 -// 返回: bool 是否存在签名, error 文档读取错误 -func (r *Reader) hasOutputSignatures() (bool, error) { +// hasOutputSignatures 检查全部文档的签名入口,不受当前文档或版本选择影响 +// 入参: ctx 取消上下文 +// 返回: bool 是否声明签名入口, error 取消错误 +func (r *Reader) hasOutputSignatures(ctx context.Context) (bool, error) { for _, body := range r.OFD.DocBody { + if err := ctx.Err(); err != nil { + return false, err + } if body.Signatures != "" { return true, nil } - data, err := r.readFile(body.DocRoot) - if err != nil { - return false, err - } - var doc struct { - Signatures string `xml:"Signatures"` - } - if err := xml.Unmarshal(data, &doc); err != nil { - return false, err - } - if doc.Signatures != "" { - return true, nil - } } - return false, nil + return false, ctx.Err() } // writeOutputEntry 优化ZIP编码,保留资源名称及条目的未压缩字节 diff --git a/ofdgo_compression_images.go b/ofdgo_compression_images.go index cb60d0e..a0f6d6d 100644 --- a/ofdgo_compression_images.go +++ b/ofdgo_compression_images.go @@ -49,7 +49,7 @@ func (r *Reader) compressionImagePlan(ctx context.Context, options CompressionOp document := r if index != r.DocumentIndex() { var err error - document, err = r.Document(index) + document, err = r.DocumentContext(ctx, index) if err != nil { return nil, nil, err } @@ -112,18 +112,19 @@ func (r *Reader) compressionImageSizes(ctx context.Context, images []ImageInfo, if dpi == 0 { return nil, nil } - count, err := r.PageCount() + doc, err := r.DocContext(ctx) if err != nil { return nil, ctx.Err() } renderer := NewRenderer(r, WithRenderBackends(RenderBackends{})) + renderer.pageContext = ctx defer renderer.ClearCache() visitor := &compressionImages{ctx: ctx, dpi: dpi, sizes: make(map[string]image.Point), renderer: renderer} - for index := 0; index < count; index++ { + for _, ref := range doc.Pages.Page { if err := ctx.Err(); err != nil { return nil, err } - page, err := r.PageContentByIndex(index) + page, err := r.PageContentContext(ctx, ref) if err != nil { return nil, ctx.Err() } diff --git a/ofdgo_compression_profiles.go b/ofdgo_compression_profiles.go index 7b92fd7..cf5edf3 100644 --- a/ofdgo_compression_profiles.go +++ b/ofdgo_compression_profiles.go @@ -15,11 +15,8 @@ package ofdgo import ( - "bytes" "context" - "crypto/sha256" "fmt" - "io" "maps" "slices" ) @@ -40,37 +37,38 @@ func compactResourceProfiles(ctx context.Context, reader *Reader, parts map[stri seen := make(map[[32]byte]string) aliases := make(map[string]string) indexes := make(map[string]map[string]string) - buffer := make([]byte, 32*1024) - for _, name := range slices.Sorted(maps.Keys(profiles)) { + ordered := slices.Sorted(maps.Keys(profiles)) + sizes := make(map[string]uint64, len(profiles)) + counts := make(map[uint64]int) + for _, name := range ordered { if err := ctx.Err(); err != nil { return err } if protected[name] { continue } - var input io.ReadCloser - if data, ok := parts[name]; ok { - input = io.NopCloser(bytes.NewReader(data)) - } else { - if reader == nil { - return fmt.Errorf("file not found: %s", name) - } - var err error - input, err = reader.openFile(name) - if err != nil { - return err - } - } - hash := sha256.New() - _, err := io.CopyBuffer(hash, imageInput{ReadCloser: input, context: ctx}, buffer) - closeErr := input.Close() + size, err := outputResourceSize(reader, parts, name) if err != nil { return err } - if closeErr != nil { - return closeErr + sizes[name] = size + counts[size]++ + } + var buffer []byte + for _, name := range ordered { + if err := ctx.Err(); err != nil { + return err + } + if protected[name] || counts[sizes[name]] < 2 { + continue + } + if buffer == nil { + buffer = make([]byte, 32*1024) + } + key, err := outputResourceDigest(ctx, reader, parts, name, buffer) + if err != nil { + return err } - key := [32]byte(hash.Sum(nil)) first, found := seen[key] if !found { seen[key] = name diff --git a/ofdgo_compression_resources.go b/ofdgo_compression_resources.go index 31c6996..e596efb 100644 --- a/ofdgo_compression_resources.go +++ b/ofdgo_compression_resources.go @@ -16,10 +16,12 @@ package ofdgo import ( "bytes" + "context" "crypto/sha256" "encoding/xml" "fmt" "image" + "io" "maps" "path" "slices" @@ -105,6 +107,10 @@ func (e *Editor) compressResourceReferences(parts map[string][]byte, reader *Rea return err } type resourceFile struct{ key, role string } + type resourceSize struct { + role string + size uint64 + } files := make(map[resourceFile]bool) for _, name := range resources { data, err := read(name) @@ -171,31 +177,43 @@ func (e *Editor) compressResourceReferences(parts map[string][]byte, reader *Rea imageResults := make(map[outputImageKey][]byte) imageSources := make(map[string]outputImageSource) ordered := slices.Sorted(maps.Keys(actual)) + sizes := make(map[string]uint64, len(files)) + largeCounts := make(map[resourceSize]int) var imageKeys []string for _, key := range ordered { - if !refs.files[key] && files[resourceFile{key, "MediaFile"}] && !files[resourceFile{key, "FontFile"}] { + if err := e.output.ctx.Err(); err != nil { + return err + } + font, media := files[resourceFile{key, "FontFile"}], files[resourceFile{key, "MediaFile"}] + if refs.files[key] || font == media { + continue + } + size, err := outputResourceSize(reader, parts, actual[key]) + if err != nil { + return err + } + sizes[key] = size + if size > outputOptimizationBufferLimit { + role := "MediaFile" + if font { + role = "FontFile" + } + largeCounts[resourceSize{role, size}]++ + } else if media { imageKeys = append(imageKeys, key) } } load := func(key string) ([]byte, error) { - if data, ok := parts[actual[key]]; ok && len(data) > outputOptimizationBufferLimit { + if sizes[key] > outputOptimizationBufferLimit { return nil, nil } - if reader != nil && parts[actual[key]] == nil { - if file, ok := reader.packageFile(actual[key]); ok && file.UncompressedSize64 > outputOptimizationBufferLimit { - return nil, nil - } - } - data, err := read(actual[key]) - if len(data) > outputOptimizationBufferLimit { - return nil, nil - } - return data, err + return read(actual[key]) } if err := e.prepareOutputImages(imageKeys, load, imageResults, imageSources); err != nil { return err } var fontCache editorSourceFontCache + var hashBuffer []byte for _, key := range ordered { if refs.files[key] || files[resourceFile{key, "FontFile"}] && files[resourceFile{key, "MediaFile"}] { continue @@ -207,20 +225,36 @@ func (e *Editor) compressResourceReferences(parts map[string][]byte, reader *Rea if err := editorProgress(e.OnWriteProgress).withContext(e.output.ctx).report("compress", e.output.completed, 0); err != nil { return err } + if size := sizes[key]; size > outputOptimizationBufferLimit && largeCounts[resourceSize{role, size}] < 2 { + continue + } source, prefetched := imageSources[key] + usage := fontUsage[key] + subset := role == "FontFile" && usage != nil && !usage.unsafe && e.backends.FontResources != nil var data []byte + optimizable := prefetched if !prefetched { var err error - data, err = load(key) - if err != nil { - return err + if role == "MediaFile" || subset { + data, err = load(key) + if err != nil { + return err + } } - if data == nil { - continue + optimizable = data != nil + if optimizable { + source.key.hash = sha256.Sum256(data) + } else { + if hashBuffer == nil { + hashBuffer = make([]byte, 32*1024) + } + source.key.hash, err = outputResourceDigest(e.output.ctx, reader, parts, actual[key], hashBuffer) + if err != nil { + return err + } } - source.key.hash = sha256.Sum256(data) } - if usage := fontUsage[key]; role == "FontFile" && usage != nil && !usage.unsafe { + if optimizable && subset { candidate, err := fontCache.subset(e.output.ctx, e.backends.FontResources, data, usage) if err != nil { return fmt.Errorf("subset font %s: %w", key, err) @@ -234,7 +268,7 @@ func (e *Editor) compressResourceReferences(parts map[string][]byte, reader *Rea source.key.hash = sha256.Sum256(data) } } - if role == "MediaFile" { + if role == "MediaFile" && optimizable { options, demand := e.outputImageOptions(key) cacheKey := outputImageKey{hash: source.key.hash, options: options, size: demand} candidate, cached := imageResults[cacheKey] @@ -304,6 +338,9 @@ func (e *Editor) compressResourceReferences(parts map[string][]byte, reader *Rea } } } + if len(aliases) == 0 && len(formats) == 0 { + return e.output.ctx.Err() + } for _, name := range resources { if refs.versions[cleanPackagePath(name)] { continue @@ -357,3 +394,53 @@ func (e *Editor) compressResourceReferences(parts map[string][]byte, reader *Rea } return e.output.ctx.Err() } + +// outputResourceSize 获取资源的实际输出大小,优先使用本次改动及已加载数据 +// 入参: reader 原包,可为空, parts 输出改动, name 资源路径 +// 返回: uint64 未压缩字节数, error 文件不存在 +func outputResourceSize(reader *Reader, parts map[string][]byte, name string) (uint64, error) { + if data, ok := parts[name]; ok { + return uint64(len(data)), nil + } + if reader != nil { + if data, ok := reader.files[cleanPackagePath(name)]; ok { + return uint64(len(data)), nil + } + if file, ok := reader.packageFile(name); ok { + return file.UncompressedSize64, nil + } + } + return 0, fmt.Errorf("file not found: %s", name) +} + +// outputResourceDigest 流式计算资源摘要,不将原包中的大文件加载到内存 +// 入参: ctx 取消上下文, reader 原包,可为空, parts 输出改动, name 资源路径, buffer 复用缓冲 +// 返回: [32]byte 内容摘要, error 读取或取消错误 +func outputResourceDigest(ctx context.Context, reader *Reader, parts map[string][]byte, name string, buffer []byte) ([32]byte, error) { + if err := ctx.Err(); err != nil { + return [32]byte{}, err + } + var input io.ReadCloser + if data, ok := parts[name]; ok { + input = io.NopCloser(bytes.NewReader(data)) + } else { + if reader == nil { + return [32]byte{}, fmt.Errorf("file not found: %s", name) + } + var err error + input, err = reader.openFile(name) + if err != nil { + return [32]byte{}, err + } + } + hash := sha256.New() + _, err := io.CopyBuffer(hash, imageInput{ReadCloser: input, context: ctx}, buffer) + closeErr := input.Close() + if err != nil { + return [32]byte{}, err + } + if closeErr != nil { + return [32]byte{}, closeErr + } + return [32]byte(hash.Sum(nil)), ctx.Err() +} diff --git a/ofdgo_doc.go b/ofdgo_doc.go index 6050dcb..53cebfd 100644 --- a/ofdgo_doc.go +++ b/ofdgo_doc.go @@ -21,6 +21,7 @@ import ( ) // Document 文档结构 +// Signatures由阅读器按主入口和所选版本解析,不属于文档根节点的XML内容 type Document struct { XMLName xml.Name `xml:"Document"` CommonData CommonData `xml:"CommonData"` @@ -30,7 +31,7 @@ type Document struct { Actions []Action `xml:"Actions>Action"` Bookmarks Bookmarks `xml:"Bookmarks"` Annotations string `xml:"Annotations"` - Signatures string `xml:"Signatures"` + Signatures string `xml:"-"` Attachments Attachments `xml:"Attachments"` CustomTags CustomTags `xml:"CustomTags"` Extensions Extensions `xml:"Extensions"` diff --git a/ofdgo_drawparam.go b/ofdgo_drawparam.go index b791c3d..1916ccc 100644 --- a/ofdgo_drawparam.go +++ b/ofdgo_drawparam.go @@ -78,8 +78,9 @@ func mergeDrawParam(base DrawParam, dp *DrawParam) *DrawParam { if dp.DashOffset != nil { base.DashOffset = dp.DashOffset } - if dp.MiterLimit > 0 { + if dp.MiterLimit > 0 || dp.MiterLimitSet { base.MiterLimit = dp.MiterLimit + base.MiterLimitSet = dp.MiterLimitSet } if dp.FillColor != nil { base.FillColor = dp.FillColor diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 04e75c6..ab7b55f 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -1045,6 +1045,11 @@ func transformEditorObject(object GraphicObject, dx, dy, scale float64) (Graphic obj.LineWidth = defaultPathLineWidth } obj.LineWidth *= scale + obj.DashPattern = scaleTextNumbers(obj.DashPattern, scale) + if obj.DashOffset != nil { + offset := *obj.DashOffset * scale + obj.DashOffset = &offset + } for i := range obj.TextCode { code := &obj.TextCode[i] for _, value := range []*string{&code.X, &code.Y, &code.DeltaX, &code.DeltaY} { @@ -1099,6 +1104,9 @@ func scaleTextNumbers(value string, scale float64) string { // 入参: obj 文字对象 // 返回: error 错误信息 func (e *Editor) prepareText(obj *TextObject) error { + if err := validateEditorStroke(obj.pathStyle()); err != nil { + return err + } if id := editorResourceID(obj.Font); id != "" { obj.Font = id } @@ -1133,7 +1141,7 @@ func (e *Editor) prepareText(obj *TextObject) error { if err := validateTextGlyphs(*obj, glyphCount); err != nil { return err } - if !finite(obj.HScale) || obj.HScale < 0 || !finite(obj.LineWidth) || obj.LineWidth < 0 || !finite(obj.MiterLimit) || obj.MiterLimit < 0 { + if !finite(obj.HScale) || obj.HScale < 0 { return fmt.Errorf("invalid text dimensions") } if obj.Weight < 0 || obj.Weight > 900 || obj.Weight%100 != 0 { @@ -1294,10 +1302,24 @@ func creationBox(value string) (Box, error) { if err != nil { return Box{}, err } - if values[2] <= 0 || values[3] <= 0 { - return Box{}, fmt.Errorf("boundary dimensions must be positive") + box := Box{X: values[0], Y: values[1], W: values[2], H: values[3]} + if err := validateCreationBox(box); err != nil { + return Box{}, err } - return Box{X: values[0], Y: values[1], W: values[2], H: values[3]}, nil + return box, nil +} + +// validateCreationBox 校验编辑范围的有限坐标、端点及正尺寸 +// 入参: box 矩形范围 +// 返回: error 非有限坐标或非正尺寸 +func validateCreationBox(box Box) error { + if !finite(box.X) || !finite(box.Y) || !finite(box.W) || !finite(box.H) || !finite(box.X+box.W) || !finite(box.Y+box.H) { + return fmt.Errorf("boundary coordinates must be finite") + } + if box.W <= 0 || box.H <= 0 { + return fmt.Errorf("boundary dimensions must be positive") + } + return nil } // creationColor 校验默认RGB颜色空间中的基本颜色 diff --git a/ofdgo_editor_composite.go b/ofdgo_editor_composite.go index afa68a1..76d510a 100644 --- a/ofdgo_editor_composite.go +++ b/ofdgo_editor_composite.go @@ -184,7 +184,7 @@ func (e *Editor) FlipCompositeObjects(page int, path ObjectPath, indexes []int, // 入参: page 页面索引, path 父复合对象路径, indexes 成员序号, box 页面毫米目标范围 // 返回: error 错误信息 func (e *Editor) ResizeCompositeObjects(page int, path ObjectPath, indexes []int, box Box) error { - if _, err := creationBox(editorBoxString(box)); err != nil { + if err := validateCreationBox(box); err != nil { return err } return e.editCompositeObjects(page, path, indexes, func(renderer *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { @@ -219,30 +219,46 @@ func (e *Editor) ResizeCompositeObjects(page int, path ObjectPath, indexes []int }) } -// StyleCompositeObjects 原子修改成员透明度、文字颜色及路径填充、描边和线宽 +// StyleCompositeObjects 原子修改成员透明度及文字、路径的填充和描边 // 不重排文字,不改写继承参数;显式实线覆盖原虚线继承 -// 入参: page 页面索引, path 父路径, indexes 成员序号, style 待修改属性,线宽使用页面毫米 +// 入参: page 页面索引, path 父路径, indexes 成员序号, style 待修改属性,线宽、虚线长度及偏移使用页面毫米 // 返回: error 错误信息 func (e *Editor) StyleCompositeObjects(page int, path ObjectPath, indexes []int, style ObjectStyle) error { + if style.DashPattern != nil { + if err := validateEditorStroke(PathObject{DashPattern: *style.DashPattern}); err != nil { + return err + } + } return e.editCompositeObjects(page, path, indexes, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { - paint := style.Fill != nil || style.Stroke != nil || style.FillColor != nil || style.StrokeColor != nil || style.LineWidth != nil || style.DashPattern != nil || style.DashOffset != nil || style.Cap != nil || style.Join != nil + paint := style.Fill != nil || style.Stroke != nil || style.FillColor != nil || style.StrokeColor != nil || style.LineWidth != nil || style.MiterLimit != nil || style.DashPattern != nil || style.DashOffset != nil || style.Cap != nil || style.Join != nil for i, node := range nodes { capability := members[i].Capabilities if !capability.Transform || paint && !capability.Paint { return fmt.Errorf("composite member %d cannot accept this style: %w", indexes[i], capability.editError()) } local := style + var painted PathObject switch members[i].Object.Type { case "TextObject": - local.FillColor = editorStyleColor(style.FillColor, members[i].Object.TextObject.FillColor) + painted = members[i].Object.TextObject.pathStyle() case "PathObject": - p := members[i].Object.PathObject - local.FillColor = editorStyleColor(style.FillColor, p.FillColor) - local.StrokeColor = (*StrokeColor)(editorStyleColor((*FillColor)(style.StrokeColor), (*FillColor)(p.StrokeColor))) + painted = members[i].Object.PathObject + } + if members[i].Object.Type == "TextObject" || members[i].Object.Type == "PathObject" { + local.FillColor = editorStyleColor(style.FillColor, painted.FillColor) + local.StrokeColor = (*StrokeColor)(editorStyleColor((*FillColor)(style.StrokeColor), (*FillColor)(painted.StrokeColor))) if style.LineWidth != nil { width := *style.LineWidth / members[i].StrokeScale local.LineWidth = &width } + if style.DashPattern != nil { + dash := scaleTextNumbers(*style.DashPattern, 1/members[i].StrokeScale) + local.DashPattern = &dash + } + if style.DashOffset != nil { + offset := *style.DashOffset / members[i].StrokeScale + local.DashOffset = &offset + } } object, err := e.styleObject(node.object, local) if err != nil { @@ -650,6 +666,13 @@ func (e *Editor) measureCompositeMembers(renderer *Renderer, nodes []*editorComp } } } + if transform && err == nil && node.object.Type == "TextObject" { + capability.RewriteText = capability.TextContent + if !capability.RewriteText { + text := style.TextObject + capability.RewriteText = e.RewriteText(&text, text.Text()) == nil + } + } if transform && node.object.Type == "ImageObject" && bounds.Bounds.W > 0 && bounds.Bounds.H > 0 { clips := node.object.ImageObject.Clips _, capability.CropImage = node.matrix(clips.usesObjectMatrix()).Invert() diff --git a/ofdgo_editor_composite_image.go b/ofdgo_editor_composite_image.go index 40bb30d..041efe5 100644 --- a/ofdgo_editor_composite_image.go +++ b/ofdgo_editor_composite_image.go @@ -27,7 +27,7 @@ import ( // 入参: page 页面索引, path 父复合路径, index 图片序号, box 页面毫米范围 // 返回: error 错误信息 func (e *Editor) CropCompositeImage(page int, path ObjectPath, index int, box Box) error { - if _, err := creationBox(editorBoxString(box)); err != nil { + if err := validateCreationBox(box); err != nil { return err } return e.editCompositeObjects(page, path, []int{index}, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { diff --git a/ofdgo_editor_composite_insert.go b/ofdgo_editor_composite_insert.go index d61aa15..41274bf 100644 --- a/ofdgo_editor_composite_insert.go +++ b/ofdgo_editor_composite_insert.go @@ -179,6 +179,9 @@ func (e *Editor) wrapCompositePath(renderer *Renderer, node *editorCompositeNode // 入参: node 页面坐标中的对象节点 // 返回: error 错误信息 func (e *Editor) isolateCompositeStyle(node *editorCompositeNode) error { + if err := e.isolateCompositeDefaultPaint(node); err != nil { + return err + } if node.object.Type == "CompositeObject" || node.object.Type == "CompositeGraphicUnit" || node.object.Type == "PathObject" { id, err := e.neutralDrawParam() if err != nil { @@ -220,7 +223,149 @@ func (e *Editor) isolateCompositeStyle(node *editorCompositeNode) error { return node.update(object) } -// neutralDrawParam 注册可复用的完整默认绘制参数,不改变文档默认颜色空间 +// isolateCompositeDefaultPaint 固定基准参数会覆盖的默认透明色,按继承环境复用资源副本 +// 入参: node 待复制的独立节点 +// 返回: error 参数、资源或XML错误 +func (e *Editor) isolateCompositeDefaultPaint(node *editorCompositeNode) error { + type resourceKey struct { + id string + fill, stroke bool + } + cache := make(map[resourceKey]string) + visiting := make(map[string]bool) + var transparent *FillColor + var isolate func(*editorCompositeNode, DrawParam) error + isolate = func(current *editorCompositeNode, base DrawParam) error { + parameter, err := e.editorDrawParam(current.node.attr("DrawParam"), make(map[string]bool)) + if err != nil { + return err + } + base = *mergeDrawParam(base, parameter) + if base.FillColor != nil && base.StrokeColor != nil { + return nil + } + object := current.object + path := object.Type == "PathObject" && object.PathObject.FillColor == nil && base.FillColor == nil + text := object.Type == "TextObject" && object.TextObject.StrokeColor == nil && base.StrokeColor == nil + if path || text { + if transparent == nil { + transparent, err = e.RGBColor(color.NRGBA{}) + if err != nil { + return err + } + } + if path { + object.PathObject.FillColor = transparent + } else { + object.TextObject.StrokeColor = (*StrokeColor)(transparent) + } + return current.update(object) + } + if object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" { + return nil + } + id := object.CompositeGraphicUnit.ResourceID + replacement := id + if id != "" { + key := resourceKey{editorResourceID(id), base.FillColor != nil, base.StrokeColor != nil} + if key.id == "" { + key.id = id + } + if visiting[key.id] { + return fmt.Errorf("cyclic composite resource %q", id) + } + var ok bool + replacement, ok = cache[key] + if !ok { + resource, err := e.compositeDefinition(id) + if err != nil { + return err + } + visiting[key.id] = true + err = isolate(resource, base) + delete(visiting, key.id) + if err != nil { + return err + } + replacement = id + if resource.changed { + for _, entry := range e.resources { + if sameResourceID(entry.composite, id) { + resource.setStates(entry.states) + break + } + } + replacement, err = e.addCompositeResource(resource.data, resource.states) + if err != nil { + return err + } + } + cache[key] = replacement + } + } + var patches []editorXMLPatch + var visit func(*editorXML) error + visit = func(parent *editorXML) error { + for _, child := range parent.children { + if classifyOFDNamespace(child.name.Space) == ofdXMLUnknown { + continue + } + switch child.name.Local { + case "Content", "PageBlock": + if err := visit(child); err != nil { + return err + } + case "TextObject", "PathObject", "CompositeObject", "CompositeGraphicUnit": + if child.name.Local == "TextObject" && (base.StrokeColor != nil || child.child("StrokeColor") != nil) || child.name.Local == "PathObject" && (base.FillColor != nil || child.child("FillColor") != nil) { + continue + } + data, err := editorXMLStandalone(current.data[child.start:child.end], child) + if err != nil { + return err + } + member, err := newEditorCompositeNode(data) + if err != nil { + return err + } + if err := isolate(member, base); err != nil { + return err + } + if member.changed { + patches = append(patches, editorXMLPatch{child.start, child.end, member.data}) + } + } + } + return nil + } + if err := visit(current.node); err != nil { + return err + } + if len(patches) != 0 { + next, err := newEditorCompositeNode(editorPatchXML(current.data, patches)) + if err != nil { + return err + } + current.data, current.node, current.object, current.changed = next.data, next.node, next.object, true + } + if replacement != id { + object = current.object + object.CompositeGraphicUnit.ResourceID = replacement + return current.update(object) + } + return nil + } + var base DrawParam + for _, id := range node.drawParams { + parameter, err := e.editorDrawParam(id, make(map[string]bool)) + if err != nil { + return err + } + base = *mergeDrawParam(base, parameter) + } + return isolate(node, base) +} + +// neutralDrawParam 注册隔离继承用的基准参数,默认透明色由对象显式保留 // 返回: string 绘制参数标识, error 错误信息 func (e *Editor) neutralDrawParam() (string, error) { fill, err := e.RGBColor(color.NRGBA{A: 255}) @@ -261,6 +406,9 @@ func (e *Editor) addEditorDrawParam(draw DrawParam) (string, error) { attrs.add("LineWidth", "0") } attrs.number("MiterLimit", draw.MiterLimit) + if draw.MiterLimitSet && draw.MiterLimit == 0 { + attrs.add("MiterLimit", "0") + } if draw.Relative == "" || draw.dashPatternSet || draw.DashPattern != "" { attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "DashPattern"}, Value: draw.DashPattern}) } diff --git a/ofdgo_editor_composite_ops.go b/ofdgo_editor_composite_ops.go index 3a46277..68cf8ea 100644 --- a/ofdgo_editor_composite_ops.go +++ b/ofdgo_editor_composite_ops.go @@ -312,7 +312,7 @@ func (e *Editor) reshapeCompositeObject(page int, path ObjectPath, index int, ki if lineShapeKind(kind) { object.PathObject, err = object.PathObject.ReshapeLine(kind, transformLineBox(box, inverse)) } else { - if _, err := creationBox(editorBoxString(box)); err != nil { + if err := validateCreationBox(box); err != nil { return err } object.PathObject, err = object.PathObject.Reshape(inverse.TransformBox(box)) diff --git a/ofdgo_editor_composite_style.go b/ofdgo_editor_composite_style.go index 25c12de..c399202 100644 --- a/ofdgo_editor_composite_style.go +++ b/ofdgo_editor_composite_style.go @@ -16,19 +16,21 @@ package ofdgo import "fmt" -// Style 获取内部成员的独立样式快照,路径CTM只表达页面描边倍率 +// Style 获取内部成员的独立样式快照,文字和路径CTM只表达页面描边倍率 // 可传入CopyStyle或CopyCompositeStyle,不作为几何复制快照 // 返回: GraphicObject 有效样式来源 func (m CompositeMember) Style() GraphicObject { object := cloneEditorData(m.Object) if object.Type == "PathObject" { object.PathObject.CTM = Matrix{a: m.StrokeScale, d: m.StrokeScale}.String() + } else if object.Type == "TextObject" { + object.TextObject.CTM = Matrix{a: m.StrokeScale, d: m.StrokeScale}.String() } return object } // CopyCompositeStyle 将同类型样式复制到内部选区,保留内容、位置、裁剪及其他实例 -// 路径复制页面实际线宽和虚线,文字沿用目标段落和基线,全部成功后提交一次撤销 +// 描边复制页面实际线宽和虚线,文字沿用目标段落和基线,全部成功后提交一次撤销 // 入参: page 页面索引, path 父路径, indexes 成员序号, source Object或CompositeMember.Style取得的样式快照 // 返回: error 错误信息 func (e *Editor) CopyCompositeStyle(page int, path ObjectPath, indexes []int, source GraphicObject) error { @@ -36,6 +38,14 @@ func (e *Editor) CopyCompositeStyle(page int, path ObjectPath, indexes []int, so return err } return e.editCompositeObjects(page, path, indexes, func(_ *Renderer, nodes []*editorCompositeNode, members []CompositeMember) error { + if len(nodes) == 0 { + return nil + } + var err error + source, err = e.explicitDefaultPaint(source) + if err != nil { + return err + } for i, node := range nodes { member := members[i] if node.object.Type != source.Type || !member.Capabilities.Transform || source.Type != "ImageObject" && !member.Capabilities.Paint { @@ -52,8 +62,11 @@ func (e *Editor) CopyCompositeStyle(page int, path ObjectPath, indexes []int, so if err != nil { return err } - object.TextObject.FillColor = cloneEditorData(source.TextObject.FillColor) - object.TextObject.Alpha = cloneEditorData(source.TextObject.Alpha) + style := copyEditorPathStyle(object.TextObject.pathStyle(), source.TextObject.pathStyle(), editorStrokeScale(source.TextObject.CTM)/member.StrokeScale) + if err := validateEditorStroke(style); err != nil { + return err + } + object.TextObject = object.TextObject.withPathStyle(style) object.TextObject.DrawParam, err = e.neutralDrawParam() if err != nil { return err diff --git a/ofdgo_editor_composite_text.go b/ofdgo_editor_composite_text.go index 80f9d0d..17aa6cb 100644 --- a/ofdgo_editor_composite_text.go +++ b/ofdgo_editor_composite_text.go @@ -20,8 +20,9 @@ import ( "strconv" ) -// UpdateCompositeText 修改内部普通横向文字,原文保留定位,已知段落按会话选项重排 +// UpdateCompositeText 修改内部文字,原文保留定位,已知段落按会话选项重排 // 空样式保留原字体、字号和颜色,显式改变字号时重新排版并保留首行基线 +// 方向和字形映射沿用RewriteText的定位校验,不改变原文中未修改的属性 // 入参: page 页面索引, path 父复合路径, index 成员序号, value 文字, style 文字样式增量 // 返回: error 错误信息 func (e *Editor) UpdateCompositeText(page int, path ObjectPath, index int, value string, style TextStyle) error { @@ -69,7 +70,8 @@ func (e *Editor) changeCompositeText(page int, path ObjectPath, indexes []int, v // 入参: node 原文节点, member 有效快照, value 新内容, style 样式增量, options 段落选项, frame 文字框调整 // 返回: error 错误信息 func (e *Editor) updateCompositeText(node *editorCompositeNode, member CompositeMember, value *string, style TextStyle, options *TextLayout, frame *Box) error { - if node.object.Type != "TextObject" || !member.Capabilities.ReplaceFont { + rewriteOnly := value != nil && options == nil && frame == nil && style.Color == "" && (style.Font == "" || style.Font == member.Object.TextObject.Font) && (style.Size == 0 || style.Size == member.Object.TextObject.Size) + if node.object.Type != "TextObject" || !member.Capabilities.ReplaceFont && !(rewriteOnly && member.Capabilities.RewriteText) { return fmt.Errorf("composite text style is not supported") } object := cloneEditorData(node.object) diff --git a/ofdgo_editor_documents.go b/ofdgo_editor_documents.go index 3a8b375..96c47f1 100644 --- a/ofdgo_editor_documents.go +++ b/ofdgo_editor_documents.go @@ -78,18 +78,31 @@ func (e *Editor) DocumentInfo(index int) (DocInfo, error) { // 入参: index 文档索引,从0开始, info 文档信息 // 返回: error 修改错误 func (e *Editor) SetDocumentInfo(index int, info DocInfo) error { + return e.SetDocumentInfoContext(context.Background(), index, info) +} + +// SetDocumentInfoContext 修改文档信息,取消或失败时保留元数据和撤销历史 +// 入参: ctx 取消上下文, index 文档索引,从0开始, info 文档信息 +// 返回: error 修改或取消错误 +func (e *Editor) SetDocumentInfoContext(ctx context.Context, index int, info DocInfo) error { + if err := ctx.Err(); err != nil { + return err + } before, err := e.DocumentInfo(index) if err != nil { return err } if reflect.DeepEqual(before, info) { - return nil + return ctx.Err() } if index == e.DocumentIndex() { + if err := ctx.Err(); err != nil { + return err + } e.SetInfo(info) return nil } - return e.editDocuments(func(reader *Reader, data []byte, _ *editorXML) (int, error) { + return e.editDocumentsContext(ctx, func(reader *Reader, data []byte, _ *editorXML) (int, error) { updated, err := editorUpdateInfoXML(data, NewEditor().Info.DocID, index, before, info) if err != nil { return 0, err @@ -104,6 +117,16 @@ func (e *Editor) SetDocumentInfo(index int, info DocInfo) error { // 入参: title 文档标题, width 页面宽度, height 页面高度, at 插入位置,从0开始,可等于文档数量 // 返回: int 新文档索引, error 创建错误 func (e *Editor) AddDocument(title string, width, height float64, at int) (int, error) { + return e.AddDocumentContext(context.Background(), title, width, height, at) +} + +// AddDocumentContext 插入并选中空白文档,取消或失败时保留原包和撤销历史 +// 入参: ctx 取消上下文, title 文档标题, width 页面宽度, height 页面高度, at 插入位置,从0开始,可等于文档数量 +// 返回: int 新文档索引, error 创建或取消错误 +func (e *Editor) AddDocumentContext(ctx context.Context, title string, width, height float64, at int) (int, error) { + if err := ctx.Err(); err != nil { + return 0, err + } if at < 0 || at > e.DocumentCount() { return 0, fmt.Errorf("document index %d out of range", at) } @@ -112,25 +135,34 @@ func (e *Editor) AddDocument(title string, width, height float64, at int) (int, if _, err := created.AddPage(width, height); err != nil { return 0, err } - err := e.editDocuments(func(reader *Reader, data []byte, root *editorXML) (int, error) { + err := e.editDocumentsContext(ctx, func(reader *Reader, data []byte, root *editorXML) (int, error) { occupied := make(map[string]bool) for name := range reader.fileNames { + if err := ctx.Err(); err != nil { + return 0, err + } directory, _, _ := strings.Cut(name, "/") occupied[directory] = true } directory := "" for number := 0; ; number++ { + if err := ctx.Err(); err != nil { + return 0, err + } candidate := fmt.Sprintf("Doc_%d", number) if !occupied[candidate] { directory = candidate break } } - createdReader, err := created.Reader() + createdReader, err := created.ReaderContext(ctx) if err != nil { return 0, err } for name, content := range createdReader.files { + if err := ctx.Err(); err != nil { + return 0, err + } if name == "OFD.xml" { continue } @@ -168,33 +200,59 @@ func (e *Editor) AddDocument(title string, width, height float64, at int) (int, // 入参: index 文档索引,从0开始, title 标题,空值移除标题 // 返回: error 修改错误 func (e *Editor) RenameDocument(index int, title string) error { + return e.RenameDocumentContext(context.Background(), index, title) +} + +// RenameDocumentContext 修改文档标题,取消或失败时保留其他元数据和撤销历史 +// 入参: ctx 取消上下文, index 文档索引,从0开始, title 标题,空值移除标题 +// 返回: error 修改或取消错误 +func (e *Editor) RenameDocumentContext(ctx context.Context, index int, title string) error { + if err := ctx.Err(); err != nil { + return err + } info, err := e.DocumentInfo(index) if err != nil { return err } info.Title = title - return e.SetDocumentInfo(index, info) + return e.SetDocumentInfoContext(ctx, index, info) } // MoveDocument 调整包内文档顺序,当前文档随条目移动,不重写内容,可通过Undo撤销 // 入参: from 原文档索引, to 目标索引,均从0开始 // 返回: error 移动错误 func (e *Editor) MoveDocument(from, to int) error { + return e.MoveDocumentContext(context.Background(), from, to) +} + +// MoveDocumentContext 调整文档顺序,取消或失败时保留当前文档和撤销历史 +// 入参: ctx 取消上下文, from 原文档索引, to 目标索引,均从0开始 +// 返回: error 移动或取消错误 +func (e *Editor) MoveDocumentContext(ctx context.Context, from, to int) error { + if err := ctx.Err(); err != nil { + return err + } if from < 0 || from >= e.DocumentCount() || to < 0 || to >= e.DocumentCount() { return fmt.Errorf("document index out of range") } if from == to { return nil } - return e.editDocuments(func(reader *Reader, data []byte, root *editorXML) (int, error) { + return e.editDocumentsContext(ctx, func(reader *Reader, data []byte, root *editorXML) (int, error) { bodies := make([][]byte, reader.DocumentCount()) for index := range bodies { + if err := ctx.Err(); err != nil { + return 0, err + } body := root.childAt("DocBody", index) bodies[index] = data[body.start:body.end] } moveEditorItem(bodies, from, to) var patches []editorXMLPatch for index, content := range bodies { + if err := ctx.Err(); err != nil { + return 0, err + } body := root.childAt("DocBody", index) patches = append(patches, editorXMLPatch{body.start, body.end, content}) } @@ -295,17 +353,6 @@ func (e *Editor) DeleteDocumentContext(ctx context.Context, index int) error { }) } -// editDocuments 在独立包快照中修改文档入口,失败时不改变当前状态或历史 -// 入参: edit 修改包快照并返回新的当前文档索引 -// 返回: error 修改错误 -func (e *Editor) editDocuments(edit func(*Reader, []byte, *editorXML) (int, error)) error { - ctx := context.Background() - if e.output != nil { - ctx = e.output.ctx - } - return e.editDocumentsContext(ctx, edit) -} - // editDocumentsContext 在独立快照中修改文档入口,确认未取消后提交完整状态 // 入参: ctx 取消上下文, edit 修改包快照并返回新的当前文档索引 // 返回: error 修改或取消错误 @@ -314,6 +361,13 @@ func (e *Editor) editDocumentsContext(ctx context.Context, edit func(*Reader, [] if err != nil { return err } + return e.editDocumentSnapshot(ctx, reader, edit) +} + +// editDocumentSnapshot 修改已有独立快照的文档入口,成功后提交并记录撤销历史 +// 入参: ctx 取消上下文, reader 包含当前编辑内容的独立快照, edit 修改快照并返回当前文档索引 +// 返回: error 修改或取消错误 +func (e *Editor) editDocumentSnapshot(ctx context.Context, reader *Reader, edit func(*Reader, []byte, *editorXML) (int, error)) error { data, err := reader.readFileViewContext(ctx, "OFD.xml") if err != nil { return err @@ -340,11 +394,11 @@ func (e *Editor) editDocumentsContext(ctx context.Context, edit func(*Reader, [] if previous.DocRoot != current.DocRoot || previous.DocInfo.DocID != current.DocInfo.DocID { reader.selectedVersion = nil } - if _, err := reader.Doc(); err != nil { + if _, err := reader.DocContext(ctx); err != nil { return err } next := *e - if err := next.adoptDocumentReader(reader); err != nil { + if err := next.adoptDocumentReader(ctx, reader); err != nil { return err } if err := ctx.Err(); err != nil { @@ -356,9 +410,9 @@ func (e *Editor) editDocumentsContext(ctx context.Context, edit func(*Reader, [] } // adoptDocumentReader 接入已加载的编辑入口,跨文档或版本时重建页面与资源状态 -// 入参: reader 不拥有输入文件的独立阅读器 +// 入参: ctx 取消上下文, reader 不拥有输入文件的独立阅读器 // 返回: error 编辑器初始化错误 -func (e *Editor) adoptDocumentReader(reader *Reader) error { +func (e *Editor) adoptDocumentReader(ctx context.Context, reader *Reader) error { version, err := reader.DocumentVersionID() if err != nil { return err @@ -372,7 +426,7 @@ func (e *Editor) adoptDocumentReader(reader *Reader) error { source.reader = reader e.source = &source } else { - next, err := reader.Editor() + next, err := reader.EditorContext(ctx) if err != nil { return err } diff --git a/ofdgo_editor_erase.go b/ofdgo_editor_erase.go index 2aebcdb..d77bb40 100644 --- a/ofdgo_editor_erase.go +++ b/ofdgo_editor_erase.go @@ -28,7 +28,7 @@ import ( // 入参: page 页面索引, ids 对象标识, box 页面毫米坐标中的擦除范围 // 返回: error 错误信息 func (e *Editor) EraseObjects(page int, ids []string, box Box) error { - if _, err := creationBox(editorBoxString(box)); err != nil { + if err := validateCreationBox(box); err != nil { return err } return e.eraseObjects(page, ids, box, nil, nil) @@ -55,7 +55,7 @@ func (e *Editor) EraseObjectsPath(page int, ids []string, points []Point) error // 入参: page 页面索引, path 父路径, indexes 成员序号, box 页面毫米范围 // 返回: error 错误信息 func (e *Editor) EraseCompositeObjects(page int, path ObjectPath, indexes []int, box Box) error { - if _, err := creationBox(editorBoxString(box)); err != nil { + if err := validateCreationBox(box); err != nil { return err } return e.eraseCompositeObjects(page, path, indexes, geometryRectangle(box)) diff --git a/ofdgo_editor_font_binding.go b/ofdgo_editor_font_binding.go index dc83341..61c553a 100644 --- a/ofdgo_editor_font_binding.go +++ b/ofdgo_editor_font_binding.go @@ -20,6 +20,7 @@ import ( "context" "crypto/sha256" "encoding/xml" + "errors" "fmt" "maps" "slices" @@ -530,7 +531,7 @@ func (e *Editor) restoreFontSnapshot(state Editor) { *e = state } -// pruneExternalFontFiles 清理全包无引用字体并同步版本清单,保留附件及无法确认的扩展引用 +// pruneExternalFontFiles 流式核对全包字体引用并同步版本清单,保留附件及无法确认的扩展引用 // 入参: ctx 取消上下文, reader 包快照, candidates 待清理的字体路径 // 返回: error 取消或读取错误 func pruneExternalFontFiles(ctx context.Context, reader *Reader, candidates ...string) error { @@ -559,7 +560,7 @@ func pruneExternalFontFiles(ctx context.Context, reader *Reader, candidates ...s } structures["OFD.xml"] = true files := make(map[string]bool) - versions := make(map[string]*editorFontXML) + versions := make(map[string][]versionEditLink) names := slices.Sorted(maps.Keys(structures)) for _, name := range slices.Sorted(maps.Keys(reader.fileNames)) { if !structures[name] && strings.HasSuffix(strings.ToLower(name), ".xml") { @@ -573,55 +574,46 @@ func pruneExternalFontFiles(ctx context.Context, reader *Reader, candidates ...s if files[name] && !structures[name] { continue } - data, err := reader.readFileViewContext(ctx, name) - if err != nil { + root, uncertain := "", false + inspect := func(node xml.StartElement) error { + if root == "" { + root = node.Name.Local + } + if classifyOFDNamespace(node.Name.Space) == ofdXMLUnknown || slices.Contains([]string{"CustomTags", "Extensions", "ExtendData", "Data"}, node.Name.Local) { + uncertain = true + return fmt.Errorf("unknown font file dependencies in %s", name) + } + for _, attr := range node.Attr { + if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != "http://www.w3.org/XML/1998/namespace" { + uncertain = true + return fmt.Errorf("unknown font file dependencies in %s", name) + } + } + return nil + } + add := func(location string, kind int) { + location = cleanPackagePath(location) + if root == "DocVersion" && kind == packageReferenceData { + return + } + delete(removed, location) + if kind == packageReferenceData { + files[location] = true + } + } + record := func(node *editorXML, attribute, target string) { + if root == "DocVersion" && node.name.Local == "File" && attribute == "" && removed[target] { + versions[name] = append(versions[name], versionEditLink{node: node, target: target}) + } + } + if err := reader.scanDocumentFileReferences(ctx, name, true, add, record, inspect); err != nil { + var syntax *xml.SyntaxError + if uncertain || errors.As(err, &syntax) { + return ctx.Err() + } return err } - root, err := parseEditorXMLContext(ctx, data) - if err != nil { - return ctx.Err() - } - base := "" - if root.matchesOFD("Res") { - base = root.attr("BaseLoc") - } - if root.matchesOFD("DocVersion") { - versions[name] = &editorFontXML{name: name, data: data, root: root} - } - var retains func(*editorXML) bool - retains = func(node *editorXML) bool { - if ctx.Err() != nil { - return true - } - if classifyOFDNamespace(node.name.Space) == ofdXMLUnknown || slices.Contains([]string{"CustomTags", "Extensions", "ExtendData", "Data"}, node.name.Local) { - return true - } - parent := "" - if node.parent != nil { - parent = node.parent.name.Local - } - for _, attr := range node.attrs { - if attr.Name.Space != "" && attr.Name.Space != "xmlns" && attr.Name.Space != "http://www.w3.org/XML/1998/namespace" { - return true - } - if kind := packageReference(root.name.Local, parent, node.name.Local, attr.Name.Local); attr.Name.Space == "" && kind != packageReferenceNone { - location := cleanPackagePath(resolveResourcePath(name, base, attr.Value)) - delete(removed, location) - if kind == packageReferenceData { - files[location] = true - } - } - } - if kind := packageReference(root.name.Local, parent, node.name.Local, ""); kind != packageReferenceNone && !(root.matchesOFD("DocVersion") && parent == "FileList" && node.matchesOFD("File")) { - location := resolveResourcePath(name, base, strings.TrimSpace(editorImportText(data, node))) - delete(removed, cleanPackagePath(location)) - if kind == packageReferenceData { - files[cleanPackagePath(location)] = true - } - } - return slices.ContainsFunc(node.children, retains) - } - if retains(root) || len(removed) == 0 { + if len(removed) == 0 { return ctx.Err() } } @@ -629,23 +621,25 @@ func pruneExternalFontFiles(ctx context.Context, reader *Reader, candidates ...s return err } updates := make(map[string][]byte) - for name, file := range versions { + for name, links := range versions { if err := ctx.Err(); err != nil { return err } var patches []editorXMLPatch - if list := file.root.child("FileList"); list != nil { - for _, node := range list.children { - if err := ctx.Err(); err != nil { - return err - } - if node.matchesOFD("File") && removed[resolveResourcePath(name, "", strings.TrimSpace(editorImportText(file.data, node)))] { - patches = append(patches, editorXMLPatch{start: node.start, end: node.end}) - } + for _, link := range links { + if err := ctx.Err(); err != nil { + return err + } + if removed[link.target] { + patches = append(patches, editorXMLPatch{start: link.node.start, end: link.node.end}) } } if len(patches) != 0 { - updates[name] = editorPatchXML(file.data, patches) + data, err := reader.readFileViewContext(ctx, name) + if err != nil { + return err + } + updates[name] = editorPatchXML(data, patches) } } if err := ctx.Err(); err != nil { diff --git a/ofdgo_editor_font_cleanup.go b/ofdgo_editor_font_cleanup.go index 379fc9b..d267297 100644 --- a/ofdgo_editor_font_cleanup.go +++ b/ofdgo_editor_font_cleanup.go @@ -35,7 +35,7 @@ func (e *Editor) compactVersionFonts(ctx context.Context, parts map[string][]byt if !versioned { return nil } - protected, err := reader.hasOutputSignatures() + protected, err := reader.hasOutputSignatures(ctx) if err != nil || protected { return err } diff --git a/ofdgo_editor_import.go b/ofdgo_editor_import.go index 04b97ea..1fb0d28 100644 --- a/ofdgo_editor_import.go +++ b/ofdgo_editor_import.go @@ -1212,6 +1212,15 @@ func (m *editorPageImport) encode(entry editorImportEntry, node *editorXML) ([]b var content []byte pageContent := name == "Page" && node.parent == nil if pageContent { + for _, child := range node.children { + if child.matchesOFD("PageRes") || !entry.template && child.matchesOFD("Template") { + encoded, err := m.encode(entry, child) + if err != nil { + return nil, err + } + content = append(content, encoded...) + } + } local := node.child("Area") if local == nil { var templates []Template @@ -1234,20 +1243,17 @@ func (m *editorPageImport) encode(entry editorImportEntry, node *editorXML) ([]b if err != nil { return nil, err } - content = encoded + content = append(content, encoded...) } else { encoded, err := m.encode(entry, local) if err != nil { return nil, err } - content = encoded + content = append(content, encoded...) } } for _, child := range node.children { - if pageContent && entry.template && child.matchesOFD("Template") { - continue - } - if pageContent && child.name.Local == "Area" { + if pageContent && (child.matchesOFD("Area") || child.matchesOFD("Template") || child.matchesOFD("PageRes")) { continue } if name == "Goto" && child.name.Local == "Bookmark" { diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 56c0fd1..81820b5 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -16,6 +16,7 @@ package ofdgo import ( "bytes" + "context" "encoding/xml" "errors" "fmt" @@ -117,7 +118,15 @@ type PageCapabilities struct { // 版本共享文件在提交修改时隔离,保留其他版本及其文件清单 // 返回: *Editor 编辑器, error 错误信息 func (r *Reader) Editor() (*Editor, error) { - _, err := r.Doc() + return r.EditorContext(context.Background()) +} + +// EditorContext 打开当前文档进行保真编辑,读取及构建页面索引时响应取消 +// 编辑器及其快照使用期间不得关闭输入Reader,不修改输入包或限制编辑权限 +// 入参: ctx 取消上下文 +// 返回: *Editor 编辑器, error 初始化或取消错误 +func (r *Reader) EditorContext(ctx context.Context) (*Editor, error) { + _, err := r.DocContext(ctx) if err != nil { return nil, err } @@ -144,6 +153,9 @@ func (r *Reader) Editor() (*Editor, error) { e.pages = make([]PageContent, len(doc.Pages.Page)) sourcePages := make([]editorSourcePage, len(doc.Pages.Page)) for i, page := range doc.Pages.Page { + if err := ctx.Err(); err != nil { + return nil, err + } if page.ID == "" || e.source.pages[page.ID] != nil { return nil, fmt.Errorf("missing or duplicate page ID %q", page.ID) } @@ -151,6 +163,9 @@ func (r *Reader) Editor() (*Editor, error) { e.source.pages[page.ID] = &sourcePages[i] e.pages[i] = PageContent{ID: page.ID} } + if err := ctx.Err(); err != nil { + return nil, err + } return e, nil } @@ -646,11 +661,15 @@ func editorPreservedObject(before, after GraphicObject) bool { a.ID, a.Boundary, a.CTM, a.Alpha = b.ID, b.Boundary, b.CTM, b.Alpha a.Fill, a.Stroke, a.FillColor, a.StrokeColor, a.LineWidth = b.Fill, b.Stroke, b.FillColor, b.StrokeColor, b.LineWidth a.LineWidthSet = b.LineWidthSet + a.MiterLimit, a.MiterLimitSet = b.MiterLimit, b.MiterLimitSet + a.DashOffset, a.DashPattern, a.Cap, a.Join = b.DashOffset, b.DashPattern, b.Cap, b.Join + a.dashPatternSet = b.dashPatternSet case "PathObject": a, b := &before.PathObject, after.PathObject a.ID, a.Boundary, a.CTM, a.Alpha = b.ID, b.Boundary, b.CTM, b.Alpha a.Fill, a.Stroke, a.FillColor, a.StrokeColor, a.LineWidth = b.Fill, b.Stroke, b.FillColor, b.StrokeColor, b.LineWidth a.LineWidthSet = b.LineWidthSet + a.MiterLimit, a.MiterLimitSet = b.MiterLimit, b.MiterLimitSet a.DashOffset, a.DashPattern, a.Cap, a.Join = b.DashOffset, b.DashPattern, b.Cap, b.Join a.dashPatternSet = b.dashPatternSet case "ImageObject": diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go index 759501f..4d36d33 100644 --- a/ofdgo_editor_package.go +++ b/ofdgo_editor_package.go @@ -340,11 +340,23 @@ func (e *Editor) sourcePageXML(index int, source *editorSourcePage) ([]byte, err } patches = append(patches, editorXMLPatch{area.start, area.end, updated}) } else { - value, err := source.root.containerXML("Area", nil, source.root.textXML("PhysicalBox", page.Area.PhysicalBox)) + var fields []byte + for _, field := range [][2]string{{"PhysicalBox", page.Area.PhysicalBox}, {"ApplicationBox", page.Area.ApplicationBox}, {"ContentBox", page.Area.ContentBox}, {"BleedBox", page.Area.BleedBox}} { + if field[1] != "" { + fields = append(fields, source.root.textXML(field[0], field[1])...) + } + } + value, err := source.root.containerXML("Area", nil, fields) if err != nil { return nil, err } - patches = append(patches, editorXMLPatch{source.root.open, source.root.open, value}) + position := source.root.open + for _, child := range source.root.children { + if child.matchesOFD("Template") || child.matchesOFD("PageRes") { + position = child.end + } + } + patches = append(patches, editorXMLPatch{position, position, value}) } } for layerIndex, original := range source.original.Content.Layer { @@ -546,7 +558,7 @@ func (e *Editor) sourcePageData(page PageContent, references *editorReferenceSca err := x.finish() return bytes.TrimPrefix(data.Bytes(), []byte(xml.Header)), err } - area, err := editorXMLContainer("Area", nil, editorXMLText("PhysicalBox", page.Area.PhysicalBox)) + header, err := e.encodeXML(func(x *ofdXML) { x.pageHeader(page) }) if err != nil { return nil, err } @@ -562,7 +574,7 @@ func (e *Editor) sourcePageData(page PageContent, references *editorReferenceSca if err != nil { return nil, err } - data := append(area, content...) + data := append(bytes.TrimPrefix(header, []byte(xml.Header)), content...) if len(page.Actions) != 0 { actions, err := e.encodeXML(func(x *ofdXML) { x.actions(page.Actions) }) if err != nil { @@ -754,7 +766,7 @@ func (e *Editor) sourceReader(ctx context.Context, progress editorProgress) (*Re delete(reader.pageHeaderCache, name) } } - if _, err := reader.Doc(); err != nil { + if _, err := reader.DocContext(ctx); err != nil { return nil, err } return reader, nil @@ -768,7 +780,7 @@ func (e *Editor) sourceReader(ctx context.Context, progress editorProgress) (*Re if err := reader.initRootContext(ctx); err != nil { return nil, err } - if _, err := reader.Doc(); err != nil { + if _, err := reader.DocContext(ctx); err != nil { return nil, err } for name, header := range e.source.reader.pageHeaderCache { diff --git a/ofdgo_editor_selection.go b/ofdgo_editor_selection.go index df515f5..e7e4c3d 100644 --- a/ofdgo_editor_selection.go +++ b/ofdgo_editor_selection.go @@ -96,7 +96,7 @@ func (e *Editor) FlipSelection(page int, objects, annotations []string, axis str // 入参: page 页面索引, objects 正文标识, annotations 注解标识, box 页面毫米目标范围 // 返回: error 标识、度量或编辑错误 func (e *Editor) ResizeSelection(page int, objects, annotations []string, box Box) error { - if _, err := creationBox(editorBoxString(box)); err != nil { + if err := validateCreationBox(box); err != nil { return err } bounds, err := e.SelectionBounds(page, objects, annotations) diff --git a/ofdgo_editor_style.go b/ofdgo_editor_style.go index 00baf9f..dbd76d2 100644 --- a/ofdgo_editor_style.go +++ b/ofdgo_editor_style.go @@ -23,7 +23,7 @@ import ( ) // ObjectStyle 对象外观,nil字段保持原值,颜色的Alpha为nil时保留原颜色透明度 -// StyleObjects使用对象坐标毫米,StyleCompositeObjects的LineWidth使用页面毫米 +// StyleObjects使用对象坐标毫米,StyleCompositeObjects的线宽、虚线长度及偏移使用页面毫米 type ObjectStyle struct { Alpha *int Fill *bool @@ -31,6 +31,7 @@ type ObjectStyle struct { FillColor *FillColor StrokeColor *StrokeColor LineWidth *float64 + MiterLimit *float64 DashPattern *string DashOffset *float64 Cap *string @@ -251,7 +252,7 @@ func (e *Editor) AddDrawParam(draw DrawParam) (string, error) { return e.addEditorDrawParam(cloneEditorData(draw)) } -// StyleObjects 原子更新对象透明度、文字颜色及路径填充和描边样式 +// StyleObjects 原子更新对象透明度及文字、路径的填充和描边样式 // 入参: page 页面索引, ids 对象标识, style 待修改属性 // 返回: error 错误信息 func (e *Editor) StyleObjects(page int, ids []string, style ObjectStyle) error { @@ -260,7 +261,7 @@ func (e *Editor) StyleObjects(page int, ids []string, style ObjectStyle) error { return err } for i := range objects { - if style.Fill != nil || style.Stroke != nil || style.FillColor != nil || style.StrokeColor != nil || style.LineWidth != nil { + if style.Fill != nil || style.Stroke != nil || style.FillColor != nil || style.StrokeColor != nil || style.LineWidth != nil || style.MiterLimit != nil || style.DashPattern != nil || style.DashOffset != nil || style.Cap != nil || style.Join != nil { capability, err := e.ObjectCapabilities(page, ids[i]) if err != nil { return err @@ -278,8 +279,8 @@ func (e *Editor) StyleObjects(page int, ids []string, style ObjectStyle) error { } // CopyStyle 将同类型对象的外观复制到选区,不复制内容、位置、资源数据或动作 -// 文字复制字体、字号、颜色及透明度,沿用目标段落设置;字体标识须属于当前文档 -// 路径按页面中的实际线宽和虚线长度复制,保持目标变换 +// 文字复制字体、字号、填充、描边及透明度,沿用目标段落设置;字体标识须属于当前文档 +// 描边按页面中的实际线宽和虚线长度复制,保持目标变换 // 入参: page 目标页面索引, ids 目标对象标识, source 样式来源快照 // 返回: error 错误信息 func (e *Editor) CopyStyle(page int, ids []string, source GraphicObject) (err error) { @@ -306,21 +307,9 @@ func (e *Editor) CopyStyle(page int, ids []string, source GraphicObject) (err er } } }() - if source.Type == "TextObject" && source.TextObject.FillColor == nil || source.Type == "PathObject" && (source.PathObject.FillColor == nil || source.PathObject.StrokeColor == nil) { - black, err := e.RGBColor(color.NRGBA{A: 255}) - if err != nil { - return err - } - if source.Type == "TextObject" { - source.TextObject.FillColor = black - } else { - if source.PathObject.FillColor == nil { - source.PathObject.FillColor = black - } - if source.PathObject.StrokeColor == nil { - source.PathObject.StrokeColor = (*StrokeColor)(black) - } - } + source, err = e.explicitDefaultPaint(source) + if err != nil { + return err } if source.Type == "TextObject" { objects, err = e.styleTextObjects(objects, TextStyle{Font: source.TextObject.Font, Size: source.TextObject.Size}) @@ -328,8 +317,9 @@ func (e *Editor) CopyStyle(page int, ids []string, source GraphicObject) (err er return err } for i := range objects { - objects[i].TextObject.FillColor = cloneEditorData(source.TextObject.FillColor) - objects[i].TextObject.Alpha = cloneEditorData(source.TextObject.Alpha) + text := objects[i].TextObject + style := copyEditorPathStyle(text.pathStyle(), source.TextObject.pathStyle(), editorStrokeScale(source.TextObject.CTM)/editorStrokeScale(text.CTM)) + objects[i].TextObject = text.withPathStyle(style) } return e.UpdateObjects(page, objects) } @@ -346,6 +336,44 @@ func (e *Editor) CopyStyle(page int, ids []string, source GraphicObject) (err er return e.updateObjects(page, objects, true) } +// explicitDefaultPaint 将样式快照的缺省颜色写为显式颜色,避免继承目标容器颜色 +// 入参: object 已解析继承样式的文字或路径,其他类型保持不变 +// 返回: GraphicObject 颜色完整的样式快照, error 颜色资源错误 +func (e *Editor) explicitDefaultPaint(object GraphicObject) (GraphicObject, error) { + var fill **FillColor + var stroke **StrokeColor + text := object.Type == "TextObject" + if text { + fill, stroke = &object.TextObject.FillColor, &object.TextObject.StrokeColor + } else if object.Type == "PathObject" { + fill, stroke = &object.PathObject.FillColor, &object.PathObject.StrokeColor + } else { + return object, nil + } + if *fill != nil && *stroke != nil { + return object, nil + } + black, err := e.RGBColor(color.NRGBA{A: 255}) + if err != nil { + return GraphicObject{}, err + } + transparent, zero := *black, 0 + transparent.Alpha = &zero + if *fill == nil { + *fill = &transparent + if text { + *fill = black + } + } + if *stroke == nil { + *stroke = (*StrokeColor)(black) + if text { + *stroke = (*StrokeColor)(&transparent) + } + } + return object, nil +} + // StyleImageBorders 原子修改图片边框,保留未修改的颜色、线型及原文扩展 // 入参: page 页面索引, ids 图片标识, style 边框增量 // 返回: error 错误信息 @@ -498,26 +526,28 @@ func (e *Editor) resolveEditorStyleDefaults(object GraphicObject, base *DrawPara if object.Type == "PathObject" { obj := &object.PathObject style = mergeDrawParam(*style, &DrawParam{LineWidth: obj.LineWidth, LineWidthSet: obj.LineWidthSet, Join: obj.Join, Cap: obj.Cap, - MiterLimit: obj.MiterLimit, DashPattern: obj.DashPattern, dashPatternSet: obj.dashPatternSet, DashOffset: obj.DashOffset, + MiterLimit: obj.MiterLimit, MiterLimitSet: obj.MiterLimitSet, DashPattern: obj.DashPattern, dashPatternSet: obj.dashPatternSet, DashOffset: obj.DashOffset, FillColor: obj.FillColor, StrokeColor: obj.StrokeColor}) obj.DrawParam = "" obj.LineWidth, obj.Join, obj.Cap = style.LineWidth, style.Join, style.Cap obj.LineWidthSet = style.LineWidthSet obj.MiterLimit, obj.DashPattern, obj.DashOffset = style.MiterLimit, style.DashPattern, style.DashOffset + obj.MiterLimitSet = style.MiterLimitSet obj.dashPatternSet = style.dashPatternSet obj.FillColor, obj.StrokeColor = style.FillColor, style.StrokeColor } else { obj := &object.TextObject - if style.Cap != "" && style.Cap != "Butt" || style.DashPattern != "" || style.DashOffset != nil && *style.DashOffset != 0 { - return GraphicObject{}, &EditError{Code: EditUnsupportedStyle, Err: fmt.Errorf("text draw parameters require unsupported stroke styles")} - } style = mergeDrawParam(*style, &DrawParam{LineWidth: obj.LineWidth, LineWidthSet: obj.LineWidthSet, Join: obj.Join, - MiterLimit: obj.MiterLimit, FillColor: obj.FillColor, StrokeColor: obj.StrokeColor}) + MiterLimit: obj.MiterLimit, MiterLimitSet: obj.MiterLimitSet, FillColor: obj.FillColor, StrokeColor: obj.StrokeColor, Cap: obj.Cap, + DashPattern: obj.DashPattern, dashPatternSet: obj.dashPatternSet, DashOffset: obj.DashOffset}) text := mergeDrawParam(*dp, &DrawParam{Font: obj.Font, Size: obj.Size, Weight: obj.Weight, Italic: obj.Italic}) obj.DrawParam = "" obj.Font, obj.Size, obj.Weight, obj.Italic = text.Font, text.Size, text.Weight, text.Italic obj.LineWidth, obj.Join, obj.MiterLimit = style.LineWidth, style.Join, style.MiterLimit + obj.MiterLimitSet = style.MiterLimitSet obj.LineWidthSet = style.LineWidthSet + obj.Cap, obj.DashPattern, obj.DashOffset = style.Cap, style.DashPattern, style.DashOffset + obj.dashPatternSet = style.dashPatternSet obj.FillColor, obj.StrokeColor = style.FillColor, style.StrokeColor } for _, color := range []*FillColor{style.FillColor, (*FillColor)(style.StrokeColor)} { @@ -535,10 +565,10 @@ func (e *Editor) styleObject(object GraphicObject, style ObjectStyle) (GraphicOb if style.Alpha != nil && (*style.Alpha < 0 || *style.Alpha > 255) { return GraphicObject{}, fmt.Errorf("alpha must be between 0 and 255") } - stroke := style.DashPattern != nil || style.DashOffset != nil || style.Cap != nil || style.Join != nil + stroke := style.DashPattern != nil || style.DashOffset != nil || style.Cap != nil || style.Join != nil || style.MiterLimit != nil paint := style.Fill != nil || style.Stroke != nil || style.StrokeColor != nil || style.LineWidth != nil - if (stroke || paint) && object.Type != "PathObject" { - return GraphicObject{}, fmt.Errorf("stroke style requires path objects") + if (stroke || paint) && object.Type != "PathObject" && object.Type != "TextObject" { + return GraphicObject{}, fmt.Errorf("stroke style requires text or path objects") } if style.FillColor != nil && object.Type != "TextObject" && object.Type != "PathObject" { return GraphicObject{}, fmt.Errorf("fill color requires text or path objects") @@ -551,13 +581,6 @@ func (e *Editor) styleObject(object GraphicObject, style ObjectStyle) (GraphicOb } } switch object.Type { - case "TextObject": - if style.Alpha != nil { - object.TextObject.Alpha = style.Alpha - } - if style.FillColor != nil { - object.TextObject.FillColor = editorStyleColor(style.FillColor, object.TextObject.FillColor) - } case "ImageObject": if style.Alpha != nil { object.ImageObject.Alpha = style.Alpha @@ -566,8 +589,11 @@ func (e *Editor) styleObject(object GraphicObject, style ObjectStyle) (GraphicOb if style.Alpha != nil { object.CompositeGraphicUnit.Alpha = style.Alpha } - case "PathObject": - path := &object.PathObject + case "TextObject", "PathObject": + path := object.PathObject + if object.Type == "TextObject" { + path = object.TextObject.pathStyle() + } if style.Alpha != nil { path.Alpha = style.Alpha } @@ -603,11 +629,20 @@ func (e *Editor) styleObject(object GraphicObject, style ObjectStyle) (GraphicOb if style.Join != nil { path.Join = *style.Join } + if style.MiterLimit != nil { + path.MiterLimit = *style.MiterLimit + path.MiterLimitSet = path.MiterLimit == 0 + } if stroke { - if err := validateEditorStroke(*path); err != nil { + if err := validateEditorStroke(path); err != nil { return GraphicObject{}, err } } + if object.Type == "TextObject" { + object.TextObject = object.TextObject.withPathStyle(path) + } else { + object.PathObject = path + } default: return GraphicObject{}, fmt.Errorf("unsupported object type %q", object.Type) } @@ -633,7 +668,7 @@ func (e *Editor) validateCopyStyle(source GraphicObject) error { var colors []*FillColor switch source.Type { case "TextObject": - alpha, colors = source.TextObject.Alpha, []*FillColor{source.TextObject.FillColor} + return e.validateCopyStyle(GraphicObject{Type: "PathObject", PathObject: source.TextObject.pathStyle()}) case "PathObject": from := source.PathObject alpha, colors = from.Alpha, []*FillColor{from.FillColor, (*FillColor)(from.StrokeColor)} @@ -668,6 +703,7 @@ func copyEditorPathStyle(to, from PathObject, scale float64) PathObject { to.FillColor, to.StrokeColor = from.FillColor, from.StrokeColor to.LineWidth, to.Cap, to.Join, to.MiterLimit = from.LineWidth, from.Cap, from.Join, from.MiterLimit to.LineWidthSet = from.LineWidthSet + to.MiterLimitSet = from.MiterLimitSet to.DashPattern, to.DashOffset, to.dashPatternSet = from.DashPattern, from.DashOffset, true if to.LineWidth == 0 && !to.LineWidthSet { to.LineWidth = defaultPathLineWidth @@ -678,7 +714,7 @@ func copyEditorPathStyle(to, from PathObject, scale float64) PathObject { if to.Join == "" { to.Join = "Miter" } - if to.MiterLimit == 0 { + if to.MiterLimit == 0 && !to.MiterLimitSet { to.MiterLimit = defaultMiterLimit } if to.DashOffset == nil { @@ -757,6 +793,9 @@ func validateEditorStroke(path PathObject) error { if err != nil { return err } + if len(values) < 2 { + return fmt.Errorf("dash pattern requires at least two lengths") + } total := 0.0 for _, value := range values { if value < 0 { @@ -767,5 +806,8 @@ func validateEditorStroke(path PathObject) error { if total == 0 { return fmt.Errorf("dash pattern must have a positive length") } + if !finite(total) || len(values)%2 != 0 && !finite(total*2) { + return fmt.Errorf("dash period overflow") + } return nil } diff --git a/ofdgo_editor_text.go b/ofdgo_editor_text.go index c27d98a..f3593a9 100644 --- a/ofdgo_editor_text.go +++ b/ofdgo_editor_text.go @@ -81,11 +81,22 @@ func (e *MissingGlyphError) Error() string { // 已知段落重新排版;原布局未知时替换字体和颜色保留坐标,不允许自动调整字号 // 入参: page 页面索引, ids 文字对象标识, style 样式增量 // 返回: error 错误信息 -func (e *Editor) StyleText(page int, ids []string, style TextStyle) 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 @@ -97,7 +108,7 @@ func (e *Editor) StyleText(page int, ids []string, style TextStyle) error { // 原文等长替换保留全部位移,单个TextCode内增删仅调整局部步进,跨定位段增删需显式重排 // 入参: page 页面索引, id 文字对象标识, value 新内容, style 样式增量 // 返回: error 错误信息 -func (e *Editor) UpdateTextContent(page int, id, value string, style TextStyle) 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 @@ -118,6 +129,17 @@ func (e *Editor) UpdateTextContent(page int, id, value string, style TextStyle) } 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 + } + } + }() updates, err := e.styleTextObjects([]GraphicObject{object}, style) if err != nil { return err @@ -178,10 +200,11 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error { } 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 obj.textCodeLineBreak(i) { + if lines.next(codes[i]) { if next[offset] != '\n' { return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing text lines requires explicit layout")} } @@ -212,7 +235,7 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error { } offset, changed := 0, false for i := range codes { - if obj.textCodeLineBreak(i) { + if lines.next(codes[i]) { offset++ } runes := textCodeRunes(codes[i].Value) diff --git a/ofdgo_editor_transform.go b/ofdgo_editor_transform.go index 9fa9676..28f4bea 100644 --- a/ofdgo_editor_transform.go +++ b/ofdgo_editor_transform.go @@ -82,7 +82,7 @@ func (e *Editor) FlipObjects(page int, ids []string, axis string) error { // 入参: page 页面索引, ids 对象标识, box 页面毫米坐标中的目标范围 // 返回: error 错误信息 func (e *Editor) ResizeObjects(page int, ids []string, box Box) error { - if _, err := creationBox(editorBoxString(box)); err != nil { + if err := validateCreationBox(box); err != nil { return err } objects, _, err := e.selectedObjects(page, ids) @@ -123,10 +123,15 @@ func (obj ImageObject) ImageBounds() (Box, error) { if obj.CTM == "" { return box, nil } - if _, err := creationNumbers(obj.CTM, 6); err != nil { + values, err := creationNumbers(obj.CTM, 6) + if err != nil { return Box{}, err } - return TranslationMatrix(box.X, box.Y).Multiply(NewMatrix(obj.CTM)).TransformBox(Box{W: 1, H: 1}), nil + box = TranslationMatrix(box.X, box.Y).Multiply(MatrixFromValues([6]float64(values))).TransformBox(Box{W: 1, H: 1}) + if !finite(box.X) || !finite(box.Y) || !finite(box.W) || !finite(box.H) || !finite(box.X+box.W) || !finite(box.Y+box.H) { + return Box{}, fmt.Errorf("image bounds exceed finite range") + } + return box, nil } // CropImage 按页面毫米坐标重设图片裁剪,保留原始资源及像素,可再次扩大裁剪区域 @@ -519,21 +524,35 @@ func cropImageObject(image *ImageObject, box Box) error { if err != nil { return err } - if _, err := creationBox(editorBoxString(box)); err != nil { + if err := validateCreationBox(box); err != nil { return err } if box.X < full.X-1e-9 || box.Y < full.Y-1e-9 || box.X+box.W > full.X+full.W+1e-9 || box.Y+box.H > full.Y+full.H+1e-9 { return fmt.Errorf("crop must stay within the image bounds") } before, _ := ParseBox(image.Boundary) + matrix := NewMatrix(image.CTM) if image.CTM == "" { - image.CTM = Matrix{a: before.W, d: before.H}.String() + matrix = Matrix{a: before.W, d: before.H} } if box.X != before.X || box.Y != before.Y { - image.CTM = TranslationMatrix(before.X-box.X, before.Y-box.Y).Multiply(NewMatrix(image.CTM)).String() + x, y := matrix.e+(before.X-box.X), matrix.f+(before.Y-box.Y) + if !finite(x) { + x = before.X + matrix.e - box.X + } + if !finite(y) { + y = before.Y + matrix.f - box.Y + } + matrix.e, matrix.f = x, y } - image.Boundary = editorBoxString(box) - image.Clips = nil + if !finite(matrix.e) || !finite(matrix.f) { + return fmt.Errorf("crop transform exceeds finite range") + } + next := *image + if image.CTM == "" || box.X != before.X || box.Y != before.Y { + next.CTM = matrix.String() + } + next.Boundary, next.Clips = editorBoxString(box), nil if box != full { path, err := NewShape(ShapeRectangle, Box{W: box.W, H: box.H}) if err != nil { @@ -541,8 +560,9 @@ func cropImageObject(image *ImageObject, box Box) error { } fill, stroke := true, false path.Fill, path.Stroke = &fill, &stroke - image.Clips = &Clips{Clip: []Clip{{Area: []ClipArea{{Path: []PathObject{path}}}}}} + next.Clips = &Clips{Clip: []Clip{{Area: []ClipArea{{Path: []PathObject{path}}}}}} } + *image = next return nil } diff --git a/ofdgo_editor_versions.go b/ofdgo_editor_versions.go index 934744d..9b8528e 100644 --- a/ofdgo_editor_versions.go +++ b/ofdgo_editor_versions.go @@ -29,14 +29,24 @@ import ( // DocumentVersions 获取当前编辑文档的版本信息,包含尚未写出的修改 // 返回: []DocumentVersionInfo 版本信息, error 读取错误 func (e *Editor) DocumentVersions() ([]DocumentVersionInfo, error) { + return e.DocumentVersionsContext(context.Background()) +} + +// DocumentVersionsContext 获取包含未保存修改的版本信息,支持取消快照准备及描述读取 +// 入参: ctx 取消上下文 +// 返回: []DocumentVersionInfo 版本信息, error 读取或取消错误 +func (e *Editor) DocumentVersionsContext(ctx context.Context) ([]DocumentVersionInfo, error) { + if err := ctx.Err(); err != nil { + return nil, err + } if e.source == nil { return nil, nil } - reader, err := e.Reader() + reader, err := e.ReaderContext(ctx) if err != nil { return nil, err } - return reader.DocumentVersions() + return reader.DocumentVersionsContext(ctx) } // DocumentVersionID 获取当前编辑版本的标识,主入口返回空字符串 @@ -53,22 +63,37 @@ func (e *Editor) DocumentVersionID() (string, error) { // 入参: id 版本标识 // 返回: error 切换错误 func (e *Editor) SelectVersion(id string) error { + return e.SelectVersionContext(context.Background(), id) +} + +// SelectVersionContext 切换编辑版本并保留未保存修改,取消或失败时不改变编辑状态和历史 +// 入参: ctx 取消上下文, id 版本标识,空值选择主入口 +// 返回: error 切换或取消错误 +func (e *Editor) SelectVersionContext(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } current, err := e.DocumentVersionID() if err != nil || current == id { return err } - reader, err := e.Reader() + reader, err := e.readerContext(ctx, nil) if err != nil { return err } - view, err := reader.DocumentVersion(id) + view, err := reader.DocumentVersionContext(ctx, id) if err != nil { return err } before := e.transactionSnapshot() - if err := e.adoptDocumentReader(view); err != nil { + next := *e + if err := next.adoptDocumentReader(ctx, view); err != nil { return err } + if err := ctx.Err(); err != nil { + return err + } + *e = next e.recordTransaction(before) return nil } @@ -180,7 +205,7 @@ func (e *Editor) AddVersionContext(ctx context.Context, name string) (string, er if err != nil { return 0, err } - data, err = editorVersionDefault(data, body, "") + data, err = editorVersionDefault(ctx, data, body, "") if err != nil { return 0, err } @@ -212,16 +237,42 @@ func (e *Editor) AddVersionContext(ctx context.Context, name string) (string, er } // SetDefaultVersion 设置下次打开使用的版本,不切换当前编辑入口,可通过Undo撤销 -// 空标识取消默认版本并使用主入口;修改OFD.xml可能使已有签名失效 +// 空标识取消默认版本并使用主入口,无主入口时不可取消;修改OFD.xml可能使已有签名失效 // 入参: id 版本标识 // 返回: error 设置错误 func (e *Editor) SetDefaultVersion(id string) error { - versions, err := e.DocumentVersions() + return e.SetDefaultVersionContext(context.Background(), id) +} + +// SetDefaultVersionContext 修改默认版本,取消或失败时保留编辑入口和撤销历史 +// 入参: ctx 取消上下文, id 版本标识,空值取消默认版本 +// 返回: error 设置或取消错误 +func (e *Editor) SetDefaultVersionContext(ctx context.Context, id string) error { + if err := ctx.Err(); err != nil { + return err + } + if e.source == nil { + if id == "" { + return nil + } + return fmt.Errorf("document version not found: %s", id) + } + reader, err := e.ReaderContext(ctx) + if err != nil { + return err + } + if id == "" && reader.OFD.DocBody[reader.documentIndex].DocRoot == "" { + return fmt.Errorf("document has no main entry") + } + versions, err := reader.DocumentVersionsContext(ctx) if err != nil { return err } found, changed := id == "", false for _, version := range versions { + if err := ctx.Err(); err != nil { + return err + } found = found || version.ID == id changed = changed || version.Current != (version.ID == id) } @@ -229,14 +280,14 @@ func (e *Editor) SetDefaultVersion(id string) error { return fmt.Errorf("document version not found: %s", id) } if !changed { - return nil + return ctx.Err() } - return e.editDocuments(func(reader *Reader, data []byte, root *editorXML) (int, error) { + return e.editDocumentSnapshot(ctx, reader, func(reader *Reader, data []byte, root *editorXML) (int, error) { current, err := reader.DocumentVersionID() if err != nil { return 0, err } - updated, err := editorVersionDefault(data, root.childAt("DocBody", reader.documentIndex), id) + updated, err := editorVersionDefault(ctx, data, root.childAt("DocBody", reader.documentIndex), id) if err != nil { return 0, err } @@ -250,7 +301,24 @@ func (e *Editor) SetDefaultVersion(id string) error { // 入参: id 版本标识, name 版本名称,空值清空名称 // 返回: error 修改错误 func (e *Editor) RenameVersion(id, name string) error { - versions, err := e.DocumentVersions() + return e.RenameVersionContext(context.Background(), id, name) +} + +// RenameVersionContext 修改版本名称,取消或失败时保留描述文件、共享资源和撤销历史 +// 入参: ctx 取消上下文, id 版本标识, name 版本名称,空值清空名称 +// 返回: error 修改或取消错误 +func (e *Editor) RenameVersionContext(ctx context.Context, id, name string) error { + if err := ctx.Err(); err != nil { + return err + } + if e.source == nil { + return fmt.Errorf("document version not found: %s", id) + } + reader, err := e.ReaderContext(ctx) + if err != nil { + return err + } + versions, err := reader.DocumentVersionsContext(ctx) if err != nil { return err } @@ -259,15 +327,15 @@ func (e *Editor) RenameVersion(id, name string) error { return fmt.Errorf("document version not found: %s", id) } if versions[index].Name == name { - return nil + return ctx.Err() } - return e.editDocuments(func(reader *Reader, data []byte, root *editorXML) (int, error) { + return e.editDocumentSnapshot(ctx, reader, func(reader *Reader, data []byte, root *editorXML) (int, error) { version := versions[index] - original, err := reader.readFile(version.Location) + original, err := reader.readFileViewContext(ctx, version.Location) if err != nil { return 0, err } - descriptor, err := parseEditorXML(original) + descriptor, err := parseEditorXMLContext(ctx, original) if err != nil { return 0, err } @@ -278,6 +346,9 @@ func (e *Editor) RenameVersion(id, name string) error { location := packageAvailableName(reader, nil, version.Location) body := root.childAt("DocBody", reader.documentIndex) for _, node := range body.child("Versions").children { + if err := ctx.Err(); err != nil { + return 0, err + } if node.matchesOFD("Version") && node.attr("ID") == id { fragment, err := editorXMLAttribute(data, node, "BaseLoc", "/"+location) if err != nil { @@ -288,7 +359,7 @@ func (e *Editor) RenameVersion(id, name string) error { } } reader.files[location] = updated - if err := reader.retireVersionFiles(context.Background(), map[string]bool{version.Location: true}); err != nil { + if err := reader.retireVersionFiles(ctx, map[string]bool{version.Location: true}); err != nil { return 0, err } return reader.documentIndex, nil @@ -297,6 +368,7 @@ func (e *Editor) RenameVersion(id, name string) error { // DeleteVersion 删除版本及其独占文件,可通过Undo撤销,不删除文档主入口 // 删除当前版本后转到剩余默认版本或主入口;修改入口可能使已有签名失效 +// 无主入口时保留至少一个版本,需要替代默认版本时选择剩余版本中版本号最大的条目 // 入参: id 版本标识,不能为空 // 返回: error 删除错误 func (e *Editor) DeleteVersion(id string) error { @@ -321,6 +393,30 @@ func (e *Editor) DeleteVersionContext(ctx context.Context, id string) error { if err != nil { return 0, err } + replacement := "" + if reader.OFD.DocBody[reader.documentIndex].DocRoot == "" { + if len(versions) == 1 { + return 0, fmt.Errorf("document must retain at least one version") + } + var fallback *DocumentVersionInfo + hasDefault := false + for i := range versions { + if err := ctx.Err(); err != nil { + return 0, err + } + if i == index { + continue + } + version := &versions[i] + hasDefault = hasDefault || version.Current + if fallback == nil || version.Index > fallback.Index { + fallback = version + } + } + if !hasDefault && (versions[index].Current || current == id) { + replacement = fallback.ID + } + } list := root.childAt("DocBody", reader.documentIndex).child("Versions") for _, node := range list.children { if err := ctx.Err(); err != nil { @@ -334,6 +430,17 @@ func (e *Editor) DeleteVersionContext(ctx context.Context, id string) error { break } } + if replacement != "" { + data = reader.files["OFD.xml"] + root, err = parseEditorXMLContext(ctx, data) + if err != nil { + return 0, err + } + reader.files["OFD.xml"], err = editorVersionDefault(ctx, data, root.childAt("DocBody", reader.documentIndex), replacement) + if err != nil { + return 0, err + } + } if current == id { reader.selectedVersion = nil } @@ -352,15 +459,21 @@ func (e *Editor) DeleteVersionContext(ctx context.Context, id string) error { } // editorVersionDefault 修改版本入口的Current属性,不改变条目顺序或其他属性 -// 入参: data OFD入口原文, body 文档节点, id 默认版本,空值取消默认版本 +// 入参: ctx 取消上下文, data OFD入口原文, body 文档节点, id 默认版本,空值取消默认版本 // 返回: []byte 更新后的入口, error 属性编码错误 -func editorVersionDefault(data []byte, body *editorXML, id string) ([]byte, error) { +func editorVersionDefault(ctx context.Context, data []byte, body *editorXML, id string) ([]byte, error) { + if err := ctx.Err(); err != nil { + return nil, err + } list := body.child("Versions") if list == nil { return data, nil } var patches []editorXMLPatch for _, node := range list.children { + if err := ctx.Err(); err != nil { + return nil, err + } if !node.matchesOFD("Version") { continue } diff --git a/ofdgo_editor_xml.go b/ofdgo_editor_xml.go index b638784..1fea173 100644 --- a/ofdgo_editor_xml.go +++ b/ofdgo_editor_xml.go @@ -318,7 +318,7 @@ func editorXMLSupported(node *editorXML) bool { var allowed, children string switch node.name.Local { case "TextObject", "Text": - allowed, children = "ID Boundary CTM DrawParam Font Size HScale Weight ReadDirection CharDirection LineWidth MiterLimit Join Italic Visible Stroke Fill Alpha", "Clips FillColor StrokeColor CGTransform TextCode" + allowed, children = "ID Boundary CTM DrawParam Font Size HScale Weight ReadDirection CharDirection LineWidth MiterLimit Join Cap DashPattern DashOffset Italic Visible Stroke Fill Alpha", "Clips FillColor StrokeColor CGTransform TextCode" case "CGTransform": allowed, children = "CodePosition CodeCount GlyphCount", "Glyphs" case "Glyphs": @@ -362,7 +362,7 @@ func editorXMLTransformable(node *editorXML) bool { var attrs string switch node.name.Local { case "TextObject": - attrs = "ID Boundary CTM DrawParam Font Size HScale VScale Weight ReadDirection CharDirection LineWidth MiterLimit Join Italic Decoration Visible Stroke Fill Alpha" + attrs = "ID Boundary CTM DrawParam Font Size HScale VScale Weight ReadDirection CharDirection LineWidth MiterLimit Join Cap DashPattern DashOffset Italic Decoration Visible Stroke Fill Alpha" case "PathObject": attrs = "ID Boundary CTM DrawParam LineWidth MiterLimit Join Cap Rule DashPattern DashOffset Visible Stroke Fill Alpha" case "ImageObject": diff --git a/ofdgo_extract_image.go b/ofdgo_extract_image.go index a298b11..87f8a5d 100644 --- a/ofdgo_extract_image.go +++ b/ofdgo_extract_image.go @@ -16,6 +16,7 @@ package ofdgo import ( "archive/zip" + "bytes" "context" "encoding/xml" "fmt" @@ -87,7 +88,7 @@ func (r *Reader) Images(ctx context.Context) ([]ImageInfo, error) { if r.imageCatalog != nil { return slices.Clone(r.imageCatalog), nil } - doc, err := r.Doc() + doc, err := r.DocContext(ctx) if err != nil { return nil, err } @@ -103,7 +104,7 @@ func (r *Reader) Images(ctx context.Context) ([]ImageInfo, error) { if err := ctx.Err(); err != nil { return nil, err } - header, err := r.readPageHeader(page) + header, err := r.readPageHeaderContext(ctx, page) if err != nil { return nil, err } @@ -122,15 +123,19 @@ func (r *Reader) Images(ctx context.Context) ([]ImageInfo, error) { continue } seen[name] = true - data, err := r.readFile(name) + data, err := r.readFileViewContext(ctx, name) if err != nil { return nil, err } var res Res - if err := xml.Unmarshal(data, &res); err != nil { + decoder := xml.NewDecoder(imageInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), context: ctx}) + if err := decoder.Decode(&res); err != nil { return nil, err } for _, media := range res.MultiMedias.MultiMedia { + if err := ctx.Err(); err != nil { + return nil, err + } if media.Type != "Image" { continue } @@ -150,6 +155,9 @@ func (r *Reader) Images(ctx context.Context) ([]ImageInfo, error) { items = append(items, item) } } + if err := ctx.Err(); err != nil { + return nil, err + } r.imageCatalog = items return slices.Clone(items), nil } @@ -163,15 +171,19 @@ func (r *Reader) Image(ctx context.Context, id string) (ImageInfo, error) { } name, _ := resourceValue(r.resourceFiles, id) if name != "" { - data, err := r.readFile(name) + data, err := r.readFileViewContext(ctx, name) if err != nil { return ImageInfo{}, err } var res Res - if err := xml.Unmarshal(data, &res); err != nil { + decoder := xml.NewDecoder(imageInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), context: ctx}) + if err := decoder.Decode(&res); err != nil { return ImageInfo{}, err } for _, media := range res.MultiMedias.MultiMedia { + if err := ctx.Err(); err != nil { + return ImageInfo{}, err + } if imageResourceKey(media.ID) == imageResourceKey(id) && media.Type == "Image" && media.MediaFile != "" { location := resolveResourcePath(name, res.BaseLoc, media.MediaFile) return ImageInfo{ID: id, Name: path.Base(location), Format: media.Format, Location: location}, nil @@ -184,6 +196,9 @@ func (r *Reader) Image(ctx context.Context, id string) (ImageInfo, error) { return ImageInfo{}, err } for _, item := range items { + if err := ctx.Err(); err != nil { + return ImageInfo{}, err + } if imageResourceKey(item.ID) == imageResourceKey(id) { return item, nil } diff --git a/ofdgo_geom_backend.go b/ofdgo_geom_backend.go index ee994d1..e16a0f8 100644 --- a/ofdgo_geom_backend.go +++ b/ofdgo_geom_backend.go @@ -30,14 +30,15 @@ const ( // GeometryOperation 指定路径填充区域的布尔运算 type GeometryOperation uint8 -// StrokeOptions 描述描边参数,长度单位为毫米 +// StrokeOptions 描述描边参数,长度单位为毫米,MiterLimitSet区分显式零值与默认截断值 type StrokeOptions struct { // Width为正有限描边宽度 Width float64 // Cap和Join使用OFD名称,空值分别采用Butt和Miter Cap, Join string - // MiterLimit为尖角长度与半线宽的比值上限,0采用默认值 - MiterLimit float64 + // MiterLimit为尖角长度与半线宽的比值上限 + MiterLimit float64 + MiterLimitSet bool // DashOffset为绝对长度的虚线相位,允许负值 DashOffset float64 // Tolerance为曲线展开误差目标,0采用后端默认精度 diff --git a/ofdgo_geom_stroke.go b/ofdgo_geom_stroke.go index 6b6b511..ae16693 100644 --- a/ofdgo_geom_stroke.go +++ b/ofdgo_geom_stroke.go @@ -371,7 +371,7 @@ func (line geometryPolyline) stroke(result GeometryPath, options StrokeOptions) return } limit := options.MiterLimit - if limit == 0 { + if limit == 0 && !options.MiterLimitSet { limit = defaultMiterLimit } if options.Join == "" || options.Join == "Miter" { diff --git a/ofdgo_mesh_sampler.go b/ofdgo_mesh_sampler.go new file mode 100644 index 0000000..fe22d68 --- /dev/null +++ b/ofdgo_mesh_sampler.go @@ -0,0 +1,120 @@ +// 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 ( + "image/color" + "math" + "slices" +) + +// MeshSampler 保存独立网格快照和空间索引,支持并发采样 +type MeshSampler struct { + mesh MeshShading + nodes []meshSampleNode +} + +// meshSampleNode 保存连续三角形组的包围盒及右子树位置 +type meshSampleNode struct { + minX, minY, maxX, maxY float64 + right int +} + +// NewSampler 创建网格快照,后续修改原网格不影响采样结果 +// 返回: *MeshSampler 只读采样器 +func (m *MeshShading) NewSampler() *MeshSampler { + s := &MeshSampler{} + if m != nil { + s.mesh = *m + s.mesh.Triangles = slices.Clone(m.Triangles) + if len(m.Triangles) > 8 { + s.build(0, len(m.Triangles)) + } + } + return s +} + +// At 采样快照中的预乘颜色,后绘制的三角形优先 +// 入参: x 横坐标, y 纵坐标 +// 返回: color.RGBA 预乘颜色 +func (s *MeshSampler) At(x, y float64) color.RGBA { + if len(s.nodes) == 0 || !finite(x) || !finite(y) { + return s.mesh.At(x, y) + } + if value, ok := s.sample(0, 0, len(s.mesh.Triangles), x, y); ok { + return value + } + return s.mesh.Background +} + +// build 按绘制顺序构建包围盒层级,不重排原三角形 +// 入参: first 起始三角形, end 结束位置 +// 返回: int 节点位置 +func (s *MeshSampler) build(first, end int) int { + index := len(s.nodes) + s.nodes = append(s.nodes, meshSampleNode{}) + if end-first <= 8 { + node := meshSampleNode{minX: math.Inf(1), minY: math.Inf(1), maxX: math.Inf(-1), maxY: math.Inf(-1)} + for _, triangle := range s.mesh.Triangles[first:end] { + a, b, c := triangle[0].Point, triangle[1].Point, triangle[2].Point + d := (b.Y-c.Y)*(a.X-c.X) + (c.X-b.X)*(a.Y-c.Y) + if !finite(d) { + node = meshSampleNode{minX: math.Inf(-1), minY: math.Inf(-1), maxX: math.Inf(1), maxY: math.Inf(1)} + break + } + for _, vertex := range triangle { + node.minX, node.minY = math.Min(node.minX, vertex.Point.X), math.Min(node.minY, vertex.Point.Y) + node.maxX, node.maxY = math.Max(node.maxX, vertex.Point.X), math.Max(node.maxY, vertex.Point.Y) + } + } + if finite(node.minX) && finite(node.minY) && finite(node.maxX) && finite(node.maxY) { + dx := 2e-12*(node.maxX-node.minX) + 1e-14*math.Max(math.Abs(node.minX), math.Abs(node.maxX)) + dy := 2e-12*(node.maxY-node.minY) + 1e-14*math.Max(math.Abs(node.minY), math.Abs(node.maxY)) + node.minX, node.maxX = math.Nextafter(node.minX-dx, math.Inf(-1)), math.Nextafter(node.maxX+dx, math.Inf(1)) + node.minY, node.maxY = math.Nextafter(node.minY-dy, math.Inf(-1)), math.Nextafter(node.maxY+dy, math.Inf(1)) + } + s.nodes[index] = node + return index + } + middle := first + (end-first)/2 + left := s.build(first, middle) + right := s.build(middle, end) + a, b := s.nodes[left], s.nodes[right] + s.nodes[index] = meshSampleNode{minX: math.Min(a.minX, b.minX), minY: math.Min(a.minY, b.minY), maxX: math.Max(a.maxX, b.maxX), maxY: math.Max(a.maxY, b.maxY), right: right} + return index +} + +// sample 优先查询后绘制的区域,包围盒外跳过整组三角形 +// 入参: index 节点位置, first 起始三角形, end 结束位置, x 横坐标, y 纵坐标 +// 返回: color.RGBA 采样颜色, bool 是否命中 +func (s *MeshSampler) sample(index, first, end int, x, y float64) (color.RGBA, bool) { + node := s.nodes[index] + if x < node.minX || x > node.maxX || y < node.minY || y > node.maxY { + return color.RGBA{}, false + } + if end-first <= 8 { + for i := end - 1; i >= first; i-- { + if value, ok := meshTriangleColor(&s.mesh.Triangles[i], x, y); ok { + return value, true + } + } + return color.RGBA{}, false + } + middle := first + (end-first)/2 + if value, ok := s.sample(node.right, middle, end, x, y); ok { + return value, true + } + return s.sample(index+1, first, middle, x, y) +} diff --git a/ofdgo_object.go b/ofdgo_object.go index 13e57cb..a5b0c78 100644 --- a/ofdgo_object.go +++ b/ofdgo_object.go @@ -70,7 +70,7 @@ func (clips *Clips) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { return nil } -// UnmarshalXML 解析路径并区分省略样式、显式实线与零线宽 +// UnmarshalXML 解析路径并区分省略样式、显式实线与零值描边参数 // 入参: d XML解码器, start 起始节点 // 返回: error 错误信息 func (p *PathObject) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { @@ -87,6 +87,9 @@ func (p *PathObject) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error if attr.Name.Local == "LineWidth" { p.LineWidthSet = p.LineWidth == 0 } + if attr.Name.Local == "MiterLimit" { + p.MiterLimitSet = p.MiterLimit == 0 + } } if value.DashPattern != nil { p.DashPattern = *value.DashPattern @@ -95,7 +98,7 @@ func (p *PathObject) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error return nil } -// UnmarshalXML 解析绘制参数并保留显式实线和零线宽对基础参数的覆盖 +// UnmarshalXML 解析绘制参数并保留显式实线和零值参数对基础参数的覆盖 // 入参: d XML解码器, start 起始节点 // 返回: error 错误信息 func (p *DrawParam) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { @@ -112,6 +115,9 @@ func (p *DrawParam) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { if attr.Name.Local == "LineWidth" { p.LineWidthSet = p.LineWidth == 0 } + if attr.Name.Local == "MiterLimit" { + p.MiterLimitSet = p.MiterLimit == 0 + } } if value.DashPattern != nil { p.DashPattern = *value.DashPattern diff --git a/ofdgo_page.go b/ofdgo_page.go index e673755..72b6b20 100644 --- a/ofdgo_page.go +++ b/ofdgo_page.go @@ -83,35 +83,41 @@ type ClipArea struct { } // TextObject 文本对象 +// MiterLimitSet为true时,零截断值覆盖继承值,否则零值表示未指定 type TextObject struct { - ID string `xml:"ID,attr"` - Boundary string `xml:"Boundary,attr"` - DrawParam string `xml:"DrawParam,attr"` - LineWidth float64 `xml:"LineWidth,attr"` - Join string `xml:"Join,attr"` - MiterLimit float64 `xml:"MiterLimit,attr"` - Font string `xml:"Font,attr"` - Size float64 `xml:"Size,attr"` - Weight int `xml:"Weight,attr"` - Italic bool `xml:"Italic,attr"` - Decoration string `xml:"Decoration,attr"` - HScale float64 `xml:"HScale,attr"` - VScale float64 `xml:"VScale,attr"` - ReadDirection int `xml:"ReadDirection,attr"` - CharDirection int `xml:"CharDirection,attr"` - CTM string `xml:"CTM,attr"` - Alpha *int `xml:"Alpha,attr"` - Visible *bool `xml:"Visible,attr"` - Fill *bool `xml:"Fill,attr"` - Stroke *bool `xml:"Stroke,attr"` - StrokeColor *StrokeColor `xml:"StrokeColor"` - FillColor *FillColor `xml:"FillColor"` - CGTransform []CGTransform `xml:"CGTransform"` - TextCode []TextCode `xml:"TextCode"` - Clips *Clips `xml:"Clips"` - Actions []Action `xml:"Actions>Action"` - LineWidthSet bool `xml:"-"` - layout *textLayout + ID string `xml:"ID,attr"` + Boundary string `xml:"Boundary,attr"` + DrawParam string `xml:"DrawParam,attr"` + LineWidth float64 `xml:"LineWidth,attr"` + Cap string `xml:"Cap,attr"` + DashPattern string `xml:"DashPattern,attr"` + DashOffset *float64 `xml:"DashOffset,attr"` + Join string `xml:"Join,attr"` + MiterLimit float64 `xml:"MiterLimit,attr"` + Font string `xml:"Font,attr"` + Size float64 `xml:"Size,attr"` + Weight int `xml:"Weight,attr"` + Italic bool `xml:"Italic,attr"` + Decoration string `xml:"Decoration,attr"` + HScale float64 `xml:"HScale,attr"` + VScale float64 `xml:"VScale,attr"` + ReadDirection int `xml:"ReadDirection,attr"` + CharDirection int `xml:"CharDirection,attr"` + CTM string `xml:"CTM,attr"` + Alpha *int `xml:"Alpha,attr"` + Visible *bool `xml:"Visible,attr"` + Fill *bool `xml:"Fill,attr"` + Stroke *bool `xml:"Stroke,attr"` + StrokeColor *StrokeColor `xml:"StrokeColor"` + FillColor *FillColor `xml:"FillColor"` + CGTransform []CGTransform `xml:"CGTransform"` + TextCode []TextCode `xml:"TextCode"` + Clips *Clips `xml:"Clips"` + Actions []Action `xml:"Actions>Action"` + LineWidthSet bool `xml:"-"` + MiterLimitSet bool `xml:"-"` + dashPatternSet bool + layout *textLayout } // FillColor 填充颜色 @@ -171,6 +177,7 @@ type CGTransform struct { } // PathObject 路径对象 +// MiterLimitSet为true时,零截断值覆盖继承值,否则零值表示未指定 type PathObject struct { ID string `xml:"ID,attr"` Boundary string `xml:"Boundary,attr"` @@ -193,6 +200,7 @@ type PathObject struct { Clips *Clips `xml:"Clips"` Actions []Action `xml:"Actions>Action"` LineWidthSet bool `xml:"-"` + MiterLimitSet bool `xml:"-"` dashPatternSet bool } diff --git a/ofdgo_paint_mesh.go b/ofdgo_paint_mesh.go index f5db66c..01d0af0 100644 --- a/ofdgo_paint_mesh.go +++ b/ofdgo_paint_mesh.go @@ -63,32 +63,40 @@ type MeshShading struct { // 返回: color.RGBA 采样颜色 func (m *MeshShading) At(x, y float64) color.RGBA { for i := len(m.Triangles) - 1; i >= 0; i-- { - t := m.Triangles[i] - a, b, c := t[0].Point, t[1].Point, t[2].Point - d := (b.Y-c.Y)*(a.X-c.X) + (c.X-b.X)*(a.Y-c.Y) - if d == 0 { - continue + if value, ok := meshTriangleColor(&m.Triangles[i], x, y); ok { + return value } - u := ((b.Y-c.Y)*(x-c.X) + (c.X-b.X)*(y-c.Y)) / d - v := ((c.Y-a.Y)*(x-c.X) + (a.X-c.X)*(y-c.Y)) / d - w := 1 - u - v - if u < -1e-12 || v < -1e-12 || w < -1e-12 { - continue - } - var values [4]float64 - alpha := 0.0 - for j, weight := range [3]float64{u, v, w} { - alpha += weight * t[j].Alpha - for k := range values { - values[k] += weight * t[j].Values[k] - } - } - red, green, blue := t[0].rgb(values) - return color.RGBA{meshByte(red * alpha), meshByte(green * alpha), meshByte(blue * alpha), meshByte(alpha)} } return m.Background } +// meshTriangleColor 按面积权重采样单个三角形,范围外不产生颜色 +// 入参: triangle 三角形顶点, x 横坐标, y 纵坐标 +// 返回: color.RGBA 预乘颜色, bool 是否位于三角形内 +func meshTriangleColor(triangle *[3]MeshVertex, x, y float64) (color.RGBA, bool) { + a, b, c := triangle[0].Point, triangle[1].Point, triangle[2].Point + d := (b.Y-c.Y)*(a.X-c.X) + (c.X-b.X)*(a.Y-c.Y) + if d == 0 { + return color.RGBA{}, false + } + u := ((b.Y-c.Y)*(x-c.X) + (c.X-b.X)*(y-c.Y)) / d + v := ((c.Y-a.Y)*(x-c.X) + (a.X-c.X)*(y-c.Y)) / d + w := 1 - u - v + if u < -1e-12 || v < -1e-12 || w < -1e-12 { + return color.RGBA{}, false + } + var values [4]float64 + alpha := 0.0 + for j, weight := range [3]float64{u, v, w} { + alpha += weight * triangle[j].Alpha + for k := range values { + values[k] += weight * triangle[j].Values[k] + } + } + red, green, blue := triangle[0].rgb(values) + return color.RGBA{meshByte(red * alpha), meshByte(green * alpha), meshByte(blue * alpha), meshByte(alpha)}, true +} + // outline 返回方向一致的三角形轮廓,供绘制裁剪和对象度量共用 // 返回: GeometryPath 非零绕数填充轮廓 func (m *MeshShading) outline() GeometryPath { diff --git a/ofdgo_pdf.go b/ofdgo_pdf.go index 7975097..c08b956 100644 --- a/ofdgo_pdf.go +++ b/ofdgo_pdf.go @@ -381,6 +381,23 @@ func pdfNumbers(values ...float64) string { return string(data) } +// pdfDashPattern 换算PDF虚线长度,将单值数组展开为等长线段与间隔 +// 入参: dash PDF虚线数组, scale 坐标缩放比例 +// 返回: string OFD虚线数组 +func pdfDashPattern(dash []float64, scale float64) string { + if len(dash) == 0 { + return "" + } + values := make([]float64, max(2, len(dash))) + for i, value := range dash { + values[i] = value * scale + } + if len(dash) == 1 { + values[1] = values[0] + } + return pdfNumbers(values...) +} + // pdfBoundary 序列化对象边界 // 入参: b 对象边界 // 返回: string OFD边界数据 @@ -618,7 +635,10 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { if mark.Fill && mark.Style.FillOverprint && pdfOverprintNeedsSeparation(mark.Style.Fill) || mark.Stroke && mark.Style.StrokeOverprint && pdfOverprintNeedsSeparation(mark.Style.Stroke) { return &pdfgo.UnsupportedError{Feature: "color separation overprint"} } - if p.warning != nil && (mark.Fill && pdfGradientError(mark.Style.Fill) != nil || mark.Stroke && pdfGradientError(mark.Style.Stroke) != nil) { + if p.warning != nil && (mark.Fill && p.gradientError(mark.Style.Fill) != nil || mark.Stroke && p.gradientError(mark.Style.Stroke) != nil) { + if err := p.ctx.Err(); err != nil { + return err + } return p.gradientPath(mark) } box, err := p.pathBoundsContext(p.ctx, mark.Path) @@ -652,7 +672,7 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { ctm = pdfNumbers(m[:]...) } paint := func(value pdfgo.Paint) (*FillColor, error) { - if value.Tiling != nil { + if value.Tiling != nil || pdfGradientPattern(value) { return p.paintColor(value, box) } return drawing.paintColor(value, origin) @@ -685,11 +705,7 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { object.Rule = "Even-Odd" } if len(mark.Style.Dash) > 0 { - values := make([]float64, len(mark.Style.Dash)) - for n, v := range mark.Style.Dash { - values[n] = v * scale - } - object.DashPattern = pdfNumbers(values...) + object.DashPattern = pdfDashPattern(mark.Style.Dash, scale) phase := mark.Style.DashPhase * scale object.DashOffset = &phase } @@ -702,9 +718,12 @@ func (p *pdfImporter) appendPath(mark pdfgo.PathMark) error { // 入参: paint PDF画刷, box 对象边界 // 返回: *FillColor OFD颜色或渐变, error 图案转换错误 func (p *pdfImporter) paintColor(paint pdfgo.Paint, box Box) (*FillColor, error) { - if err := pdfGradientError(paint); err != nil { + if err := p.gradientError(paint); err != nil { return nil, err } + if pdfGradientPattern(paint) { + return p.gradientPattern(paint, box) + } if paint.Mesh != nil { return p.meshColor(paint, box) } diff --git a/ofdgo_pdf_composite.go b/ofdgo_pdf_composite.go index ef0e39a..a459609 100644 --- a/ofdgo_pdf_composite.go +++ b/ofdgo_pdf_composite.go @@ -114,6 +114,7 @@ type pdfCompositeCache struct { imageProcesses map[pdfImageProcessKey]*pdfgo.ImageProcess masks map[*pdfgo.SoftMask][]pdfCompositeNode meshes map[*pdfgo.MeshGradient][]pdfMeshTriangle + linearMeshes *renderCache[pdfLinearMeshKey, []pdfgo.MeshTriangle] patterns map[pdfgo.Paint]*pdfCompositePattern shadingPatterns map[*pdfgo.ShadingPattern][]pdfCompositeNode shadingSources map[pdfShadingSourceKey][]pdfCompositeNode @@ -637,16 +638,19 @@ func (p *pdfImporter) compositeObjects(nodes []pdfCompositeNode) error { } // opaque 检查图元是否无需与页面背景混合 -// 入参: space 当前混合空间 -// 返回: bool 是否可直接保留, error 遮罩解析错误 -func (n pdfCompositeNode) opaque(space *pdfgo.ColorSpace) (bool, error) { +// 入参: n 待检查图元, space 当前混合空间 +// 返回: bool 是否可直接保留, error 遮罩解析或取消错误 +func (p *pdfImporter) opaque(n pdfCompositeNode, space *pdfgo.ColorSpace) (bool, error) { + if err := p.ctx.Err(); err != nil { + return false, err + } if n.group != nil { g := n.group if g.Knockout && n.textObject == nil || g.Alpha != 1 || g.SoftMask != nil || !pdfNormalBlend(g.BlendMode) || g.ColorSpace != nil && !g.ColorSpace.Equal(space) { return false, nil } for _, child := range n.children { - if opaque, err := child.opaque(space); err != nil || !opaque { + if opaque, err := p.opaque(child, space); err != nil || !opaque { return false, err } } @@ -662,8 +666,8 @@ func (n pdfCompositeNode) opaque(space *pdfgo.ColorSpace) (bool, error) { if fill && style.FillOverprint && pdfOverprintNeedsSeparation(style.Fill) || stroke && style.StrokeOverprint && pdfOverprintNeedsSeparation(style.Stroke) { return false, nil } - if fill && pdfGradientError(style.Fill) != nil || stroke && pdfGradientError(style.Stroke) != nil { - return false, nil + if fill && p.gradientError(style.Fill) != nil || stroke && p.gradientError(style.Stroke) != nil { + return false, p.ctx.Err() } if n.image != nil { masked, err := n.image.Image.HasMask() diff --git a/ofdgo_pdf_composite_pattern.go b/ofdgo_pdf_composite_pattern.go index 0330de9..f411b09 100644 --- a/ofdgo_pdf_composite_pattern.go +++ b/ofdgo_pdf_composite_pattern.go @@ -290,7 +290,7 @@ func (p *pdfImporter) directCompositeNode(node pdfCompositeNode, space *pdfgo.Co if group := node.group; group != nil && !group.Knockout && group.Alpha == 1 && group.SoftMask == nil && pdfNormalBlend(group.BlendMode) && (group.ColorSpace == nil || group.ColorSpace.SRGBEquivalent()) { directGroup = true } - opaque, err := node.opaque(space) + opaque, err := p.opaque(node, space) if err != nil { return false, err } diff --git a/ofdgo_pdf_mesh.go b/ofdgo_pdf_mesh.go index 3eaaef6..89a77a4 100644 --- a/ofdgo_pdf_mesh.go +++ b/ofdgo_pdf_mesh.go @@ -18,6 +18,7 @@ import ( "fmt" "math" "runtime" + "slices" "sync" "github.com/xiaoqidun/pdfgo" @@ -49,11 +50,58 @@ type pdfMeshSample struct { patch int } +// pdfLinearMeshKey 定位源网格中需分段展开的三角形,不包含输出变换 +type pdfLinearMeshKey struct { + mesh *pdfgo.MeshGradient + index int +} + +// linearMeshTriangles 在单页内复用只读分区,固定布局计量内存并丢弃超预算缓存 +// 入参: mesh 源网格, index 源三角形索引 +// 返回: []pdfgo.MeshTriangle 只读分区结果, error 转换或取消错误 +func (p *pdfImporter) linearMeshTriangles(mesh *pdfgo.MeshGradient, index int) ([]pdfgo.MeshTriangle, error) { + if err := p.ctx.Err(); err != nil { + return nil, err + } + cache := p.compositingCache() + if cache.linearMeshes == nil { + cache.linearMeshes = &renderCache[pdfLinearMeshKey, []pdfgo.MeshTriangle]{limit: 8 << 20} + } + key := pdfLinearMeshKey{mesh: mesh, index: index} + if triangles, ok := cache.linearMeshes.get(key); ok { + return triangles, nil + } + triangles, err := mesh.LinearTrianglesContext(p.ctx, index) + if err != nil { + return nil, err + } + if len(triangles) > (cache.linearMeshes.limit-64)/216 { + return triangles, nil + } + stored := make([]pdfgo.MeshTriangle, len(triangles)) + colors := make([][3][4]float64, len(triangles)) + for i, triangle := range triangles { + if err := p.ctx.Err(); err != nil { + return nil, err + } + stored[i].Points = triangle.Points + for j, values := range triangle.Colors { + copy(colors[i][j][:], values) + stored[i].Colors[j] = colors[i][j][:len(values):len(values)] + } + } + if err := p.ctx.Err(); err != nil { + return nil, err + } + cache.linearMeshes.put(key, stored, 64+len(stored)*216) + return stored, nil +} + // meshColor 将线性三角网格映射为OFD高洛德渐变,保留顶点和覆盖顺序 // 入参: paint PDF网格画刷, box 对象边界 // 返回: *FillColor OFD渐变, error 不可线性表达的颜色或坐标错误 func (p *pdfImporter) meshColor(paint pdfgo.Paint, box Box) (*FillColor, error) { - if err := pdfGradientError(paint); err != nil { + if err := p.gradientError(paint); err != nil { return nil, err } mesh := paint.Mesh @@ -67,35 +115,79 @@ func (p *pdfImporter) meshColor(paint pdfgo.Paint, box Box) (*FillColor, error) return nil, err } } - for _, triangle := range mesh.Triangles { + var previous pdfgo.MeshTriangle + var previousColors [3][4]float64 + var colors [3][4]float64 + for i := range previous.Colors { + previous.Colors[i] = previousColors[i][:mesh.Space.Components()] + } + for index, triangle := range mesh.Triangles { if err := p.ctx.Err(); err != nil { return nil, err } - for i, point := range triangle.Points { - point = matrix.Apply(point) - if !finite(point.X) || !finite(point.Y) { - return nil, fmt.Errorf("nonfinite mesh vertex") - } - values := triangle.Colors[i] - if icc != "" { - value, err := pdfICCValues(values, mesh.Space.Components()) - if err != nil { - return nil, err - } - shading.Point = append(shading.Point, ShdPoint{X: point.X - box.X, Y: point.Y - box.Y, Color: ShdColor{Value: value, ColorSpace: icc}}) - continue - } - rgb, err := mesh.Space.RGB(values[:mesh.Space.Components()], mesh.Intent) + var err error + colors, err = mesh.LinearColors(index) + var triangles []pdfgo.MeshTriangle + if err != nil { + triangles, err = p.linearMeshTriangles(mesh, index) if err != nil { return nil, err } - color := pdfgo.Paint{RGB: rgb, Alpha: 1} - if mesh.Space.Model == "DeviceCMYK" && !mesh.Space.Calibrated() { - color.CMYK = (*[4]float64)(values) + } else { + for i := range triangle.Colors { + triangle.Colors[i] = colors[i][:mesh.Space.Components()] } - converted := p.color(color) - shading.Point = append(shading.Point, ShdPoint{X: point.X - box.X, Y: point.Y - box.Y, Color: ShdColor{Value: converted.Value, ColorSpace: converted.ColorSpace}}) + triangles = []pdfgo.MeshTriangle{triangle} } + for _, triangle := range triangles { + if err := p.ctx.Err(); err != nil { + return nil, err + } + first, flag := 0, 0 + if len(shading.Point) > 0 { + flag = pdfMeshEdge(previous, triangle) + if flag != 0 { + first = 2 + } + } + for i := first; i < len(triangle.Points); i++ { + point := triangle.Points[i] + point = matrix.Apply(point) + if !finite(point.X) || !finite(point.Y) { + return nil, fmt.Errorf("nonfinite mesh vertex") + } + values := triangle.Colors[i] + if icc != "" { + value, err := pdfICCValues(values, mesh.Space.Components()) + if err != nil { + return nil, err + } + shading.Point = append(shading.Point, ShdPoint{X: point.X - box.X, Y: point.Y - box.Y, EdgeFlag: flag, Color: ShdColor{Value: value, ColorSpace: icc}}) + continue + } + rgb, err := mesh.Space.RGB(values[:mesh.Space.Components()], mesh.Intent) + if err != nil { + return nil, err + } + color := pdfgo.Paint{RGB: rgb, Alpha: 1} + if mesh.Space.Model == "DeviceCMYK" && !mesh.Space.Calibrated() { + color.CMYK = (*[4]float64)(values) + } + converted := p.color(color) + shading.Point = append(shading.Point, ShdPoint{X: point.X - box.X, Y: point.Y - box.Y, EdgeFlag: flag, Color: ShdColor{Value: converted.Value, ColorSpace: converted.ColorSpace}}) + } + previous.Points = triangle.Points + for i := range previous.Colors { + copy(previous.Colors[i], triangle.Colors[i]) + } + } + } + if mesh.Background != nil { + background, err := p.gradientBackgroundColor(*mesh.Background, mesh.Space, mesh.Intent) + if err != nil { + return nil, err + } + shading.Extend, shading.BackColor = 1, &background } color := p.color(paint) color.Value = "" @@ -103,6 +195,21 @@ func (p *pdfImporter) meshColor(paint pdfgo.Paint, box Box) (*FillColor, error) return color, nil } +// pdfMeshEdge 按坐标及颜色分量匹配相邻三角形的共享边,不改变顶点顺序 +// 入参: previous 前一三角形, current 当前三角形 +// 返回: int OFD共享边标志,零表示独立三角形 +func pdfMeshEdge(previous, current pdfgo.MeshTriangle) int { + if previous.Points[2] != current.Points[1] || !slices.Equal(previous.Colors[2], current.Colors[1]) { + return 0 + } + for _, index := range [2]int{1, 0} { + if previous.Points[index] == current.Points[0] && slices.Equal(previous.Colors[index], current.Colors[0]) { + return 2 - index + } + } + return 0 +} + // meshTriangles 按曲面二阶导数界细分,将几何误差控制在输出像素的十六分之一内 // 入参: mesh 原始曲面 // 返回: []pdfMeshTriangle 细分结果, error 取消或坐标错误 diff --git a/ofdgo_pdf_object.go b/ofdgo_pdf_object.go index f695abf..dab7c32 100644 --- a/ofdgo_pdf_object.go +++ b/ofdgo_pdf_object.go @@ -311,7 +311,10 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { if (paintMode == 0 || paintMode == 2) && mark.Style.FillOverprint && pdfOverprintNeedsSeparation(mark.Style.Fill) || (paintMode == 1 || paintMode == 2) && mark.Style.StrokeOverprint && pdfOverprintNeedsSeparation(mark.Style.Stroke) { return &pdfgo.UnsupportedError{Feature: "text color separation overprint"} } - if p.warning != nil && ((paintMode == 0 || paintMode == 2) && pdfGradientError(mark.Style.Fill) != nil || (paintMode == 1 || paintMode == 2) && pdfGradientError(mark.Style.Stroke) != nil) { + if p.warning != nil && ((paintMode == 0 || paintMode == 2) && p.gradientError(mark.Style.Fill) != nil || (paintMode == 1 || paintMode == 2) && p.gradientError(mark.Style.Stroke) != nil) { + if err := p.ctx.Err(); err != nil { + return err + } gradient := mark return p.compositeRegion(nil, pdfCompositeNode{text: &gradient}, &pdfgo.ColorSpace{Model: "DeviceRGB"}, false) } @@ -544,10 +547,14 @@ func (p *pdfImporter) text(mark pdfgo.TextMark) error { box = Box{box.X - margin, box.Y - margin, box.W + 2*margin, box.H + 2*margin} object.LineWidth = lineWidth / scale object.LineWidthSet = object.LineWidth == 0 + object.Cap = []string{"Butt", "Round", "Square"}[mark.Style.Cap] object.Join = []string{"Miter", "Round", "Bevel"}[mark.Style.Join] object.MiterLimit = mark.Style.MiterLimit if len(mark.Style.Dash) > 0 { - return &pdfgo.UnsupportedError{Feature: "dashed text stroke"} + strokeScale := math.Hypot(p.matrix[0], p.matrix[1]) * sx / scale + object.DashPattern = pdfDashPattern(mark.Style.Dash, strokeScale) + phase := mark.Style.DashPhase * strokeScale + object.DashOffset = &phase } } object.Boundary = pdfBoundary(box) diff --git a/ofdgo_pdf_shading.go b/ofdgo_pdf_shading.go index 90fa574..af89767 100644 --- a/ofdgo_pdf_shading.go +++ b/ofdgo_pdf_shading.go @@ -180,10 +180,13 @@ func pdfGradientStopsError(stops []pdfgo.GradientStop, space *pdfgo.ColorSpace) return nil } -// pdfGradientError 检查渐变函数与源空间能否原生映射为OFD分段 +// gradientError 检查渐变函数与源空间能否原生映射为OFD分段 // 入参: paint 画刷 -// 返回: error 不可等价表达的渐变 -func pdfGradientError(paint pdfgo.Paint) error { +// 返回: error 不可等价表达的渐变或取消错误 +func (p *pdfImporter) gradientError(paint pdfgo.Paint) error { + if err := p.ctx.Err(); err != nil { + return err + } if paint.Shading != nil { return &pdfgo.UnsupportedError{Feature: "shading pattern state compositing"} } @@ -192,26 +195,254 @@ func pdfGradientError(paint pdfgo.Paint) error { } if paint.Mesh != nil { mesh := paint.Mesh - if len(mesh.Patches) != 0 || len(mesh.Triangles) == 0 || mesh.UsesFunction() || mesh.Background != nil || mesh.Bounds != nil { + if len(mesh.Patches) != 0 || len(mesh.Triangles) == 0 { return &pdfgo.UnsupportedError{Feature: "nonlinear mesh conversion"} } + for i := range mesh.Triangles { + if err := p.ctx.Err(); err != nil { + return err + } + if _, err := mesh.LinearColors(i); err != nil { + if _, err := p.linearMeshTriangles(mesh, i); err != nil { + return err + } + } + } if mesh.Space.Calibrated() && !pdfICCTransfer(mesh.Space) && !mesh.Space.SRGBEquivalent() { return &pdfgo.UnsupportedError{Feature: "nonlinear calibrated mesh conversion"} } } if paint.Axial != nil { - if paint.Axial.Background != nil || paint.Axial.Bounds != nil { - return &pdfgo.UnsupportedError{Feature: "bounded axial pattern conversion"} - } return pdfGradientStopsError(paint.Axial.Stops, paint.Axial.Space) } if paint.Radial != nil { - if paint.Radial.Background != nil || paint.Radial.Bounds != nil { - return &pdfgo.UnsupportedError{Feature: "bounded radial pattern conversion"} + if paint.Radial.Background != nil && !pdfRadialNested(paint.Radial) { + return &pdfgo.UnsupportedError{Feature: "radial pattern background conversion"} } return pdfGradientStopsError(paint.Radial.Stops, paint.Radial.Space) } - return nil + return p.ctx.Err() +} + +// pdfGradientPattern 判断渐变是否需要底纹保留边界或背景 +// 入参: paint PDF画刷 +// 返回: bool 是否需要底纹单元 +func pdfGradientPattern(paint pdfgo.Paint) bool { + return paint.Axial != nil && (paint.Axial.Bounds != nil || paint.Axial.Background != nil && paint.Axial.Extend != [2]bool{true, true}) || paint.Radial != nil && (paint.Radial.Bounds != nil || paint.Radial.Background != nil && paint.Radial.Extend != [2]bool{true, true}) || paint.Mesh != nil && paint.Mesh.Bounds != nil +} + +// pdfRadialNested 判断起止圆是否严格内含,确保绘制区由内外圆边界唯一限定 +// 入参: gradient 径向渐变 +// 返回: bool 是否严格内含 +func pdfRadialNested(gradient *pdfgo.RadialGradient) bool { + return math.Hypot(gradient.End.X-gradient.Start.X, gradient.End.Y-gradient.Start.Y) < math.Abs(gradient.EndRadius-gradient.StartRadius) +} + +// gradientPattern 用底纹单元保留渐变边界及背景,单元覆盖整个对象以避免重复绘制 +// 入参: paint PDF渐变画刷, box 页面坐标中的对象边界 +// 返回: *FillColor OFD底纹颜色, error 坐标或渐变转换错误 +func (p *pdfImporter) gradientPattern(paint pdfgo.Paint, box Box) (*FillColor, error) { + if !finite(box.X) || !finite(box.Y) || !finite(box.W) || !finite(box.H) || box.W <= 0 || box.H <= 0 { + return nil, fmt.Errorf("invalid PDF gradient paint bounds") + } + var bounds *pdfgo.Rectangle + var background *[4]float64 + var space *pdfgo.ColorSpace + var intent pdfgo.Name + var extend [2]bool + matrix := pdfgo.Identity() + plain := paint + plain.Alpha = 1 + if paint.Axial != nil { + gradient := *paint.Axial + bounds, matrix = gradient.Bounds, gradient.Matrix + background, space, intent, extend = gradient.Background, gradient.Space, gradient.Intent, gradient.Extend + gradient.Bounds = nil + gradient.Background = nil + plain.Axial = &gradient + } else if paint.Radial != nil { + gradient := *paint.Radial + bounds, matrix = gradient.Bounds, gradient.Matrix + background, space, intent, extend = gradient.Background, gradient.Space, gradient.Intent, gradient.Extend + gradient.Bounds = nil + gradient.Background = nil + plain.Radial = &gradient + } else { + gradient := *paint.Mesh + bounds, matrix = gradient.Bounds, gradient.Matrix + gradient.Bounds = nil + plain.Mesh = &gradient + } + if matrix == (pdfgo.Matrix{}) { + matrix = pdfgo.Identity() + } + matrix = p.matrix.Mul(matrix) + if _, ok := matrix.Inverse(); !ok { + return nil, fmt.Errorf("invalid PDF gradient bounds matrix") + } + fill, err := p.paintColor(plain, box) + if err != nil { + return nil, err + } + filled, stroked := true, false + boundary := pdfBoundary(Box{W: box.W, H: box.H}) + object := PathObject{Boundary: boundary, AbbreviatedData: "M 0 0 L " + pdfNumbers(box.W, 0) + " L " + pdfNumbers(box.W, box.H) + " L " + pdfNumbers(0, box.H) + " C", Fill: &filled, Stroke: &stroked, FillColor: fill} + if bounds != nil { + points := [4]pdfgo.Point{{X: bounds.XMin, Y: bounds.YMin}, {X: bounds.XMax, Y: bounds.YMin}, {X: bounds.XMax, Y: bounds.YMax}, {X: bounds.XMin, Y: bounds.YMax}} + data := "" + for i, point := range points { + point = matrix.Apply(point) + if !finite(point.X) || !finite(point.Y) { + return nil, fmt.Errorf("invalid PDF gradient bounds") + } + if i == 0 { + data = "M " + } else { + data += " L " + } + data += pdfNumbers(point.X-box.X, point.Y-box.Y) + } + clip := PathObject{Boundary: boundary, AbbreviatedData: data + " C", Fill: &filled, Stroke: &stroked} + object.Clips = &Clips{Clip: []Clip{{Area: []ClipArea{{Path: []PathObject{clip}}}}}} + } + objects := []PathObject{object} + if background != nil && extend != [2]bool{true, true} { + data := "" + if paint.Axial != nil { + data, err = pdfAxialBackground(fill.AxialShd, box) + } else { + data, err = pdfRadialBackground(paint.Radial, matrix, box) + clip := object + clip.FillColor, clip.Clips = nil, nil + clips := &Clips{Clip: []Clip{{Area: []ClipArea{{Path: []PathObject{clip}}}}}} + if object.Clips != nil { + clips.Clip = append(clips.Clip, object.Clips.Clip...) + } + object.Clips = clips + object.Rule = "Even-Odd" + } + if err != nil { + return nil, err + } + if data != "" { + color, err := p.gradientBackgroundColor(*background, space, intent) + if err != nil { + return nil, err + } + object.AbbreviatedData = data + object.FillColor = &FillColor{Value: color.Value, ColorSpace: color.ColorSpace} + objects = append(objects, object) + } + } + color := p.color(paint) + color.Pattern = &Pattern{Width: box.W, Height: box.H, RelativeTo: "Page", CTM: pdfNumbers(1, 0, 0, 1, box.X, box.Y), CellContent: PatternContent{PathObject: objects}} + return color, nil +} + +// gradientBackgroundColor 保留渐变背景的源颜色分量并复用颜色空间资源 +// 入参: values 背景分量, space 源颜色空间, intent 渲染意图 +// 返回: ShdColor OFD基本颜色, error 颜色转换错误 +func (p *pdfImporter) gradientBackgroundColor(values [4]float64, space *pdfgo.ColorSpace, intent pdfgo.Name) (ShdColor, error) { + if space == nil || space.Components() < 1 || space.Components() > 4 { + return ShdColor{}, fmt.Errorf("invalid PDF gradient background color space") + } + stop := pdfgo.GradientStop{Values: values} + if !pdfICCTransfer(space) && (space.Model != "DeviceCMYK" || space.Calibrated()) { + var err error + stop.RGB, err = space.RGB(values[:space.Components()], intent) + if err != nil { + return ShdColor{}, err + } + } + segments, err := p.gradientSegments([]pdfgo.GradientStop{stop}, space, intent) + if err != nil { + return ShdColor{}, err + } + return segments[0].Color, nil +} + +// pdfRadialBackground 用圆弧保留内含双圆的背景区域,不离散化渐变边界 +// 入参: gradient 源渐变, matrix 渐变到页面的矩阵, box 对象边界 +// 返回: string 奇偶填充背景路径, error 几何转换错误 +func pdfRadialBackground(gradient *pdfgo.RadialGradient, matrix pdfgo.Matrix, box Box) (string, error) { + inner, outer := gradient.Start, gradient.End + r0, r1, extend := gradient.StartRadius, gradient.EndRadius, gradient.Extend + if r1 < r0 { + inner, outer, r0, r1 = outer, inner, r1, r0 + extend = [2]bool{extend[1], extend[0]} + } + var circles, path GeometryPath + if !extend[1] { + path = geometryRectangle(Box{W: box.W, H: box.H}) + circles = geometryCircle(RasterPoint{X: outer.X, Y: outer.Y}, r1) + } + if !extend[0] && r0 > 0 { + circles = append(circles, geometryCircle(RasterPoint{X: inner.X, Y: inner.Y}, r0)...) + } + local := Matrix{a: matrix[0], b: matrix[1], c: matrix[2], d: matrix[3], e: matrix[4] - box.X, f: matrix[5] - box.Y} + circles, err := circles.Transform(local) + if err != nil { + return "", err + } + return append(path, circles...).OFD() +} + +// pdfAxialBackground 裁出轴向渐变未延伸的两侧区域,与渐变绘制区互不重叠 +// 入参: shading 对象局部渐变, box 对象边界 +// 返回: string 背景路径, error 非有限坐标 +func pdfAxialBackground(shading *AxialShd, box Box) (string, error) { + start, end := parseFloats(shading.StartPoint), parseFloats(shading.EndPoint) + dx, dy := end[0]-start[0], end[1]-start[1] + scale := math.Max(math.Abs(dx), math.Abs(dy)) + if !finite(scale) || scale == 0 { + return "", fmt.Errorf("invalid PDF gradient background axis") + } + dx, dy = dx/scale, dy/scale + corners := [4]pdfgo.Point{{}, {X: box.W}, {X: box.W, Y: box.H}, {Y: box.H}} + data := "" + for side, origin := range [][]float64{start, end} { + if side == 0 && (shading.Extend == "1" || shading.Extend == "3") || side == 1 && (shading.Extend == "2" || shading.Extend == "3") { + continue + } + distances := [4]float64{} + for i, point := range corners { + distances[i] = (point.X-origin[0])*dx + (point.Y-origin[1])*dy + if side == 1 { + distances[i] = -distances[i] + } + if !finite(distances[i]) { + return "", fmt.Errorf("invalid PDF gradient background extent") + } + } + points := make([]pdfgo.Point, 0, 5) + previous := 3 + for i, point := range corners { + a, b := distances[previous], distances[i] + if (a <= 0) != (b <= 0) { + scale := math.Max(math.Abs(a), math.Abs(b)) + ratio := (a / scale) / (a/scale - b/scale) + from := corners[previous] + points = append(points, pdfgo.Point{X: from.X + (point.X-from.X)*ratio, Y: from.Y + (point.Y-from.Y)*ratio}) + } + if b <= 0 { + points = append(points, point) + } + previous = i + } + if len(points) < 3 { + continue + } + for i, point := range points { + if i == 0 { + data += " M " + } else { + data += " L " + } + data += pdfNumbers(point.X, point.Y) + } + data += " C" + } + return data, nil } // gradientPath 按原始函数合成渐变路径,保留填充描边的单一对象语义 diff --git a/ofdgo_pdf_text_replacement.go b/ofdgo_pdf_text_replacement.go index 58f3387..4ccf896 100644 --- a/ofdgo_pdf_text_replacement.go +++ b/ofdgo_pdf_text_replacement.go @@ -168,7 +168,10 @@ func (r *pdfTextReplacements) count(nodes []pdfCompositeNode, blocked bool) erro } childBlocked := blocked if group := node.group; group != nil && node.textObject == nil { - opaque, err := node.opaque(group.ColorSpace) + opaque, err := r.importer.opaque(node, group.ColorSpace) + if err := r.importer.ctx.Err(); err != nil { + return err + } childBlocked = childBlocked || !opaque || err != nil || group.ColorSpace != nil && !group.ColorSpace.SRGBEquivalent() } if err := r.count(node.children, childBlocked); err != nil { diff --git a/ofdgo_reader.go b/ofdgo_reader.go index 9856541..e0a5dc6 100644 --- a/ofdgo_reader.go +++ b/ofdgo_reader.go @@ -139,14 +139,25 @@ func (r *Reader) DocumentPageCountContext(ctx context.Context, index int) (int, // 入参: index 文档索引,从0开始 // 返回: *Reader 文档阅读器, error 错误信息 func (r *Reader) Document(index int) (*Reader, error) { + return r.DocumentContext(context.Background(), index) +} + +// DocumentContext 创建指定文档的独立阅读器,读取结构和关联内容时响应取消 +// 返回值不关闭输入文件,使用期间不得关闭持有输入文件的原阅读器 +// 入参: ctx 取消上下文, index 文档索引,从0开始 +// 返回: *Reader 文档阅读器, error 读取或取消错误 +func (r *Reader) DocumentContext(ctx context.Context, index int) (*Reader, error) { + if err := ctx.Err(); err != nil { + return nil, err + } if index < 0 || index >= r.DocumentCount() { return nil, fmt.Errorf("document index %d out of range", index) } next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: index} - if err := next.initRoot(); err != nil { + if err := next.initRootContext(ctx); err != nil { return nil, err } - if _, err := next.Doc(); err != nil { + if _, err := next.DocContext(ctx); err != nil { return nil, err } return next, nil @@ -155,15 +166,65 @@ func (r *Reader) Document(index int) (*Reader, error) { // Doc 获取当前文档结构 // 返回: *Document 文档结构, error 错误信息 func (r *Reader) Doc() (*Document, error) { - doc, err := r.docStructure() + return r.DocContext(context.Background()) +} + +// DocContext 获取当前文档结构及关联内容,取消或失败时保留原阅读状态 +// 入参: ctx 取消上下文 +// 返回: *Document 文档结构, error 读取或取消错误 +func (r *Reader) DocContext(ctx context.Context) (*Document, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + if r.doc != nil && !r.relatedPending { + return r.doc, nil + } + view := *r + if r.doc == nil { + view.resourcesRead = maps.Clone(r.resourcesRead) + view.resourceFiles = maps.Clone(r.resourceFiles) + view.ResMap = maps.Clone(r.ResMap) + view.mediaCache = maps.Clone(r.mediaCache) + view.fontCache = maps.Clone(r.fontCache) + view.colorSpaceCache = maps.Clone(r.colorSpaceCache) + view.drawParamCache = maps.Clone(r.drawParamCache) + view.compositeGraphicUnitCache = maps.Clone(r.compositeGraphicUnitCache) + } + doc, err := view.docStructureContext(ctx) if err != nil { return nil, err } - if r.relatedPending { - r.relatedPending = false - _ = r.parseAnnotations(doc) - _ = r.parseSignatures(doc) + if view.relatedPending { + view.Annots = maps.Clone(r.Annots) + for page, entries := range view.Annots { + if err := ctx.Err(); err != nil { + return nil, err + } + view.Annots[page] = slices.Clone(entries) + } + view.Stamps = maps.Clone(r.Stamps) + for page, entries := range view.Stamps { + if err := ctx.Err(); err != nil { + return nil, err + } + view.Stamps[page] = slices.Clone(entries) + } + _ = view.parseAnnotations(ctx, doc) + if err := ctx.Err(); err != nil { + return nil, err + } + _ = view.parseSignatures(ctx, doc) + view.relatedPending = false } + if err := ctx.Err(); err != nil { + return nil, err + } + if r.doc == nil { + r.commitDocumentStructure(&view) + } else { + r.relatedPending = view.relatedPending + } + r.Annots, r.Stamps, r.annotationFiles = view.Annots, view.Stamps, view.annotationFiles return doc, nil } @@ -202,9 +263,7 @@ func (r *Reader) docStructureContext(ctx context.Context) (*Document, error) { if err := decoder.Decode(&doc); err != nil { return nil, fmt.Errorf("failed to unmarshal document.xml: %w", err) } - if doc.Signatures == "" { - doc.Signatures = view.versionSignatures(docAttr.Signatures) - } + doc.Signatures = view.versionSignatures(docAttr.Signatures) view.resourcesRead = maps.Clone(r.resourcesRead) view.resourceFiles = maps.Clone(r.resourceFiles) view.ResMap = maps.Clone(r.ResMap) @@ -226,6 +285,14 @@ func (r *Reader) docStructureContext(ctx context.Context) (*Document, error) { if err := ctx.Err(); err != nil { return nil, err } + view.doc, view.relatedPending = &doc, true + r.commitDocumentStructure(&view) + return r.doc, nil +} + +// commitDocumentStructure 提交已完成的文档读取,保留渲染器共享的资源索引 +// 入参: view 已验证的独立读取状态 +func (r *Reader) commitDocumentStructure(view *Reader) { maps.Copy(r.resourcesRead, view.resourcesRead) maps.Copy(r.resourceFiles, view.resourceFiles) maps.Copy(r.ResMap, view.ResMap) @@ -235,8 +302,7 @@ func (r *Reader) docStructureContext(ctx context.Context) (*Document, error) { maps.Copy(r.drawParamCache, view.drawParamCache) maps.Copy(r.compositeGraphicUnitCache, view.compositeGraphicUnitCache) r.documentRoot, r.versionInfo, r.RootDir = view.documentRoot, view.versionInfo, view.RootDir - r.doc, r.relatedPending = &doc, true - return r.doc, nil + r.doc, r.relatedPending = view.doc, view.relatedPending } // PageContent 获取页面内容 @@ -244,7 +310,14 @@ func (r *Reader) docStructureContext(ctx context.Context) (*Document, error) { // 入参: page 页面对象 // 返回: *PageContent 页面内容, error 错误信息 func (r *Reader) PageContent(page Page) (*PageContent, error) { - return r.readPageContent(page, false, false) + return r.PageContentContext(context.Background(), page) +} + +// PageContentContext 读取独立页面内容,取消时不返回部分页面 +// 入参: ctx 取消上下文, page 页面对象 +// 返回: *PageContent 页面内容, error 读取、解析或取消错误 +func (r *Reader) PageContentContext(ctx context.Context, page Page) (*PageContent, error) { + return r.readPageContent(ctx, page, false, false) } // PageCount 获取当前文档总页数 @@ -261,25 +334,39 @@ func (r *Reader) PageCount() (int, error) { // 入参: index 页面索引,从0开始 // 返回: *PageContent 页面内容, error 错误信息 func (r *Reader) PageContentByIndex(index int) (*PageContent, error) { - doc, err := r.Doc() + return r.PageContentByIndexContext(context.Background(), index) +} + +// PageContentByIndexContext 按当前文档页面顺序读取独立页面内容 +// 入参: ctx 取消上下文, index 页面索引,从0开始 +// 返回: *PageContent 页面内容, error 索引、读取或取消错误 +func (r *Reader) PageContentByIndexContext(ctx context.Context, index int) (*PageContent, error) { + doc, err := r.DocContext(ctx) if err != nil { return nil, err } if index < 0 || index >= len(doc.Pages.Page) { return nil, fmt.Errorf("page index %d out of range", index) } - return r.PageContent(doc.Pages.Page[index]) + return r.PageContentContext(ctx, doc.Pages.Page[index]) } // PageArea 读取页面区域,不解析页面图元 // 入参: page 页面对象 // 返回: PageArea 页面区域, error 错误信息 func (r *Reader) PageArea(page Page) (PageArea, error) { - content, err := r.readPageHeader(page) + return r.PageAreaContext(context.Background(), page) +} + +// PageAreaContext 读取页面区域并解析模板继承,不解析页面图元 +// 入参: ctx 取消上下文, page 页面对象 +// 返回: PageArea 页面区域, error 读取、解析或取消错误 +func (r *Reader) PageAreaContext(ctx context.Context, page Page) (PageArea, error) { + content, err := r.readPageHeaderContext(ctx, page) if err != nil { return PageArea{}, err } - return r.resolvePageArea(content.Area, content.Template) + return r.resolvePageAreaContext(ctx, content.Area, content.Template) } // ResPath 按当前文档目录解析ST_Loc,包内绝对路径以根目录为基准 @@ -842,20 +929,20 @@ func resolveResourcePath(resPath, baseLoc, filePath string) string { } // readPageContent 读取页面,可跳过文字提取不使用的独立路径和图片 -// 入参: page 页面对象, textOnly 是否仅用于文字提取, template 是否按模板页读取 +// 入参: ctx 取消上下文, page 页面对象, textOnly 是否仅用于文字提取, template 是否按模板页读取 // 返回: *PageContent 页面内容, error 错误信息 -func (r *Reader) readPageContent(page Page, textOnly, template bool) (*PageContent, error) { +func (r *Reader) readPageContent(ctx context.Context, page Page, textOnly, template bool) (*PageContent, error) { fullPath := r.ResPath(page.BaseLoc) - data, err := r.readFile(fullPath) + data, err := r.readFileViewContext(ctx, fullPath) if err != nil { return nil, err } var content PageContent + decoder := xml.NewDecoder(imageInput{ReadCloser: io.NopCloser(bytes.NewReader(data)), context: ctx}) if textOnly { - decoder := &textPageTokens{Decoder: xml.NewDecoder(bytes.NewReader(data))} - err = xml.NewTokenDecoder(decoder).Decode(&content) + err = xml.NewTokenDecoder(&textPageTokens{Decoder: decoder}).Decode(&content) } else { - err = xml.Unmarshal(data, &content) + err = decoder.Decode(&content) } if err != nil { return nil, fmt.Errorf("failed to unmarshal page content: %w", err) @@ -865,12 +952,17 @@ func (r *Reader) readPageContent(page Page, textOnly, template bool) (*PageConte content.Template = nil } for _, res := range content.PageRes { - r.loadRes("/" + resolveResourcePath(fullPath, "", res)) + if err := r.loadResContext(ctx, "/"+resolveResourcePath(fullPath, "", res)); err != nil { + return nil, err + } } - content.Area, err = r.resolvePageArea(content.Area, content.Template) + content.Area, err = r.resolvePageAreaContext(ctx, content.Area, content.Template) if err != nil { return nil, err } + if err := ctx.Err(); err != nil { + return nil, err + } return &content, nil } @@ -890,6 +982,16 @@ func (r *Reader) loadPageResources(page Page) { // 入参: page 页面对象 // 返回: PageContent 页面头部, error 错误信息 func (r *Reader) readPageHeader(page Page) (PageContent, error) { + return r.readPageHeaderContext(context.Background(), page) +} + +// readPageHeaderContext 读取并缓存页面头部,取消时不缓存部分结果 +// 入参: ctx 取消上下文, page 页面对象 +// 返回: PageContent 页面头部, error 读取、解析或取消错误 +func (r *Reader) readPageHeaderContext(ctx context.Context, page Page) (PageContent, error) { + if err := ctx.Err(); err != nil { + return PageContent{}, err + } name := r.ResPath(page.BaseLoc) if content, ok := r.pageHeaderCache[name]; ok { return content, nil @@ -900,9 +1002,12 @@ func (r *Reader) readPageHeader(page Page) (PageContent, error) { } defer f.Close() var content PageContent - d := xml.NewDecoder(f) + d := xml.NewDecoder(imageInput{ReadCloser: f, context: ctx}) scan: for { + if err := ctx.Err(); err != nil { + return PageContent{}, err + } token, err := d.Token() if err == io.EOF { break @@ -939,6 +1044,9 @@ scan: } } } + if err := ctx.Err(); err != nil { + return PageContent{}, err + } r.pageHeaderCache[name] = content return content, nil } @@ -947,15 +1055,28 @@ scan: // 入参: area 页面区域, templates 模板引用 // 返回: PageArea 页面区域, error 错误信息 func (r *Reader) resolvePageArea(area PageArea, templates []Template) (PageArea, error) { + return r.resolvePageAreaContext(context.Background(), area, templates) +} + +// resolvePageAreaContext 按模板顺序解析页面区域的继承,读取过程中响应取消 +// 入参: ctx 取消上下文, area 页面区域, templates 模板引用 +// 返回: PageArea 页面区域, error 读取或取消错误 +func (r *Reader) resolvePageAreaContext(ctx context.Context, area PageArea, templates []Template) (PageArea, error) { + if err := ctx.Err(); err != nil { + return PageArea{}, err + } if area != (PageArea{}) || r.doc == nil { return area, nil } for _, ref := range templates { for _, template := range r.doc.CommonData.TemplatePage { + if err := ctx.Err(); err != nil { + return PageArea{}, err + } if !sameResourceID(template.ID, ref.TemplateID) { continue } - content, err := r.readPageHeader(Page{BaseLoc: template.BaseLoc}) + content, err := r.readPageHeaderContext(ctx, Page{BaseLoc: template.BaseLoc}) if err != nil { return PageArea{}, err } diff --git a/ofdgo_reader_dependencies.go b/ofdgo_reader_dependencies.go index 9488465..7fe16bb 100644 --- a/ofdgo_reader_dependencies.go +++ b/ofdgo_reader_dependencies.go @@ -235,13 +235,13 @@ func (r *Reader) documentDependencies(ctx context.Context, indexes ...int) (map[ // 入参: ctx 取消上下文, name 文件路径, rejectUnknown 是否拒绝无法识别的扩展节点, add 接收引用路径及类别 // 返回: error 读取或结构错误 func (r *Reader) documentFileReferences(ctx context.Context, name string, rejectUnknown bool, add func(string, int)) error { - return r.scanDocumentFileReferences(ctx, name, rejectUnknown, add, nil) + return r.scanDocumentFileReferences(ctx, name, rejectUnknown, add, nil, nil) } // scanDocumentFileReferences 流式扫描文件依赖,按需记录引用节点位置,不构建完整节点树 -// 入参: ctx 取消上下文, name 文件路径, rejectUnknown 是否拒绝未知扩展, add 接收依赖, record 接收引用位置,可为nil +// 入参: ctx 取消上下文, name 文件路径, rejectUnknown 是否拒绝未知扩展, add 接收依赖, record 接收引用位置,可为nil, inspect 检查节点,可为nil // 返回: error 读取、结构或取消错误 -func (r *Reader) scanDocumentFileReferences(ctx context.Context, name string, rejectUnknown bool, add func(string, int), record func(*editorXML, string, string)) error { +func (r *Reader) scanDocumentFileReferences(ctx context.Context, name string, rejectUnknown bool, add func(string, int), record func(*editorXML, string, string), inspect func(xml.StartElement) error) error { input, err := r.openFile(name) if err != nil { return err @@ -269,6 +269,11 @@ func (r *Reader) scanDocumentFileReferences(ctx context.Context, name string, re } switch node := token.(type) { case xml.StartElement: + if inspect != nil { + if err := inspect(node); err != nil { + return err + } + } parent := "" if len(stack) == 0 { if root != "" { diff --git a/ofdgo_render_compile.go b/ofdgo_render_compile.go index e48e76f..dda8797 100644 --- a/ofdgo_render_compile.go +++ b/ofdgo_render_compile.go @@ -18,7 +18,6 @@ import ( "fmt" "math" "strconv" - "strings" ) // semanticCompiler 将公共页面语义编译为路径和原始图片,绘制、搜索和度量共享定位 @@ -296,8 +295,9 @@ func (c *semanticCompiler) objectStyle(object PathObject, defaults *DrawParam, l if defaults.Join != "" { style.options.Join = defaults.Join } - if defaults.MiterLimit > 0 { + if defaults.MiterLimit > 0 || defaults.MiterLimitSet { style.options.MiterLimit = defaults.MiterLimit + style.options.MiterLimitSet = defaults.MiterLimitSet } style.options.Dashes = parseFloats(defaults.DashPattern) if defaults.DashOffset != nil { @@ -319,8 +319,9 @@ func (c *semanticCompiler) objectStyle(object PathObject, defaults *DrawParam, l if object.Join != "" { style.options.Join = object.Join } - if object.MiterLimit > 0 { + if object.MiterLimit > 0 || object.MiterLimitSet { style.options.MiterLimit = object.MiterLimit + style.options.MiterLimitSet = object.MiterLimitSet } if object.DashPattern != "" || object.dashPatternSet { style.options.Dashes = parseFloats(object.DashPattern) @@ -337,9 +338,7 @@ func (c *semanticCompiler) objectStyle(object PathObject, defaults *DrawParam, l style.options.Dashes[i] *= scale } } - if style.options.Width == 0 { - style.options.Width = 25.4 / c.renderer.DPI - } + style.options.Width = c.renderer.deviceLineWidth(style.options.Width) return style } @@ -354,24 +353,11 @@ func (c *semanticCompiler) pathObject(object PathObject, state RenderState) erro local := object local.Boundary = "0 0 1 1" local.CTM = "" - var path GeometryPath - var err error - tinyMatrix := matrix - if state.BoundaryInCTM && state.Parent != nil { - tinyMatrix, _ = renderObjectMatrix(object.Boundary, NewMatrix(object.CTM), RenderState{}) - } - path = tinyFillRectPath(object, tinyMatrix) - if path != nil { - if state.BoundaryInCTM && state.Parent != nil { - path, err = c.geometry.Transform(path, *state.Parent) - } - } else { - path, err = c.geometry.Path(local) - if err != nil { - return err - } - path, err = c.geometry.Transform(path, matrix) + path, err := c.geometry.Path(local) + if err != nil { + return err } + path, err = c.geometry.Transform(path, matrix) if err != nil { return err } @@ -427,7 +413,7 @@ func (c *semanticCompiler) textObject(object TextObject, state RenderState) erro return nil } _, linear := renderObjectMatrix(object.Boundary, NewMatrix(object.CTM), state) - style := c.objectStyle(PathObject{DrawParam: object.DrawParam, FillColor: object.FillColor, StrokeColor: object.StrokeColor, Alpha: object.Alpha, LineWidth: object.LineWidth, LineWidthSet: object.LineWidthSet, Join: object.Join, MiterLimit: object.MiterLimit}, state.Defaults, linear) + style := c.objectStyle(object.pathStyle(), state.Defaults, linear) if style.fill == nil { style.fill = withFillAlpha(&FillColor{}, object.Alpha) } @@ -511,39 +497,3 @@ func (c *semanticCompiler) fillPattern(clip GeometryPath, pattern *PatternPaint, } return nil } - -// tinyFillRectPath 保留历史文档中微小填充块的缺省末端处理 -// 入参: object 路径对象, matrix 不含外层变换的页面矩阵 -// 返回: GeometryPath 修正路径,非匹配格式时为空 -func tinyFillRectPath(object PathObject, matrix Matrix) GeometryPath { - if object.Fill == nil || !*object.Fill || object.Stroke == nil || *object.Stroke { - return nil - } - box, err := ParseBox(object.Boundary) - if err != nil || box.W <= 0 || box.H <= 0 || box.W > .6 || box.H > .6 { - return nil - } - tokens := strings.Fields(object.AbbreviatedData) - if len(tokens) != 11 || tokens[0] != "M" || tokens[3] != "L" || tokens[6] != "L" || tokens[9] != "L" || tokens[10] != "C" { - return nil - } - var minX, minY, maxX, maxY float64 - for i := 1; i < 10; i += 3 { - x, errX := strconv.ParseFloat(tokens[i], 64) - y, errY := strconv.ParseFloat(tokens[i+1], 64) - if errX != nil || errY != nil { - return nil - } - x, y = matrix.Transform(x, y) - if i == 1 { - minX, maxX, minY, maxY = x, x, y, y - } else { - minX = math.Min(minX, x) - maxX = math.Max(maxX, x) - minY = math.Min(minY, y) - maxY = math.Max(maxY, y) - } - } - expand := math.Min(box.W, box.H) * .08 - return geometryRectangle(Box{X: minX - expand, Y: minY - expand, W: maxX - minX + 2*expand, H: maxY - minY + 2*expand}) -} diff --git a/ofdgo_render_text.go b/ofdgo_render_text.go index 03d1dd0..553610b 100644 --- a/ofdgo_render_text.go +++ b/ofdgo_render_text.go @@ -140,8 +140,9 @@ func (r *Renderer) PositionText(object TextObject, state RenderState) (*Position angle := float64(object.CharDirection) * math.Pi / 180 rotate := Matrix{a: math.Cos(angle), b: math.Sin(angle), c: -math.Sin(angle), d: math.Cos(angle)} glyphLinear := Matrix{a: linear.a, b: linear.b, c: linear.c, d: linear.d}.Multiply(rotate) - for index, code := range object.TextCode { - if object.textCodeLineBreak(index) { + lines := newTextLineScanner(object) + for _, code := range object.TextCode { + if lines.next(code) { textOffset++ } runes := textCodeRunes(code.Value) diff --git a/ofdgo_renderer.go b/ofdgo_renderer.go index c6f93b2..2c5a1d0 100644 --- a/ofdgo_renderer.go +++ b/ofdgo_renderer.go @@ -61,6 +61,20 @@ type Renderer struct { TransparentBackground bool } +// deviceLineWidth 按设备分辨率计算描边宽度,零线宽为一像素,正线宽至少为两像素 +// 入参: width 页面空间线宽,单位为毫米 +// 返回: float64 设备描边宽度,单位为毫米 +func (r *Renderer) deviceLineWidth(width float64) float64 { + pixel := 25.4 / r.DPI + if width == 0 { + return pixel + } + if width > 0 { + return max(width, 2*pixel) + } + return width +} + // RendererOption 渲染器配置选项 type RendererOption func(*Renderer) @@ -117,6 +131,13 @@ func (r *Renderer) childRenderer(reader *Reader) *Renderer { // 入参: templateID 模板ID // 返回: *PageContent 模板页面 func (r *Renderer) loadTemplate(templateID string) *PageContent { + ctx := r.pageContext + if ctx == nil { + ctx = r.outputContext() + } + if err := ctx.Err(); err != nil { + return nil + } if key := editorResourceID(templateID); key != "" { templateID = key } @@ -127,7 +148,7 @@ func (r *Renderer) loadTemplate(templateID string) *PageContent { if !sameResourceID(tpl.ID, templateID) { continue } - page, err := r.Reader.readPageContent(Page{BaseLoc: tpl.BaseLoc}, false, true) + page, err := r.Reader.readPageContent(ctx, Page{BaseLoc: tpl.BaseLoc}, false, true) if err != nil { return nil } diff --git a/ofdgo_renderer_canvas.go b/ofdgo_renderer_canvas.go index 873642a..425b2fe 100644 --- a/ofdgo_renderer_canvas.go +++ b/ofdgo_renderer_canvas.go @@ -40,6 +40,18 @@ type rasterStrokeGradientCanvas struct { type rasterGradientCanvas struct { gradient *RasterGradient space canvas.ColorSpace + mesh *MeshSampler +} + +// newRasterGradientCanvas 准备渐变适配器及独立网格采样器 +// 入参: gradient 公共渐变 +// 返回: rasterGradientCanvas Canvas渐变 +func newRasterGradientCanvas(gradient *RasterGradient) rasterGradientCanvas { + result := rasterGradientCanvas{gradient: gradient} + if gradient.Kind == RasterMesh { + result.mesh = gradient.Mesh.NewSampler() + } + return result } // Name 返回后端标识 @@ -123,7 +135,9 @@ func (b CanvasBackend) renderImage(page *RasterPage, img *image.RGBA) (image.Ima } } paint := canvas.Paint{Color: cmd.Paint.Color} + var sampled rasterGradientCanvas if g := cmd.Paint.Gradient; g != nil { + sampled = newRasterGradientCanvas(g) stops := make(canvas.Grad, len(g.Stops)) for j, stop := range g.Stops { stops[j] = canvas.Stop{Offset: stop.Offset, Color: stop.Color} @@ -135,12 +149,12 @@ func (b CanvasBackend) renderImage(page *RasterPage, img *image.RGBA) (image.Ima case RasterRadial: paint = canvas.Paint{Gradient: stops.ToRadial(start, g.R0, end, g.R1)} case RasterMesh: - paint = canvas.Paint{Gradient: rasterGradientCanvas{gradient: g}} + paint = canvas.Paint{Gradient: sampled} default: return nil, fmt.Errorf("canvas command %d: unsupported gradient %d", i, g.Kind) } if g.Spread != nil || g.Sample != nil { - paint = canvas.Paint{Gradient: rasterGradientCanvas{gradient: g}} + paint = canvas.Paint{Gradient: sampled} } } style := canvas.Style{Fill: paint, FillRule: canvas.NonZero} @@ -159,10 +173,10 @@ func (b CanvasBackend) renderImage(page *RasterPage, img *image.RGBA) (image.Ima if err != nil { return nil, err } - paint = canvas.Paint{Gradient: rasterStrokeGradientCanvas{rasterGradientCanvas{gradient: cmd.Paint.Gradient}, inverse}} + paint = canvas.Paint{Gradient: rasterStrokeGradientCanvas{sampled, inverse}} } line := pathStyle{lineJoin: canvas.MiterJoin, miterLimit: defaultMiterLimit} - line.applyLineJoin(options.Join, options.MiterLimit) + line.applyLineJoin(options.Join, options.MiterLimit, options.MiterLimitSet) style = canvas.Style{Stroke: paint, StrokeWidth: options.Width, StrokeCapper: pathLineCap(options.Cap, canvas.ButtCap), StrokeJoiner: line.lineJoin, FillRule: canvas.NonZero} if len(options.Dashes) > 0 { style.DashOffset = options.DashOffset / options.Width @@ -224,7 +238,12 @@ func (g rasterStrokeGradientCanvas) SetColorSpace(space canvas.ColorSpace) canva // 入参: x 横坐标, y 纵坐标 // 返回: color.RGBA 渐变颜色 func (g rasterGradientCanvas) At(x, y float64) color.RGBA { - value := g.gradient.At(x, y) + var value color.RGBA + if g.mesh != nil { + value = g.mesh.At(x, y) + } else { + value = g.gradient.At(x, y) + } if g.space != nil { return g.space.ToLinear(value) } diff --git a/ofdgo_renderer_clip_canvas.go b/ofdgo_renderer_clip_canvas.go index 7bdd956..dbfe0fa 100644 --- a/ofdgo_renderer_clip_canvas.go +++ b/ofdgo_renderer_clip_canvas.go @@ -16,6 +16,7 @@ package ofdgo import ( "image" + "math" "slices" "github.com/tdewolff/canvas" @@ -119,6 +120,9 @@ func (r *Renderer) buildClipPath(clips *Clips, pageH float64, bx, by float64, ma textObj.FillColor = &FillColor{Value: "0 0 0"} textObj.StrokeColor = &StrokeColor{Value: "0 0 0"} textRenderer := *r + if scale := math.Sqrt(math.Abs(m.a*m.d - m.b*m.c)); scale > 0 { + textRenderer.DPI *= scale + } textRenderer.pageText = nil textRenderer.textOnly = false textRenderer.renderText(ctx, textObj, 0, r.drawParamDefaults(area.DrawParam, nil), nil, false, nil) @@ -170,8 +174,10 @@ func (r *Renderer) buildClipAreaPath(obj PathObject, drawParam string, parent Ma obj.FillColor, obj.StrokeColor = nil, nil style := r.newPathStyle(dp, 0, 0, 0, nil) style.applyPathObject(r, obj, 0, 0, 0) - if style.lineWidth == 0 { - style.lineWidth = 25.4 / r.DPI + if scale := math.Sqrt(math.Abs(m.a*m.d - m.b*m.c)); scale > 0 { + style.lineWidth = r.deviceLineWidth(style.lineWidth*scale) / scale + } else { + style.lineWidth = r.deviceLineWidth(style.lineWidth) } result = unionClipPath(result, r.strokeDashedCanvasPath(p, style.lineWidth, style.lineCap, style.lineJoin, style.dashOffset, style.dashPattern, false)) } diff --git a/ofdgo_renderer_geom_canvas.go b/ofdgo_renderer_geom_canvas.go index 290c6b0..9e2fcb7 100644 --- a/ofdgo_renderer_geom_canvas.go +++ b/ofdgo_renderer_geom_canvas.go @@ -173,7 +173,7 @@ func (CanvasBackend) Stroke(path GeometryPath, options StrokeOptions) (result Ge } }() style := pathStyle{lineJoin: canvas.MiterJoin, lineCap: canvas.ButtCap, miterLimit: defaultMiterLimit} - style.applyLineJoin(options.Join, options.MiterLimit) + style.applyLineJoin(options.Join, options.MiterLimit, options.MiterLimitSet) style.lineCap = pathLineCap(options.Cap, style.lineCap) p = p.Dash(options.DashOffset, options.Dashes...) p = p.Flatten(tolerance) @@ -224,6 +224,7 @@ func canvasStrokeOptions(width float64, cap canvas.Capper, join canvas.Joiner, t options.Join = "Bevel" case canvas.MiterJoiner: options.MiterLimit = value.Limit + options.MiterLimitSet = value.Limit == 0 } return options } diff --git a/ofdgo_renderer_paint_canvas.go b/ofdgo_renderer_paint_canvas.go index 79ebd18..e92a45a 100644 --- a/ofdgo_renderer_paint_canvas.go +++ b/ofdgo_renderer_paint_canvas.go @@ -35,7 +35,7 @@ func (r *Renderer) parseFillPaint(fillColor *FillColor, x, y, pageH float64) any case PaintLinear, PaintRadial: return canvasShading(paint, x, y, pageH) case PaintMesh: - return &shdPaint{gradient: rasterGradientCanvas{gradient: &RasterGradient{Kind: RasterMesh, Mesh: paint.Mesh}}, view: canvas.Matrix{{1, 0, x}, {0, -1, pageH - y}}} + return &shdPaint{gradient: newRasterGradientCanvas(&RasterGradient{Kind: RasterMesh, Mesh: paint.Mesh}), view: canvas.Matrix{{1, 0, x}, {0, -1, pageH - y}}} default: return paint.Color } diff --git a/ofdgo_renderer_path_canvas.go b/ofdgo_renderer_path_canvas.go index 0351b49..3cac9c2 100644 --- a/ofdgo_renderer_path_canvas.go +++ b/ofdgo_renderer_path_canvas.go @@ -35,6 +35,7 @@ type pathStyle struct { lineWidth float64 lineCap canvas.Capper lineJoin canvas.Joiner + lineJoinName string miterLimit float64 dashOffset float64 dashPattern []float64 @@ -90,21 +91,35 @@ func pathLineCap(cap string, fallback canvas.Capper) canvas.Capper { } // applyLineJoin 应用线连接样式及尖角限制 -// 入参: join 线连接名称, limit 尖角限制 -func (s *pathStyle) applyLineJoin(join string, limit float64) { - if limit > 0 { +// 入参: join 线连接名称, limit 尖角限制, explicit 是否显式指定零截断值 +func (s *pathStyle) applyLineJoin(join string, limit float64, explicit bool) { + if limit > 0 || explicit { s.miterLimit = limit } + if s.lineJoinName == "" { + switch s.lineJoin.(type) { + case canvas.RoundJoiner: + s.lineJoinName = "Round" + case canvas.BevelJoiner: + s.lineJoinName = "Bevel" + default: + s.lineJoinName = "Miter" + } + } switch join { - case "Miter": - s.lineJoin = canvas.MiterJoin + case "Miter", "Round", "Bevel": + s.lineJoinName = join + } + switch s.lineJoinName { case "Round": s.lineJoin = canvas.RoundJoin case "Bevel": s.lineJoin = canvas.BevelJoin - } - if _, ok := s.lineJoin.(canvas.MiterJoiner); ok { + default: s.lineJoin = canvas.MiterJoiner{GapJoiner: canvas.BevelJoin, Limit: s.miterLimit} + if s.miterLimit <= 1 { + s.lineJoin = canvas.BevelJoin + } } } @@ -123,7 +138,7 @@ func (s *pathStyle) applyDrawParam(r *Renderer, dp *DrawParam, bx, by, pageH flo if dp.Cap != "" { s.lineCap = pathLineCap(dp.Cap, s.lineCap) } - s.applyLineJoin(dp.Join, dp.MiterLimit) + s.applyLineJoin(dp.Join, dp.MiterLimit, dp.MiterLimitSet) if dp.DashPattern != "" || dp.dashPatternSet { s.dashPattern = parseFloats(dp.DashPattern) } @@ -147,7 +162,7 @@ func (s *pathStyle) applyPathObject(r *Renderer, obj PathObject, bx, by, pageH f if obj.Cap != "" { s.lineCap = pathLineCap(obj.Cap, canvas.ButtCap) } - s.applyLineJoin(obj.Join, obj.MiterLimit) + s.applyLineJoin(obj.Join, obj.MiterLimit, obj.MiterLimitSet) if obj.DashPattern != "" || obj.dashPatternSet { s.dashPattern = parseFloats(obj.DashPattern) } @@ -213,9 +228,7 @@ func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64 } style.applyPathObject(r, obj, bx, by, pageH) style.scale(ctm) - if style.lineWidth == 0 { - style.lineWidth = 25.4 / r.DPI - } + style.lineWidth = r.deviceLineWidth(style.lineWidth) objectCTM := TranslationMatrix(bx, by).Multiply(ctm) if boundaryInCTM && parentCTM != nil { objectCTM = parentCTM.Multiply(TranslationMatrix(bx, by)).Multiply(localCTM) @@ -226,10 +239,7 @@ func (r *Renderer) renderPath(ctx *canvas.Context, obj PathObject, pageH float64 if boundaryInCTM { pathCTM = localCTM } - p := r.buildTinyFillRectPath(obj, pageH, pathCTM, bx, by) - if p == nil { - p = r.buildPath(obj, pageH, pathCTM, false) - } + p := r.buildPath(obj, pageH, pathCTM, false) if boundaryInCTM && parentCTM != nil { p = p.Transform(canvas.Matrix{ {parentCTM.a, -parentCTM.c, parentCTM.c*pageH + parentCTM.e}, @@ -471,19 +481,3 @@ func (r *Renderer) parseCanvasPath(data string) (*canvas.Path, error) { } return path, nil } - -// buildTinyFillRectPath 构建微小填充矩形路径 -// 入参: obj 路径对象, pageH 页面高度, ctm 变换矩阵, bx 边界X坐标, by 边界Y坐标 -// 返回: *canvas.Path 路径对象 -func (r *Renderer) buildTinyFillRectPath(obj PathObject, pageH float64, ctm Matrix, bx, by float64) *canvas.Path { - path := tinyFillRectPath(obj, TranslationMatrix(bx, by).Multiply(ctm)) - if path == nil { - return nil - } - p, err := geometryToCanvasPath(&path) - if err != nil { - r.renderError = err - return nil - } - return p.Translate(0, pageH) -} diff --git a/ofdgo_renderer_text_canvas.go b/ofdgo_renderer_text_canvas.go index 4b80c5b..589bb7a 100644 --- a/ofdgo_renderer_text_canvas.go +++ b/ofdgo_renderer_text_canvas.go @@ -108,20 +108,14 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 if dp != nil { strokeStyle.applyDrawParam(r, dp, bx, by, pageH, obj.Alpha) } - if obj.StrokeColor != nil { - strokeStyle.applyStrokeColor(r, obj.StrokeColor, bx, by, pageH, obj.Alpha) - } - if obj.LineWidth > 0 || obj.LineWidthSet { - strokeStyle.lineWidth = obj.LineWidth - } - strokeStyle.applyLineJoin(obj.Join, obj.MiterLimit) + strokeObject := obj.pathStyle() + strokeObject.FillColor = nil + strokeStyle.applyPathObject(r, strokeObject, bx, by, pageH) if strokeStyle.strokePaint == nil { strokeStyle.strokePaint = canvas.Transparent } strokeStyle.scale(ctm) - if strokeStyle.lineWidth == 0 { - strokeStyle.lineWidth = 25.4 / r.DPI - } + strokeStyle.lineWidth = r.deviceLineWidth(strokeStyle.lineWidth) transformShdPaint(strokeStyle.strokePaint, parentCTM, bx, by, pageH, boundaryInCTM) strokeStyle.strokePaint, shadingClip, strokeView = resolveShdPaint(ctx, strokeStyle.strokePaint) strokeClip = r.intersectCachedClipPath(strokeClip, shadingClip) @@ -220,8 +214,12 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64 } codePos := 0 textPos := 0 - for index, tc := range obj.TextCode { - if textRun != nil && obj.textCodeLineBreak(index) { + var lines textLineScanner + if textRun != nil { + lines = newTextLineScanner(obj) + } + for _, tc := range obj.TextCode { + if textRun != nil && lines.next(tc) { textPos++ } var runes []rune diff --git a/ofdgo_res.go b/ofdgo_res.go index 23eeccf..a8e5ae6 100644 --- a/ofdgo_res.go +++ b/ofdgo_res.go @@ -79,6 +79,7 @@ type DrawParams struct { } // DrawParam 绘制参数 +// MiterLimitSet为true时,零截断值覆盖继承值,否则零值表示未指定 type DrawParam struct { ID string `xml:"ID,attr"` Relative string `xml:"Relative,attr"` @@ -98,6 +99,7 @@ type DrawParam struct { FillColor *FillColor `xml:"FillColor"` StrokeColor *StrokeColor `xml:"StrokeColor"` LineWidthSet bool `xml:"-"` + MiterLimitSet bool `xml:"-"` dashPatternSet bool } diff --git a/ofdgo_search.go b/ofdgo_search.go index ce1d282..b676750 100644 --- a/ofdgo_search.go +++ b/ofdgo_search.go @@ -106,14 +106,18 @@ func (r *Renderer) PageText(page *PageContent) (*PageText, error) { // 入参: index 页面索引,从0开始 // 返回: *PageText 页面文字, error 错误信息 func (r *Renderer) PageTextByIndex(index int) (*PageText, error) { - doc, err := r.Reader.Doc() + ctx := r.pageContext + if ctx == nil { + ctx = r.outputContext() + } + doc, err := r.Reader.DocContext(ctx) if err != nil { return nil, err } if index < 0 || index >= len(doc.Pages.Page) { return nil, fmt.Errorf("page index %d out of range", index) } - page, err := r.Reader.readPageContent(doc.Pages.Page[index], true, false) + page, err := r.Reader.readPageContent(ctx, doc.Pages.Page[index], true, false) if err != nil { return nil, err } diff --git a/ofdgo_shape.go b/ofdgo_shape.go index 3c14ba2..ef5842c 100644 --- a/ofdgo_shape.go +++ b/ofdgo_shape.go @@ -205,7 +205,7 @@ func (p PathObject) ReshapeFrame(box Box) (PathObject, error) { if box == previous { return p, nil } - if _, err := creationBox(editorBoxString(box)); err != nil { + if err := validateCreationBox(box); err != nil { return PathObject{}, err } shape, err := NewShape(kind, Box{W: box.W, H: box.H}) @@ -214,7 +214,7 @@ func (p PathObject) ReshapeFrame(box Box) (PathObject, error) { } matrix = matrix.Multiply(TranslationMatrix(box.X, box.Y)) bounds := matrix.TransformBox(Box{W: box.W, H: box.H}) - if _, err := creationBox(editorBoxString(bounds)); err != nil { + if err := validateCreationBox(bounds); err != nil { return PathObject{}, err } p.Boundary = editorBoxString(bounds) diff --git a/ofdgo_sign.go b/ofdgo_sign.go index 5dee1da..fce2ca6 100644 --- a/ofdgo_sign.go +++ b/ofdgo_sign.go @@ -16,6 +16,7 @@ package ofdgo import ( "bytes" + "context" "encoding/asn1" "encoding/binary" "encoding/xml" @@ -167,9 +168,12 @@ func parseSignatureStampBox(s string) (Box, error) { } // parseSignatures 解析签名文件 -// 入参: doc 文档结构 +// 入参: ctx 取消上下文, doc 文档结构 // 返回: error 错误信息 -func (r *Reader) parseSignatures(doc *Document) error { +func (r *Reader) parseSignatures(ctx context.Context, doc *Document) error { + if err := ctx.Err(); err != nil { + return err + } if doc.Signatures == "" { return nil } @@ -180,11 +184,14 @@ func (r *Reader) parseSignatures(doc *Document) error { } defer f.Close() var signatures Signatures - if err := xml.NewDecoder(f).Decode(&signatures); err != nil { + if err := xml.NewDecoder(imageInput{ReadCloser: f, context: ctx}).Decode(&signatures); err != nil { return err } pages := signaturePageNumbers(doc) for _, sigRef := range signatures.List { + if err := ctx.Err(); err != nil { + return err + } func(sigRef Signature) { sigPath := resolveResourcePath(sigListPath, "", sigRef.BaseLoc) sf, err := r.openFile(sigPath) @@ -193,20 +200,20 @@ func (r *Reader) parseSignatures(doc *Document) error { } defer sf.Close() var sigFile SignatureFile - if err := xml.NewDecoder(sf).Decode(&sigFile); err != nil { + if err := xml.NewDecoder(imageInput{ReadCloser: sf, context: ctx}).Decode(&sigFile); err != nil { return } var sealType string var sealData []byte if sigFile.SignedInfo.Seal.BaseLoc != "" { sealPath := resolveResourcePath(sigPath, "", sigFile.SignedInfo.Seal.BaseLoc) - if data, err := r.readFile(sealPath); err == nil { + if data, err := r.readFileViewContext(ctx, sealPath); err == nil { sealType, sealData = extractSeal(data) } } if len(sealData) == 0 && sigFile.SignedValue != "" { signedValuePath := resolveResourcePath(sigPath, "", sigFile.SignedValue) - if data, err := r.readFile(signedValuePath); err == nil { + if data, err := r.readFileViewContext(ctx, signedValuePath); err == nil { if sig, err := parseSESSignature(data); err == nil { sealType, sealData = sig.Seal.PicType, sig.Seal.PicData } else { @@ -217,7 +224,11 @@ func (r *Reader) parseSignatures(doc *Document) error { if len(sealData) == 0 { return } + sealData = bytes.Clone(sealData) for _, annot := range sigFile.SignedInfo.StampAnnot { + if ctx.Err() != nil { + return + } page, ok := resourceValue(pages, annot.PageRef) if !ok { continue @@ -239,7 +250,7 @@ func (r *Reader) parseSignatures(doc *Document) error { } }(sigRef) } - return nil + return ctx.Err() } // signaturePageNumbers 获取签名页面编号索引 diff --git a/ofdgo_sign_verify.go b/ofdgo_sign_verify.go index b8992d9..66afcf9 100644 --- a/ofdgo_sign_verify.go +++ b/ofdgo_sign_verify.go @@ -322,9 +322,7 @@ func (r *Reader) VerifyDocumentSignatures(index int, opts ...SignatureVerifyOpti if err := xml.Unmarshal(data, &doc); err != nil { return nil, err } - if doc.Signatures == "" { - doc.Signatures = view.versionSignatures(body.Signatures) - } + doc.Signatures = view.versionSignatures(body.Signatures) if doc.Signatures == "" { return nil, nil } diff --git a/ofdgo_text.go b/ofdgo_text.go index c245767..f386e21 100644 --- a/ofdgo_text.go +++ b/ofdgo_text.go @@ -34,13 +34,35 @@ type textGlyphTransform struct { Glyphs []textGlyph } +// pathStyle 提取文字绘制属性和变换,供路径描边处理复用 +// 返回: PathObject 绘制属性 +func (obj TextObject) pathStyle() PathObject { + return PathObject{CTM: obj.CTM, DrawParam: obj.DrawParam, Alpha: obj.Alpha, Fill: obj.Fill, Stroke: obj.Stroke, + FillColor: obj.FillColor, StrokeColor: obj.StrokeColor, LineWidth: obj.LineWidth, LineWidthSet: obj.LineWidthSet, + Cap: obj.Cap, Join: obj.Join, MiterLimit: obj.MiterLimit, MiterLimitSet: obj.MiterLimitSet, DashPattern: obj.DashPattern, + dashPatternSet: obj.dashPatternSet, DashOffset: obj.DashOffset} +} + +// withPathStyle 替换共用绘制属性,保留文字内容、字体及定位 +// 入参: style 绘制属性 +// 返回: TextObject 修改后的文字对象 +func (obj TextObject) withPathStyle(style PathObject) TextObject { + obj.DrawParam, obj.Alpha, obj.Fill, obj.Stroke = style.DrawParam, style.Alpha, style.Fill, style.Stroke + obj.FillColor, obj.StrokeColor = style.FillColor, style.StrokeColor + obj.LineWidth, obj.LineWidthSet = style.LineWidth, style.LineWidthSet + obj.Cap, obj.Join, obj.MiterLimit = style.Cap, style.Join, style.MiterLimit + obj.MiterLimitSet = style.MiterLimitSet + obj.DashPattern, obj.dashPatternSet, obj.DashOffset = style.DashPattern, style.dashPatternSet, style.DashOffset + return obj +} + // Text 获取定位后的文字,横向换行以换行符分隔,字形索引以占位字符表示 // 返回: string 原文 func (obj TextObject) Text() string { - obj.TextCode = textCodeOrigins(obj.TextCode, 0) + lines := newTextLineScanner(obj) var text strings.Builder - for i, code := range obj.TextCode { - if obj.textCodeLineBreak(i) { + for _, code := range obj.TextCode { + if lines.next(code) { text.WriteByte('\n') } if code.Index == "" { @@ -64,120 +86,6 @@ func (tc *TextCode) GetDeltaY() []float64 { return parseFloats(tc.DeltaY) } -// textCodeLineBreak 根据行首、行距和字形位置推断横向文本换行 -// 入参: index 文本编码索引 -// 返回: bool 是否换行 -func (obj TextObject) textCodeLineBreak(index int) bool { - if index == 0 || obj.ReadDirection != 0 || obj.CharDirection != 0 || obj.TextCode[index].Y == "" { - return false - } - code, previous := obj.TextCode[index], obj.TextCode[index-1] - for i := index - 2; i >= 0 && (previous.X == "" || previous.Y == ""); i-- { - if previous.X == "" { - previous.X = obj.TextCode[i].X - } - if previous.Y == "" { - previous.Y = obj.TextCode[i].Y - } - } - if previous.X == "" { - previous.X = "0" - } - if previous.Y == "" { - previous.Y = "0" - } - if code.X == "" { - code.X = previous.X - } - startX := obj.TextCode[0].X - if startX == "" { - startX = "0" - } - x, ex := strconv.ParseFloat(code.X, 64) - start, es := strconv.ParseFloat(startX, 64) - y, ey := strconv.ParseFloat(code.Y, 64) - prev, ep := strconv.ParseFloat(previous.Y, 64) - if ex != nil || es != nil || ey != nil || ep != nil { - return false - } - if x == start && y != prev { - return true - } - left, err := strconv.ParseFloat(previous.X, 64) - if err != nil || obj.Size <= 0 || y-prev < obj.Size/2 || previous.DeltaY != "" || code.DeltaY != "" { - return false - } - count := obj.textCodeGlyphCount(index - 1) - if count == 0 { - return false - } - right := left + obj.Size - positioner := NewTextPositioner(previous) - for range count { - right = math.Max(right, positioner.Next().X+obj.Size) - } - return x <= right -} - -// textCodeGlyphCount 统计文本段实际绘制的字形数量,计入字符到字形的变换 -// 入参: index 文本段索引 -// 返回: int 字形数量 -func (obj TextObject) textCodeGlyphCount(index int) int { - code := obj.TextCode[index] - if code.Index != "" { - count := 0 - for _, field := range strings.Fields(code.Index) { - first, last, interval := strings.Cut(field, "-") - start, err := strconv.ParseUint(first, 10, 16) - if err != nil { - continue - } - if !interval { - count++ - continue - } - end, err := strconv.ParseUint(last, 10, 16) - if err == nil && end >= start { - count += int(end-start) + 1 - } - } - return count - } - count := textCodeRuneCount(code.Value) - if len(obj.CGTransform) == 0 || count == 0 { - return count - } - offset := 0 - for _, previous := range obj.TextCode[:index] { - offset += textCodeRuneCount(previous.Value) - } - transforms := make(map[int][2]int) - for _, transform := range obj.CGTransform { - start, codes := transform.CodePosition-offset, max(1, transform.CodeCount) - if start < 0 || start >= count { - continue - } - glyphs := len(parseInts(transform.Glyphs)) - if transform.GlyphCount > 0 { - glyphs = min(glyphs, transform.GlyphCount) - } - if glyphs > 0 { - transforms[start] = [2]int{codes, glyphs} - } - } - glyphs := 0 - for i := 0; i < count; { - if transform, ok := transforms[i]; ok && transform[0] <= count-i { - i += transform[0] - glyphs += transform[1] - } else { - i++ - glyphs++ - } - } - return glyphs -} - // textCodeRuneCount 统计文本编码中的字符数,识别转义及XML排版空白 // 入参: value 文本编码内容 // 返回: int 字符数量 diff --git a/ofdgo_text_lines.go b/ofdgo_text_lines.go new file mode 100644 index 0000000..0180c7e --- /dev/null +++ b/ofdgo_text_lines.go @@ -0,0 +1,151 @@ +// 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 ( + "math" + "strconv" + "strings" +) + +// textLineScanner 顺序判断文本段换行,复用继承坐标和字符到字形的变换 +type textLineScanner struct { + size float64 + enabled bool + started bool + firstX string + previous TextCode + offset int + pending []CGTransform + transforms map[int][2]int +} + +// newTextLineScanner 创建独立的换行扫描器,不修改文字对象 +// 入参: obj 定位文字 +// 返回: textLineScanner 扫描状态 +func newTextLineScanner(obj TextObject) textLineScanner { + return textLineScanner{size: obj.Size, enabled: len(obj.TextCode) > 1 && obj.ReadDirection == 0 && obj.CharDirection == 0, previous: TextCode{X: "0", Y: "0"}, pending: obj.CGTransform} +} + +// glyphCount 首次需要字形范围时解析变换,后续文本段复用结果 +// 入参: code 文本段, offset 对象内字符起点 +// 返回: int 字形数量 +func (s *textLineScanner) glyphCount(code TextCode, offset int) int { + if len(s.pending) != 0 { + s.transforms = make(map[int][2]int, len(s.pending)) + } + for _, transform := range s.pending { + if transform.CodePosition < 0 { + continue + } + glyphs := len(parseInts(transform.Glyphs)) + if transform.GlyphCount > 0 { + glyphs = min(glyphs, transform.GlyphCount) + } + if glyphs > 0 { + s.transforms[transform.CodePosition] = [2]int{max(1, transform.CodeCount), glyphs} + } + } + s.pending = nil + return textCodeGlyphCount(code, offset, s.transforms) +} + +// next 继承文本段原点并根据实际字形范围判断是否换行 +// 入参: code 按文档顺序传入的文本段 +// 返回: bool 当前段前是否换行 +func (s *textLineScanner) next(code TextCode) bool { + if !s.enabled { + return false + } + previous, offset := s.previous, s.offset + if s.started && (len(s.pending) != 0 || len(s.transforms) != 0) { + s.offset += textCodeRuneCount(previous.Value) + } + if code.X == "" { + code.X = previous.X + } + if code.Y == "" { + code.Y = previous.Y + } + s.previous = code + if !s.started { + s.started, s.firstX = true, code.X + return false + } + x, ex := strconv.ParseFloat(code.X, 64) + start, es := strconv.ParseFloat(s.firstX, 64) + y, ey := strconv.ParseFloat(code.Y, 64) + prev, ep := strconv.ParseFloat(previous.Y, 64) + if ex != nil || es != nil || ey != nil || ep != nil { + return false + } + if x == start && y != prev { + return true + } + left, err := strconv.ParseFloat(previous.X, 64) + if err != nil || s.size <= 0 || y-prev < s.size/2 || previous.DeltaY != "" || code.DeltaY != "" { + return false + } + count := s.glyphCount(previous, offset) + if count == 0 { + return false + } + right := left + s.size + positioner := NewTextPositioner(previous) + for range count { + right = math.Max(right, positioner.Next().X+s.size) + } + return x <= right +} + +// textCodeGlyphCount 按文本段的字符起点统计实际字形数量 +// 入参: code 文本段, offset 对象内字符起点, transforms 已解析的字符到字形变换 +// 返回: int 字形数量 +func textCodeGlyphCount(code TextCode, offset int, transforms map[int][2]int) int { + if code.Index != "" { + count := 0 + for _, field := range strings.Fields(code.Index) { + first, last, interval := strings.Cut(field, "-") + start, err := strconv.ParseUint(first, 10, 16) + if err != nil { + continue + } + if !interval { + count++ + continue + } + end, err := strconv.ParseUint(last, 10, 16) + if err == nil && end >= start { + count += int(end-start) + 1 + } + } + return count + } + count := textCodeRuneCount(code.Value) + if len(transforms) == 0 || count == 0 { + return count + } + glyphs := 0 + for i := 0; i < count; { + if transform, ok := transforms[offset+i]; ok && transform[0] <= count-i { + i += transform[0] + glyphs += transform[1] + } else { + i++ + glyphs++ + } + } + return glyphs +} diff --git a/ofdgo_text_xml.go b/ofdgo_text_xml.go index 5efbdde..c883362 100644 --- a/ofdgo_text_xml.go +++ b/ofdgo_text_xml.go @@ -85,6 +85,12 @@ func (obj *TextObject) UnmarshalXML(decoder *xml.Decoder, start xml.StartElement if attr.Name.Local == "LineWidth" { obj.LineWidthSet = obj.LineWidth == 0 } + if attr.Name.Local == "MiterLimit" { + obj.MiterLimitSet = obj.MiterLimit == 0 + } + if attr.Name.Local == "DashPattern" { + obj.dashPatternSet = obj.DashPattern == "" + } } return nil } diff --git a/ofdgo_version_edit.go b/ofdgo_version_edit.go index de95987..4ee5602 100644 --- a/ofdgo_version_edit.go +++ b/ofdgo_version_edit.go @@ -122,7 +122,7 @@ func (r *Reader) versionEditGraph(ctx context.Context, entry string, parts map[s record := func(node *editorXML, attribute, target string) { file.links = append(file.links, versionEditLink{node: node, attribute: attribute, target: target}) } - if err := view.scanDocumentFileReferences(ctx, name, false, add, record); err != nil { + if err := view.scanDocumentFileReferences(ctx, name, false, add, record, nil); err != nil { return nil, err } } @@ -182,7 +182,7 @@ func (r *Reader) versionSharedFiles(ctx context.Context) (map[string]bool, error shared[file.Location] = true } } - if r.versionInfo != nil { + if r.versionInfo != nil && r.OFD.DocBody[r.documentIndex].DocRoot != "" { entry, err := versionFileLocation("OFD.xml", r.OFD.DocBody[r.documentIndex].DocRoot) if err != nil { return nil, err @@ -238,9 +238,9 @@ func (r *Reader) versionChanges(ctx context.Context, changes map[string][]byte) } // versionFileUnchanged 分块比较原文件与待写入内容,读取失败时保留替换内容 -// 入参: ctx 取消上下文, name 包内路径, data 待写入内容, buffer 非空比较缓冲区 +// 入参: ctx 取消上下文, name 包内路径, data 待写入内容, buffer 复用缓冲区,比较ZIP条目时按需分配 // 返回: bool 内容相同, error 取消错误 -func (r *Reader) versionFileUnchanged(ctx context.Context, name string, data, buffer []byte) (bool, error) { +func (r *Reader) versionFileUnchanged(ctx context.Context, name string, data []byte, buffer *[]byte) (bool, error) { if err := ctx.Err(); err != nil { return false, err } @@ -253,7 +253,7 @@ func (r *Reader) versionFileUnchanged(ctx context.Context, name string, data, bu if err := ctx.Err(); err != nil { return false, err } - end := offset + min(len(buffer), len(data)-offset) + end := offset + min(32*1024, len(data)-offset) if !bytes.Equal(original[offset:end], data[offset:end]) { return false, nil } @@ -270,9 +270,12 @@ func (r *Reader) versionFileUnchanged(ctx context.Context, name string, data, bu return false, nil } defer input.Close() + if len(*buffer) == 0 { + *buffer = make([]byte, 32*1024) + } reader := imageInput{ReadCloser: input, context: ctx} for len(data) != 0 { - block := buffer[:min(len(buffer), len(data))] + block := (*buffer)[:min(len(*buffer), len(data))] if _, err := io.ReadFull(&reader, block); err != nil { return false, ctx.Err() } @@ -281,11 +284,11 @@ func (r *Reader) versionFileUnchanged(ctx context.Context, name string, data, bu } data = data[len(block):] } - n, err := reader.Read(buffer[:1]) + n, err := reader.Read((*buffer)[:1]) return n == 0 && err == io.EOF, ctx.Err() } -// versionOutputChanges 隔离文档或版本改动并同步删除清单,保留其他入口仍需使用的文件 +// versionOutputChanges 隔离文档或版本改动并同步删除清单,失败时不修改调用方清单 // 入参: ctx 取消上下文, changes 待写入条目, removed 待删除条目,返回前移除受保护路径, generated 本次自产页面及引用,随隔离路径更新 // 返回: map[string][]byte 隔离后的修改集, error 读取、引用或结构错误 func (r *Reader) versionOutputChanges(ctx context.Context, changes map[string][]byte, removed map[string]bool, generated map[string]editorGeneratedReferences) (map[string][]byte, error) { @@ -303,12 +306,12 @@ func (r *Reader) versionOutputChanges(ctx context.Context, changes map[string][] if parts == nil { parts = make(map[string][]byte) } - buffer := make([]byte, 32*1024) + var buffer []byte for name, data := range parts { if generated[name].stagedLeaf(data) { continue } - same, err := r.versionFileUnchanged(ctx, name, data, buffer) + same, err := r.versionFileUnchanged(ctx, name, data, &buffer) if err != nil { return nil, err } @@ -339,6 +342,8 @@ func (r *Reader) versionOutputChanges(ctx context.Context, changes map[string][] if err != nil { return nil, err } + outputRemoved := removed + removed = maps.Clone(removed) for name := range removed { if graph.used[name] { delete(removed, name) @@ -434,6 +439,11 @@ func (r *Reader) versionOutputChanges(ctx context.Context, changes map[string][] if err := ctx.Err(); err != nil { return nil, err } + for name := range outputRemoved { + if _, ok := removed[name]; !ok { + delete(outputRemoved, name) + } + } for source, target := range paths { if known := generated[source]; known.stagedLeaf(parts[target]) { generated[target] = known diff --git a/ofdgo_version_extract.go b/ofdgo_version_extract.go index daf5712..c2bc338 100644 --- a/ofdgo_version_extract.go +++ b/ofdgo_version_extract.go @@ -120,7 +120,7 @@ func (r *Reader) extractedVersionRoot(ctx context.Context, version DocumentVersi return nil, err } body := root.childAt("DocBody", r.documentIndex) - if body == nil || body.child("DocRoot") == nil { + if body == nil { return nil, fmt.Errorf("document entry not found: %d", r.documentIndex) } var patches []editorXMLPatch @@ -133,9 +133,17 @@ func (r *Reader) extractedVersionRoot(ctx context.Context, version DocumentVersi } } entry := body.child("DocRoot") - patches = append(patches, editorXMLContent(data, entry, editorFontXMLText(version.DocRoot))) + if entry != nil { + patches = append(patches, editorXMLContent(data, entry, editorFontXMLText(version.DocRoot))) + } if versions := body.child("Versions"); versions != nil { - patches = append(patches, editorXMLPatch{start: versions.start, end: versions.end}) + var replacement []byte + if entry == nil { + replacement = body.textXML("DocRoot", version.DocRoot) + } + patches = append(patches, editorXMLPatch{versions.start, versions.end, replacement}) + } else if entry == nil { + return nil, fmt.Errorf("document entry not found: %d", r.documentIndex) } references := []*editorXML{body.child("Signatures")} if info := body.child("DocInfo"); info != nil { diff --git a/ofdgo_version_reader.go b/ofdgo_version_reader.go index dded18d..43aa1c5 100644 --- a/ofdgo_version_reader.go +++ b/ofdgo_version_reader.go @@ -104,11 +104,19 @@ func (r *Reader) DocumentVersionID() (string, error) { // 入参: id 版本标识,空值显式选择主入口;Document可重新按Current选择默认版本 // 返回: *Reader 版本阅读器, error 选择或读取错误 func (r *Reader) DocumentVersion(id string) (*Reader, error) { + return r.DocumentVersionContext(context.Background(), id) +} + +// DocumentVersionContext 创建指定版本的独立阅读器,读取版本及关联内容时响应取消 +// 返回值不关闭输入文件,使用期间不得关闭持有输入文件的原阅读器 +// 入参: ctx 取消上下文, id 版本标识,空值选择主入口 +// 返回: *Reader 版本阅读器, error 读取或取消错误 +func (r *Reader) DocumentVersionContext(ctx context.Context, id string) (*Reader, error) { next := &Reader{Path: r.Path, Zip: r.Zip, files: r.files, encryption: r.encryption, documentIndex: r.documentIndex, selectedVersion: &id} - if err := next.initRoot(); err != nil { + if err := next.initRootContext(ctx); err != nil { return nil, err } - if _, err := next.Doc(); err != nil { + if _, err := next.DocContext(ctx); err != nil { return nil, err } return next, nil diff --git a/ofdgo_writer.go b/ofdgo_writer.go index b99cf9f..5c12c83 100644 --- a/ofdgo_writer.go +++ b/ofdgo_writer.go @@ -141,6 +141,14 @@ func (e *Editor) Reader() (*Reader, error) { return e.reader(nil) } +// ReaderContext 获取可取消的独立内存快照,失败时不返回部分结果 +// 快照保持Reader的资源所有权规则,不改变当前编辑状态或撤销记录 +// 入参: ctx 取消上下文 +// 返回: *Reader 阅读器, error 准备或取消错误 +func (e *Editor) ReaderContext(ctx context.Context) (*Reader, error) { + return e.readerContext(ctx, nil) +} + // Write 写入并累计字节数 // 入参: data 数据 // 返回: int 字节数, error 错误信息 @@ -276,11 +284,11 @@ func (e *Editor) readerContext(ctx context.Context, progress editorProgress) (*R if err := r.initRootContext(ctx); err != nil { return nil, err } - if _, err := r.Doc(); err != nil { + if _, err := r.DocContext(ctx); err != nil { return nil, err } for i, page := range r.doc.Pages.Page { - r.pageHeaderCache[r.ResPath(page.BaseLoc)] = PageContent{Area: PageArea{PhysicalBox: e.pages[i].Area.PhysicalBox}} + r.pageHeaderCache[r.ResPath(page.BaseLoc)] = PageContent{Area: e.pages[i].Area, Template: slices.Clone(e.pages[i].Template), PageRes: slices.Clone(e.pages[i].PageRes)} } return r, nil } @@ -411,9 +419,7 @@ func (e *Editor) writePartsWithPages(write func(string, []byte, bool) error, pro x.root("Document", nil) x.start("CommonData", nil) x.text("MaxUnitID", strconv.Itoa(e.maxID)) - x.start("PageArea", nil) - x.text("PhysicalBox", e.pages[0].Area.PhysicalBox) - x.end("PageArea") + x.pageArea("PageArea", e.pages[0].Area) if len(fonts)+len(images)+len(spaces) != 0 { x.text("DocumentRes", "DocumentRes.xml") } @@ -620,13 +626,11 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color return fonts, images, spaces, files } -// page 写出页面尺寸、图层和对象 +// page 按标准顺序写出页面定义、图层、对象及动作 // 入参: page 页面内容 func (x *ofdXML) page(page PageContent) { x.root("Page", nil) - x.start("Area", nil) - x.text("PhysicalBox", page.Area.PhysicalBox) - x.end("Area") + x.pageHeader(page) x.start("Content", nil) for _, layer := range page.Content.Layer { if x.err != nil { @@ -639,6 +643,34 @@ func (x *ofdXML) page(page PageContent) { x.end("Page") } +// pageHeader 写出模板、页资源及页面区域 +// 入参: page 页面内容 +func (x *ofdXML) pageHeader(page PageContent) { + for _, template := range page.Template { + attrs := ofdAttrs{{Name: xml.Name{Local: "TemplateID"}, Value: template.TemplateID}} + attrs.add("ZOrder", template.ZOrder) + x.start("Template", attrs) + x.end("Template") + } + for _, resource := range page.PageRes { + x.text("PageRes", resource) + } + x.pageArea("Area", page.Area) +} + +// pageArea 写出物理、显示、版心及出血区域,省略未定义的可选区域 +// 入参: name 区域容器名称, area 页面区域 +func (x *ofdXML) pageArea(name string, area PageArea) { + x.start(name, nil) + x.text("PhysicalBox", area.PhysicalBox) + for _, field := range [][2]string{{"ApplicationBox", area.ApplicationBox}, {"ContentBox", area.ContentBox}, {"BleedBox", area.BleedBox}} { + if field[1] != "" { + x.text(field[0], field[1]) + } + } + x.end(name) +} + // layer 写出新建图层及其对象 // 入参: layer 图层 func (x *ofdXML) layer(layer Layer) { @@ -890,7 +922,19 @@ func (x *ofdXML) object(object GraphicObject, root bool) { attrs.add("LineWidth", "0") } attrs.number("MiterLimit", obj.MiterLimit) + if obj.MiterLimitSet && obj.MiterLimit == 0 { + attrs.add("MiterLimit", "0") + } attrs.add("Join", obj.Join) + attrs.add("Cap", obj.Cap) + if obj.dashPatternSet { + attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "DashPattern"}, Value: obj.DashPattern}) + } else { + attrs.add("DashPattern", obj.DashPattern) + } + if obj.DashOffset != nil { + attrs.add("DashOffset", ofdNumber(*obj.DashOffset)) + } if obj.Italic { attrs.add("Italic", "true") } @@ -911,6 +955,9 @@ func (x *ofdXML) object(object GraphicObject, root bool) { attrs.add("LineWidth", "0") } attrs.number("MiterLimit", obj.MiterLimit) + if obj.MiterLimitSet && obj.MiterLimit == 0 { + attrs.add("MiterLimit", "0") + } attrs.add("Join", obj.Join) attrs.add("Cap", obj.Cap) attrs.add("Rule", obj.Rule)