diff --git a/README.md b/README.md
index 1024d5a..ae7f9b2 100644
--- a/README.md
+++ b/README.md
@@ -149,5 +149,41 @@ func main() {
}
```
+# 编辑文档
+```go
+package main
+
+import (
+ "log"
+ "os"
+
+ "github.com/xiaoqidun/ofdgo"
+)
+
+func main() {
+ // 1. 打开文档
+ reader, err := ofdgo.Open("test.ofd")
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer reader.Close()
+ // 2. 编辑标题
+ editor, err := reader.Editor()
+ if err != nil {
+ log.Fatal(err)
+ }
+ editor.Info.Title = "编辑后的"
+ // 3. 另存文档
+ ofdFile, err := os.Create("edited.ofd")
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer ofdFile.Close()
+ if _, err := editor.WriteTo(ofdFile); err != nil {
+ log.Fatal(err)
+ }
+}
+```
+
# 授权协议
本项目使用 [Apache License 2.0](https://github.com/xiaoqidun/ofdgo/blob/main/LICENSE) 授权协议
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css
index 8a83712..46ca2fa 100644
--- a/assets/webui/ofdgo.css
+++ b/assets/webui/ofdgo.css
@@ -1071,6 +1071,10 @@ button.outline-link:hover {
outline-offset: -2px;
}
+.viewer-panel.pointer-focus:focus {
+ outline: none;
+}
+
.viewer-panel.pan-mode,
.pan-mode .text-run > span {
cursor: grab;
diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js
index 0c470d4..c88daed 100644
--- a/assets/webui/ofdgo.js
+++ b/assets/webui/ofdgo.js
@@ -488,6 +488,8 @@ el.searchInput.addEventListener("input", () => resetSearch(false));
el.searchPrev.addEventListener("click", () => selectSearchMatch(Math.max(0, state.searchIndex) - 1));
el.searchNext.addEventListener("click", () => selectSearchMatch(state.searchIndex + 1));
document.addEventListener("keydown", handleKeyDown);
+document.addEventListener("keydown", () => el.viewerPanel.classList.remove("pointer-focus"), true);
+document.addEventListener("pointerdown", () => el.viewerPanel.classList.add("pointer-focus"), true);
el.fontAddButton.addEventListener("click", () => openFontFile(el.fontInput));
el.fontDirectoryButton.addEventListener("click", () => openFontFile(el.fontDirectoryInput));
el.localFontButton.addEventListener("click", loadLocalFonts);
diff --git a/go.mod b/go.mod
index 7400d95..8a15871 100644
--- a/go.mod
+++ b/go.mod
@@ -12,24 +12,29 @@ require (
)
require (
+ codeberg.org/go-latex/latex v0.3.0 // indirect
codeberg.org/go-pdf/fpdf v0.12.0 // indirect
github.com/BurntSushi/freetype-go v0.0.0-20160129220410-b763ddbfe298 // indirect
github.com/BurntSushi/graphics-go v0.0.0-20160129215708-b43f31a4a966 // indirect
github.com/BurntSushi/xgb v0.0.0-20210121224620-deaf085860bc // indirect
github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 // indirect
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f // indirect
+ github.com/Kagami/go-avif v0.1.0 // indirect
github.com/andybalholm/brotli v1.2.2 // indirect
github.com/benoitkugler/textlayout v0.3.2 // indirect
github.com/benoitkugler/textprocessing v0.0.6 // indirect
github.com/go-fonts/latin-modern v0.3.3 // indirect
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
+ github.com/kolesa-team/go-webp v1.0.5 // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/srwiley/scanx v0.0.0-20190309010443-e94503791388 // indirect
github.com/tdewolff/minify/v2 v2.24.17 // indirect
github.com/tdewolff/parse/v2 v2.8.16 // indirect
+ github.com/wcharczuk/go-chart/v2 v2.1.2 // indirect
github.com/yuin/goldmark v1.8.5 // indirect
golang.org/x/net v0.58.0 // indirect
golang.org/x/sys v0.48.0 // indirect
+ gonum.org/v1/plot v0.17.0 // indirect
modernc.org/knuth v0.6.0 // indirect
modernc.org/token v1.1.0 // indirect
star-tex.org/x/tex v0.7.1 // indirect
diff --git a/go.sum b/go.sum
index 3a5a9ba..60d6327 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+codeberg.org/go-fonts/dejavu v0.4.0 h1:2yn58Vkh4CFK3ipacWUAIE3XVBGNa0y1bc95Bmfx91I=
+codeberg.org/go-fonts/dejavu v0.4.0/go.mod h1:abni088lmhQJvso2Lsb7azCKzwkfcnttl6tL1UTWKzg=
codeberg.org/go-fonts/liberation v0.6.0 h1:15Gh6SdwYve22CWCm9jYpVpRuaTh726av2TgHTHvAtQ=
codeberg.org/go-fonts/liberation v0.6.0/go.mod h1:J15VAa+lyxdcI/Je7lDDDl6QOhLk9feNBnnwXqEHXOk=
codeberg.org/go-latex/latex v0.3.0 h1:LKTaDHFbEC2PH1sh0sYv6PZ1pzs/g2aoeV1HItWj/bg=
@@ -18,6 +20,8 @@ github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046 h1:O/r2Sj+8QcMF
github.com/BurntSushi/xgbutil v0.0.0-20190907113008-ad855c713046/go.mod h1:uw9h2sd4WWHOPdJ13MQpwK5qYWKYDumDqxWWIknEQ+k=
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f h1:l7moT9o/v/9acCWA64Yz/HDLqjcRTvc0noQACi4MsJw=
github.com/ByteArena/poly2tri-go v0.0.0-20170716161910-d102ad91854f/go.mod h1:vIOkSdX3NDCPwgu8FIuTat2zDF0FPXXQ0RYFRy+oQic=
+github.com/Kagami/go-avif v0.1.0 h1:8GHAGLxCdFfhpd4Zg8j1EqO7rtcQNenxIDerC/uu68w=
+github.com/Kagami/go-avif v0.1.0/go.mod h1:OPmPqzNdQq3+sXm0HqaUJQ9W/4k+Elbc3RSfJUemDKA=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b h1:slYM766cy2nI3BwyRiyQj/Ud48djTMtMebDqepE95rw=
github.com/ajstarks/svgo v0.0.0-20211024235047-1546f124cd8b/go.mod h1:1KcenG0jGWcpt8ov532z81sp/kMMUG485J2InIOyADM=
github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM=
@@ -28,6 +32,7 @@ github.com/benoitkugler/textlayout v0.3.2/go.mod h1:o+1hFV+JSHBC9qNLIuwVoLedERU7
github.com/benoitkugler/textlayout-testdata v0.1.1/go.mod h1:i/qZl09BbUOtd7Bu/W1CAubRwTWrEXWq6JwMkw8wYxo=
github.com/benoitkugler/textprocessing v0.0.6 h1:obkMyj62GEPg3xUVYqROlCN22z1OleuZm6ULqX9Om1g=
github.com/benoitkugler/textprocessing v0.0.6/go.mod h1:Io0gN08/PXEzrSOWFa88xHx2Xv3VjvLMY7H76YoI23A=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-fonts/latin-modern v0.3.3 h1:g2xNgI8yzdNzIVm+qvbMryB6yGPe0pSMss8QT3QwlJ0=
github.com/go-fonts/latin-modern v0.3.3/go.mod h1:tHaiWDGze4EPB0Go4cLT5M3QzRY3peya09Z/8KSCrpY=
github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
@@ -36,6 +41,10 @@ github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/kolesa-team/go-webp v1.0.5 h1:GZQHJBaE8dsNKZltfwqsL0qVJ7vqHXsfA+4AHrQW3pE=
+github.com/kolesa-team/go-webp v1.0.5/go.mod h1:QmJu0YHXT3ex+4SgUvs+a+1SFCDcCqyZg+LbIuNNTnE=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
@@ -46,6 +55,10 @@ github.com/srwiley/scanFT v0.0.0-20220128184157-0d1ee492111f h1:uLR2GaV0kWYZ3Ns3
github.com/srwiley/scanFT v0.0.0-20220128184157-0d1ee492111f/go.mod h1:LZwgIPG9X6nH6j5Ef+xMFspl6Hru4b5EJxzMfeqHYJY=
github.com/srwiley/scanx v0.0.0-20190309010443-e94503791388 h1:ZdkidVdpLW13BQ9a+/3uerT2ezy9J7KQWH18JCfhDmI=
github.com/srwiley/scanx v0.0.0-20190309010443-e94503791388/go.mod h1:C/WY5lmWfMtPFYYBTd3Lzdn4FTLr+RxlIeiBNye+/os=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
+github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/tdewolff/canvas v0.0.0-20260913163248-dd4999d1c76a h1:Qxn+Q+tIBhRGMkWXOXhhOmJMlj7FbSmdMabXyAOtBzI=
github.com/tdewolff/canvas v0.0.0-20260913163248-dd4999d1c76a/go.mod h1:K9Ux3cwFtyAFRITZ46g7v+ORXgI3ENtQa5rL9yjNz5Y=
github.com/tdewolff/font v0.0.0-20260913163313-54f98bb59ee6 h1:phNTWRYLLTlPpUgxkd6FDmWLlrOzpEpQAqq1ryLXYS8=
@@ -56,31 +69,92 @@ github.com/tdewolff/parse/v2 v2.8.16 h1:bLk5svUOQRkW/Y2SJ+DeENSIkZBcTIkq+Atyv5D8
github.com/tdewolff/parse/v2 v2.8.16/go.mod h1:XdsoSFThlVIRIajAuqz1evNY7bagZS8LBOPA3aVopwQ=
github.com/tdewolff/test v1.0.12 h1:7F21DqIajswxuche0geHdrUZRCWE4oko4b7bcmkkrxk=
github.com/tdewolff/test v1.0.12/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
+github.com/wcharczuk/go-chart/v2 v2.1.2 h1:Y17/oYNuXwZg6TFag06qe8sBajwwsuvPiJJXcUcLL6E=
+github.com/wcharczuk/go-chart/v2 v2.1.2/go.mod h1:Zi4hbaqlWpYajnXB2K22IUYVXRXaLfSGNNR7P4ukyyQ=
github.com/xiaoqidun/jbig2 v1.0.0 h1:Wt434JKS7j7LqHnMSL/3yatRCxjOfDiA+RhZA/DhAAo=
github.com/xiaoqidun/jbig2 v1.0.0/go.mod h1:8QonVWtENmh2kc6l+leqeGl7psb79DWlHDkCpdChjfQ=
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.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
+go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
+go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
+golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
+golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
+golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
+golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/image v0.0.0-20210504121937-7319ad40d33e/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
+golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
+golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
+golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
+golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
+golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
+golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
+golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
+golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
+golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
+golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
+golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
+golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
+golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
+golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
+golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
+golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
+golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
+golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
+golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
+golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/plot v0.17.0 h1:d0DwPVBe9jnEGqQBoZGl/P2M9WciJbG2CnV59C9QBT4=
gonum.org/v1/plot v0.17.0/go.mod h1:ipt2GUN1oqzr2O7wCjLDtw1ShfIYYNBp4o0O1Ez5B3Y=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/knuth v0.6.0 h1:zLv0hzgdcpcCUB/iMwpQvn9m/iciqO/hyNXlpWymMKU=
modernc.org/knuth v0.6.0/go.mod h1:ISAZV+URZIlE8h1BwNVg1NzpwHKndV5lQ13NPPj3tds=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go
index 962892c..0a80030 100644
--- a/internal/webui/wasm.go
+++ b/internal/webui/wasm.go
@@ -98,6 +98,7 @@ func RunWASM() {
registerExportCallback("ofdgoExportAttachment", exportAttachment)
registerCallback("ofdgoMatchFontFiles", matchFontFiles)
registerCallback("ofdgoCreateDocument", createDocument)
+ registerCallback("ofdgoEditDocument", editDocument)
registerCallback("ofdgoChangePage", changePage)
registerCallback("ofdgoInsertText", insertText)
registerCallback("ofdgoUpdateText", updateText)
@@ -530,10 +531,10 @@ func encodeResult(result apiResult) string {
return string(data)
}
-// currentEditor 当前新建文档
+// currentEditor 当前编辑文档
var currentEditor *ofdgo.Editor
-// editorClipboard 当前创作文档中的对象快照与剪贴板标识。
+// editorClipboard 当前编辑文档中的对象快照与剪贴板标识。
type editorClipboard struct {
token string
objects []ofdgo.GraphicObject
@@ -542,12 +543,14 @@ type editorClipboard struct {
// copiedObjects 当前对象剪贴板,不保存字体或图片的重复数据。
var copiedObjects *editorClipboard
-// editorInfo 创作文档信息与操作状态
+// editorInfo 编辑文档信息与操作状态
type editorInfo struct {
DocumentInfo
- Revision uint64 `json:"revision"`
- CanUndo bool `json:"canUndo"`
- CanRedo bool `json:"canRedo"`
+ Revision uint64 `json:"revision"`
+ CanUndo bool `json:"canUndo"`
+ CanRedo bool `json:"canRedo"`
+ PageCapabilities []map[string]bool `json:"pageCapabilities"`
+ EditWarnings []string `json:"editWarnings,omitempty"`
}
// editorPageInfo 页面操作结果与目标页面
@@ -562,10 +565,21 @@ type editorSelectionInfo struct {
SelectedIDs []string `json:"selectedIDs"`
}
-// editorSummary 获取当前创作文档状态
+// editorSummary 获取当前编辑文档状态
// 返回: editorInfo 文档信息
func editorSummary() editorInfo {
- return editorInfo{currentSession.Summary(), currentEditor.Revision(), currentEditor.CanUndo(), currentEditor.CanRedo()}
+ info := editorInfo{DocumentInfo: currentSession.Summary(), Revision: currentEditor.Revision(), CanUndo: currentEditor.CanUndo(), CanRedo: currentEditor.CanRedo()}
+ if !currentSession.doc.Permissions.Edit {
+ info.EditWarnings = append(info.EditWarnings, "原文件声明不允许编辑")
+ }
+ if currentSession.doc.Signatures != "" {
+ info.EditWarnings = append(info.EditWarnings, "修改文档可能使原签名失效")
+ }
+ for i := 0; i < currentEditor.PageCount(); i++ {
+ capability, _ := currentEditor.PageCapabilities(i)
+ info.PageCapabilities = append(info.PageCapabilities, map[string]bool{"insert": capability.Insert, "copy": capability.Copy, "delete": capability.Delete, "move": capability.Move, "resize": capability.Resize})
+ }
+ return info
}
// restoreEditor 撤销或重做并更新预览
@@ -573,7 +587,7 @@ func editorSummary() editorInfo {
// 返回: any 文档信息, error 错误信息
func restoreEditor(redo bool) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
apply := currentEditor.Undo
if redo {
@@ -585,7 +599,7 @@ func restoreEditor(redo bool) (any, error) {
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
}
-// editorObjects 提供创作画布的对象范围、文字排版及原始图片边界
+// editorObjects 提供编辑画布的对象范围、文字排版及原始图片边界
// 入参: index 页面索引, text 页面文字
// 返回: []any 对象区域, error 错误信息
func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
@@ -623,28 +637,38 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
for _, layer := range page.Content.Layer {
for order, object := range layer.Objects {
var id string
- var box ofdgo.Box
switch object.Type {
case "TextObject":
id = object.TextObject.ID
- box = textBoxes[id]
case "ImageObject":
id = object.ImageObject.ID
- box, err = ofdgo.ParseBox(object.ImageObject.Boundary)
- if err != nil {
- return nil, err
- }
case "PathObject":
id = object.PathObject.ID
- box, err = ofdgo.ParseBox(object.PathObject.Boundary)
- if err != nil {
- return nil, err
- }
default:
continue
}
+ capability, err := currentEditor.ObjectCapabilities(index, id)
+ if err != nil {
+ return nil, err
+ }
+ if !capability.Transform && !capability.Update {
+ continue
+ }
+ var box ofdgo.Box
+ switch object.Type {
+ case "TextObject":
+ box = textBoxes[id]
+ case "ImageObject":
+ box, err = ofdgo.ParseBox(object.ImageObject.Boundary)
+ case "PathObject":
+ box, err = ofdgo.ParseBox(object.PathObject.Boundary)
+ }
+ if err != nil {
+ return nil, err
+ }
if box.W > 0 && box.H > 0 {
- item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "count": len(layer.Objects)}
+ item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "count": len(layer.Objects), "layer": layer.ID,
+ "capabilities": map[string]any{"update": capability.Update, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason}}
if object.Type == "ImageObject" {
full, err := object.ImageObject.ImageBounds()
if err != nil {
@@ -652,7 +676,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
}
item["imageBounds"] = editorBox(full)
}
- if object.Type == "PathObject" {
+ if object.Type == "PathObject" && capability.Update {
path := object.PathObject
kind, geometry := path.Shape()
if kind != "" {
@@ -673,36 +697,34 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
item["text"], item["font"], item["fontName"], item["size"] = value, object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size
item["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight
item["letterSpacing"] = layout.LetterSpacing
- frame, err := object.TextObject.TextFrame()
- if err != nil {
- return nil, err
- }
- boundary, _ := ofdgo.ParseBox(object.TextObject.Boundary)
- matrix := ofdgo.TranslationMatrix(boundary.X, boundary.Y).Multiply(ofdgo.NewMatrix(object.TextObject.CTM))
- inverse, _ := matrix.Invert()
- local := inverse.TransformBox(box)
- width := math.Max(object.TextObject.Size, local.X+local.W)
- if layout.Wrap {
- width = frame.W
- }
- frame = ofdgo.Box{W: width, H: math.Max(object.TextObject.Size, local.Y+local.H)}
- item["textFrame"] = map[string]any{"width": frame.W, "height": frame.H, "matrix": editorMatrix(matrix)}
- if layout.Wrap {
- bounds := matrix.TransformBox(frame)
- item["x"], item["y"], item["width"], item["height"] = bounds.X, bounds.Y, bounds.W, bounds.H
+ if capability.Reflow {
+ frame, err := object.TextObject.TextFrame()
+ if err != nil {
+ return nil, err
+ }
+ boundary, _ := ofdgo.ParseBox(object.TextObject.Boundary)
+ matrix := ofdgo.TranslationMatrix(boundary.X, boundary.Y).Multiply(ofdgo.NewMatrix(object.TextObject.CTM))
+ inverse, _ := matrix.Invert()
+ local := inverse.TransformBox(box)
+ width := math.Max(object.TextObject.Size, local.X+local.W)
+ if layout.Wrap {
+ width = frame.W
+ }
+ frame = ofdgo.Box{W: width, H: math.Max(object.TextObject.Size, local.Y+local.H)}
+ item["textFrame"] = map[string]any{"width": frame.W, "height": frame.H, "matrix": editorMatrix(matrix)}
+ if layout.Wrap {
+ bounds := matrix.TransformBox(frame)
+ item["x"], item["y"], item["width"], item["height"] = bounds.X, bounds.Y, bounds.W, bounds.H
+ }
}
if codes := object.TextObject.TextCode; len(codes) > 1 {
first, _ := strconv.ParseFloat(codes[0].Y, 64)
second, _ := strconv.ParseFloat(codes[1].Y, 64)
item["lineHeight"] = second - first
}
- var r, g, b float64
- if fill := object.TextObject.FillColor; fill != nil {
- if _, err := fmt.Sscan(fill.Value, &r, &g, &b); err != nil {
- return nil, err
- }
+ if capability.Update {
+ item["color"] = editorColorHex(object.TextObject.FillColor)
}
- item["color"] = fmt.Sprintf("#%02x%02x%02x", byte(r), byte(g), byte(b))
}
objects = append(objects, item)
}
@@ -716,7 +738,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
// 返回: any 字体数据, error 错误信息
func editorFont(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
fonts, err := currentSession.Reader.Fonts()
if err != nil {
@@ -739,7 +761,7 @@ func editorFont(args []js.Value) (any, error) {
// 返回: any 文档信息, error 错误信息
func replaceImage(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
data, err := bytesFromJS(args[2])
if err != nil {
@@ -760,7 +782,7 @@ func replaceImage(args []js.Value) (any, error) {
// 返回: any 文档信息, error 错误信息
func alignObject(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
revision := currentEditor.Revision()
if err := currentEditor.AlignObject(args[0].Int(), args[1].String(), args[2].String()); err != nil {
@@ -777,7 +799,7 @@ func alignObject(args []js.Value) (any, error) {
// 返回: any 文档信息, error 错误信息
func transformObject(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
page, id := args[0].Int(), args[1].String()
revision := currentEditor.Revision()
@@ -795,7 +817,7 @@ func transformObject(args []js.Value) (any, error) {
// 返回: any 文档信息, error 错误信息
func reshapeObject(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
page, id := args[0].Int(), args[1].String()
object, err := currentEditor.Object(page, id)
@@ -824,7 +846,7 @@ func reshapeObject(args []js.Value) (any, error) {
// 返回: any 文档信息, error 错误信息
func copyObjects(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
objects, err := currentEditor.Objects(args[0].Int(), stringsFromJS(args[1]))
if err != nil {
@@ -838,7 +860,7 @@ func copyObjects(args []js.Value) (any, error) {
// 返回: any 空结果, error 错误信息
func captureObjects(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
objects, err := currentEditor.Objects(args[0].Int(), stringsFromJS(args[1]))
if err != nil {
@@ -896,7 +918,7 @@ func distributeObjects(args []js.Value) (any, error) {
// 返回: any 文档信息, error 错误信息
func deleteObject(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
if err := currentEditor.DeleteObject(args[0].Int(), args[1].String()); err != nil {
return nil, err
@@ -909,7 +931,7 @@ func deleteObject(args []js.Value) (any, error) {
// 返回: any 文档信息和目标页面, error 错误信息
func changePage(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
index := args[1].Int()
revision := currentEditor.Revision()
@@ -960,8 +982,26 @@ func createDocument(args []js.Value) (any, error) {
return previewEditor(editor, args[3].Bool())
}
+// editDocument 将已打开文档接入编辑器,沿用页面、资源及字体配置。
+// 入参: args 浏览器参数
+// 返回: any 编辑状态, error 错误信息
+func editDocument(args []js.Value) (any, error) {
+ if currentSession == nil {
+ return nil, fmt.Errorf("ofd document is not opened")
+ }
+ if currentEditor != nil {
+ return editorSummary(), nil
+ }
+ editor, err := currentSession.Reader.Editor()
+ if err != nil {
+ return nil, err
+ }
+ editor.SetHistoryLimit(100)
+ return previewEditor(editor, currentSession.Renderer.RenderAnnotations)
+}
+
// previewEditor 通过内存快照更新预览,不生成中间压缩包
-// 入参: editor 新建文档, annotations 是否显示注解
+// 入参: editor 编辑文档, annotations 是否显示注解
// 返回: editorInfo 文档信息, error 错误信息
func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) {
reader, err := editor.Reader()
@@ -973,6 +1013,10 @@ func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) {
return editorInfo{}, err
}
if currentSession != nil {
+ if currentSession.fontFS != nil {
+ session.fontFS = currentSession.fontFS
+ session.Renderer.SetFontFS(session.fontFS)
+ }
_ = currentSession.Close()
}
currentSession = session
@@ -988,7 +1032,7 @@ func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) {
// 返回: any 文档信息, error 错误信息
func updateText(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
page, id := args[0].Int(), args[1].String()
object, err := currentEditor.Object(page, id)
@@ -1112,7 +1156,7 @@ func setPathStyle(object *ofdgo.PathObject, args []js.Value) error {
// 返回: any 文档信息, error 错误信息
func insertShape(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
path, err := ofdgo.NewShape(ofdgo.ShapeKind(args[1].String()), ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: args[4].Float(), H: args[5].Float()})
if err != nil {
@@ -1132,7 +1176,7 @@ func insertShape(args []js.Value) (any, error) {
// 返回: any 文档信息, error 错误信息
func updatePathStyle(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
page, id := args[0].Int(), args[1].String()
object, err := currentEditor.Object(page, id)
@@ -1160,7 +1204,7 @@ func updatePathStyle(args []js.Value) (any, error) {
// 返回: any 文档信息, error 错误信息
func insertText(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
data, err := bytesFromJS(args[2])
if err != nil {
@@ -1206,7 +1250,7 @@ func insertText(args []js.Value) (any, error) {
// 返回: any 文档信息, error 错误信息
func layoutText(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
page, id := args[0].Int(), args[1].String()
object, err := currentEditor.Object(page, id)
@@ -1294,7 +1338,7 @@ func fitImage(args []js.Value) (any, error) {
// 返回: any 页面SVG和分离的图片资源, error 错误信息
func previewImage(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
page, err := currentEditor.Page(args[0].Int())
if err != nil {
@@ -1365,7 +1409,7 @@ func deleteObjects(args []js.Value) (any, error) {
// 返回: any 文档信息, error 错误信息
func changeObjects(apply func() error) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
revision := currentEditor.Revision()
if err := apply(); err != nil {
@@ -1382,7 +1426,7 @@ func changeObjects(apply func() error) (any, error) {
// 返回: any 文档信息, error 错误信息
func insertImage(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
data, err := bytesFromJS(args[1])
if err != nil {
@@ -1424,7 +1468,7 @@ func insertImage(args []js.Value) (any, error) {
// 返回: any 保存结果, error 错误信息
func saveDocument(args []js.Value) (any, error) {
if currentEditor == nil {
- return nil, fmt.Errorf("no document is being created")
+ return nil, fmt.Errorf("no document is being edited")
}
writer := bufio.NewWriterSize(exportWriter{write: args[0]}, 1<<20)
if _, err := currentEditor.WriteTo(writer); err != nil {
diff --git a/internal/webui/webui.go b/internal/webui/webui.go
index 6fa4c9e..94d1f48 100644
--- a/internal/webui/webui.go
+++ b/internal/webui/webui.go
@@ -100,6 +100,7 @@ type SignatureStampInfo struct {
type Session struct {
Reader *ofdgo.Reader
Renderer *ofdgo.Renderer
+ fontFS *ofdgo.FontFS
doc *ofdgo.Document
pageCache map[int]*ofdgo.PageContent
boxCache map[int]ofdgo.Box
@@ -232,8 +233,9 @@ func newSession(reader *ofdgo.Reader, opts OpenOptions) (*Session, error) {
return nil, err
}
var rendererOptions []ofdgo.RendererOption
+ var fontFS *ofdgo.FontFS
if len(opts.Fonts) > 0 {
- fontFS := ofdgo.NewFontFS(opts.Fonts)
+ fontFS = ofdgo.NewFontFS(opts.Fonts)
if fontFS.Len() == 0 {
reader.Close()
return nil, fmt.Errorf("invalid font file")
@@ -244,6 +246,7 @@ func newSession(reader *ofdgo.Reader, opts OpenOptions) (*Session, error) {
return &Session{
Reader: reader,
Renderer: ofdgo.NewRenderer(reader, rendererOptions...),
+ fontFS: fontFS,
doc: doc,
pageCache: make(map[int]*ofdgo.PageContent),
boxCache: make(map[int]ofdgo.Box),
@@ -271,8 +274,10 @@ func (s *Session) SetFonts(fonts []FontFile) error {
return fmt.Errorf("invalid font file")
}
s.Renderer.SetFontFS(fontFS)
+ s.fontFS = fontFS
} else {
s.Renderer.SetFontFS()
+ s.fontFS = nil
}
clear(s.textCache)
clear(s.svgFonts)
diff --git a/ofdgo_editor.go b/ofdgo_editor.go
index b029100..b3d4fdd 100644
--- a/ofdgo_editor.go
+++ b/ofdgo_editor.go
@@ -23,7 +23,6 @@ import (
"fmt"
"image"
"math"
- "reflect"
"slices"
"strconv"
"strings"
@@ -33,8 +32,8 @@ import (
"github.com/tdewolff/font"
)
-// Editor 新建OFD文档,长度单位为毫米,页面索引从0开始,实例需串行使用
-// Info可修改文档元数据,通过方法管理页面、对象和资源,不修改已有OFD文件
+// Editor 编辑OFD文档,长度单位为毫米,页面索引从0开始,实例需串行使用
+// Info可修改文档元数据,通过方法管理页面、对象和资源,WriteTo另存结果,不覆盖输入
type Editor struct {
Info DocInfo
pages []PageContent
@@ -48,6 +47,7 @@ type Editor struct {
historyLimit int
revision uint64
serial uint64
+ source *editorSource
}
// editorResource 文档内嵌资源
@@ -96,18 +96,23 @@ func (e *Editor) Page(index int) (*PageContent, error) {
if err != nil {
return nil, err
}
- data, err := encodeOFDXML(func(x *ofdXML) { x.page(*page) })
- if err != nil {
- return nil, err
- }
- var result PageContent
- if err := xml.Unmarshal(data, &result); err != nil {
- return nil, err
- }
- result.ID = page.ID
- for i, layer := range page.Content.Layer {
- for j, object := range layer.Objects {
- result.Content.Layer[i].Objects[j].TextObject.layout = object.TextObject.layout
+ result := cloneEditorData(*page)
+ result.XMLName = xml.Name{Space: ofdNamespace, Local: "Page"}
+ for i := range result.Content.Layer {
+ layer := &result.Content.Layer[i]
+ layer.TextObject, layer.PathObject, layer.ImageObject, layer.CompositeGraphicUnit = nil, nil, nil, nil
+ for _, object := range layer.Objects {
+ switch object.Type {
+ case "TextObject":
+ object.TextObject.layout = nil
+ layer.TextObject = append(layer.TextObject, object.TextObject)
+ case "PathObject":
+ layer.PathObject = append(layer.PathObject, object.PathObject)
+ case "ImageObject":
+ layer.ImageObject = append(layer.ImageObject, object.ImageObject)
+ case "CompositeObject", "CompositeGraphicUnit":
+ layer.CompositeGraphicUnit = append(layer.CompositeGraphicUnit, object.CompositeGraphicUnit)
+ }
}
}
return &result, nil
@@ -120,6 +125,11 @@ func (e *Editor) page(index int) (*PageContent, error) {
if index < 0 || index >= len(e.pages) {
return nil, fmt.Errorf("page index %d out of range", index)
}
+ if e.source != nil {
+ if err := e.loadSourcePage(index); err != nil {
+ return nil, err
+ }
+ }
return &e.pages[index], nil
}
@@ -130,6 +140,9 @@ func (e *Editor) AddPage(width, height float64) (int, error) {
if !finite(width) || !finite(height) || width <= 0 || height <= 0 {
return 0, fmt.Errorf("page dimensions must be finite and positive")
}
+ if err := e.prepareSourceIDs(); err != nil {
+ return 0, err
+ }
e.pages = append(e.pages, PageContent{
ID: e.nextID(),
Area: PageArea{PhysicalBox: fmt.Sprintf("0 0 %s %s", ofdNumber(width), ofdNumber(height))},
@@ -145,6 +158,9 @@ func (e *Editor) AddPage(width, height float64) (int, error) {
// 入参: index 原页面索引
// 返回: int 副本页面索引, error 错误信息
func (e *Editor) CopyPage(index int) (int, error) {
+ if e.originalPage(index) {
+ return 0, fmt.Errorf("copying imported pages is not supported")
+ }
source, err := e.page(index)
if err != nil {
return 0, err
@@ -165,7 +181,11 @@ func (e *Editor) CopyPage(index int) (int, error) {
layer.ID = e.nextID()
for j := range layer.Objects {
object := &layer.Objects[j]
+ origin := e.objectOrigin(editorObjectID(*object))
id := e.nextID()
+ if origin != nil {
+ e.source.origins[id] = origin
+ }
switch object.Type {
case "TextObject":
object.TextObject.ID = id
@@ -186,6 +206,9 @@ func (e *Editor) CopyPage(index int) (int, error) {
// 入参: index 页面索引
// 返回: error 错误信息
func (e *Editor) DeletePage(index int) error {
+ if e.originalPage(index) {
+ return fmt.Errorf("deleting imported pages is not supported")
+ }
page, err := e.page(index)
if err != nil {
return err
@@ -282,13 +305,16 @@ func (e *Editor) AddFont(file FontFile, index int) (string, error) {
if name == "" {
return "", fmt.Errorf("font has no name")
}
+ if err := e.prepareSourceIDs(); err != nil {
+ return "", err
+ }
id := e.nextID()
extension := ".ttf"
if sfnt.IsCFF {
extension = ".otf"
}
resource := editorResource{
- name: "Doc_0/Res/Font_" + id + extension,
+ name: e.resourceDirectory() + "/Font_" + id + extension,
data: data,
font: &Font{
ID: id, FontName: name,
@@ -299,7 +325,7 @@ func (e *Editor) AddFont(file FontFile, index int) (string, error) {
FixedWidth: sfnt.Post.IsFixedPitch != 0,
},
}
- resource.font.FontFile = strings.TrimPrefix(resource.name, "Doc_0/Res/")
+ resource.font.FontFile = "Font_" + id + extension
e.resources = append(e.resources, resource)
e.fonts[id] = sfnt
e.resourceID[key] = id
@@ -335,6 +361,9 @@ func (e *Editor) AddImage(data []byte) (string, error) {
if id, ok := e.resourceID[key]; ok {
return id, nil
}
+ if err := e.prepareSourceIDs(); err != nil {
+ return "", err
+ }
id := e.nextID()
extension := format
if extension == "jpeg" {
@@ -342,7 +371,7 @@ func (e *Editor) AddImage(data []byte) (string, error) {
}
name := "Image_" + id + "." + extension
e.resources = append(e.resources, editorResource{
- name: "Doc_0/Res/" + name,
+ name: e.resourceDirectory() + "/" + name,
data: bytes.Clone(data),
image: &MultiMedia{ID: id, Type: "Image", Format: strings.ToUpper(format), MediaFile: name},
})
@@ -356,29 +385,11 @@ func (e *Editor) AddImage(data []byte) (string, error) {
// 入参: page 页面索引, object 对象内容,添加后不再引用调用方的可变数据
// 返回: string 对象标识, error 错误信息
func (e *Editor) AddObject(page int, object GraphicObject) (string, error) {
- content, err := e.page(page)
+ ids, err := e.CopyObjects(page, []GraphicObject{object}, 0, 0)
if err != nil {
return "", err
}
- id := strconv.Itoa(e.maxID + 1)
- object, err = e.prepareObject(id, object)
- if err != nil {
- return "", err
- }
- e.maxID++
- content.Content.Layer[0].Objects = append(content.Content.Layer[0].Objects, object)
- if change := e.recordChange(); change != nil {
- index := len(content.Content.Layer[0].Objects) - 1
- change.undo = func(e *Editor) {
- layer := &e.pages[page].Content.Layer[0]
- layer.Objects = slices.Delete(layer.Objects, index, index+1)
- }
- change.redo = func(e *Editor) {
- layer := &e.pages[page].Content.Layer[0]
- layer.Objects = slices.Insert(layer.Objects, index, object)
- }
- }
- return id, nil
+ return ids[0], nil
}
// Object 获取对象的独立副本,修改副本不影响文档
@@ -396,39 +407,24 @@ func (e *Editor) Object(page int, id string) (GraphicObject, error) {
// 入参: page 页面索引, id 对象标识, object 新内容,忽略其ID且不引用调用方的可变数据
// 返回: error 错误信息
func (e *Editor) UpdateObject(page int, id string, object GraphicObject) error {
- _, index, err := e.findObject(page, id)
- if err != nil {
- return err
+ switch object.Type {
+ case "TextObject":
+ object.TextObject.ID = id
+ case "PathObject":
+ object.PathObject.ID = id
+ case "ImageObject":
+ object.ImageObject.ID = id
+ default:
+ return fmt.Errorf("unsupported object type %q", object.Type)
}
- object, err = e.prepareObject(id, object)
- if err != nil {
- return err
- }
- e.replaceObject(page, index, object)
- return nil
+ return e.UpdateObjects(page, []GraphicObject{object})
}
// DeleteObject 删除对象,不回收资源或复用标识
// 入参: page 页面索引, id 对象标识
// 返回: error 错误信息
func (e *Editor) DeleteObject(page int, id string) error {
- layer, index, err := e.findObject(page, id)
- if err != nil {
- return err
- }
- if change := e.recordChange(); change != nil {
- object := layer.Objects[index]
- change.undo = func(e *Editor) {
- layer := &e.pages[page].Content.Layer[0]
- layer.Objects = slices.Insert(layer.Objects, index, object)
- }
- change.redo = func(e *Editor) {
- layer := &e.pages[page].Content.Layer[0]
- layer.Objects = slices.Delete(layer.Objects, index, index+1)
- }
- }
- layer.Objects = slices.Delete(layer.Objects, index, index+1)
- return nil
+ return e.DeleteObjects(page, []string{id})
}
// MoveObject 调整同页正文图层内的绘制顺序,保留对象标识和内容
@@ -445,30 +441,24 @@ func (e *Editor) MoveObject(page int, id string, to int) error {
if from == to {
return nil
}
- moveEditorItem(layer.Objects, from, to)
- if change := e.recordChange(); change != nil {
- change.undo = func(e *Editor) { moveEditorItem(e.pages[page].Content.Layer[0].Objects, to, from) }
- change.redo = func(e *Editor) { moveEditorItem(e.pages[page].Content.Layer[0].Objects, from, to) }
+ capability, err := e.ObjectCapabilities(page, id)
+ if err != nil {
+ return err
}
+ if !capability.Order {
+ return fmt.Errorf("object %q cannot be reordered", id)
+ }
+ layers := copyEditorPage(e.pages[page]).Content.Layer
+ for i := range layers {
+ if layers[i].ID == layer.ID {
+ moveEditorItem(layers[i].Objects, from, to)
+ }
+ }
+ e.replaceLayers(page, layers)
return nil
}
-// replaceObject 提交已校验的对象并记录前后内容
-// 入参: page 页面索引, index 对象索引, object 新内容
-func (e *Editor) replaceObject(page, index int, object GraphicObject) {
- layer := &e.pages[page].Content.Layer[0]
- before := layer.Objects[index]
- if reflect.DeepEqual(before, object) {
- return
- }
- layer.Objects[index] = object
- if change := e.recordChange(); change != nil {
- change.undo = func(e *Editor) { e.pages[page].Content.Layer[0].Objects[index] = before }
- change.redo = func(e *Editor) { e.pages[page].Content.Layer[0].Objects[index] = object }
- }
-}
-
-// findObject 查找正文图层中的对象
+// findObject 查找页面各图层中的对象
// 入参: page 页面索引, id 对象标识
// 返回: *Layer 所属图层, int 对象索引, error 错误信息
func (e *Editor) findObject(page int, id string) (*Layer, int, error) {
@@ -476,19 +466,12 @@ func (e *Editor) findObject(page int, id string) (*Layer, int, error) {
if err != nil {
return nil, 0, err
}
- layer := &content.Content.Layer[0]
- for index, object := range layer.Objects {
- var objectID string
- switch object.Type {
- case "TextObject":
- objectID = object.TextObject.ID
- case "PathObject":
- objectID = object.PathObject.ID
- case "ImageObject":
- objectID = object.ImageObject.ID
- }
- if objectID == id {
- return layer, index, nil
+ for i := range content.Content.Layer {
+ layer := &content.Content.Layer[i]
+ for index, object := range layer.Objects {
+ if editorObjectID(object) == id && id != "" {
+ return layer, index, nil
+ }
}
}
return nil, 0, fmt.Errorf("object %q not found on page %d", id, page)
@@ -506,9 +489,13 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject,
switch object.Type {
case "TextObject":
obj := &object.TextObject
+ codes := obj.TextCode
if err := e.prepareText(obj); err != nil {
return GraphicObject{}, err
}
+ if e.objectOrigin(obj.ID) != nil {
+ obj.TextCode = codes
+ }
obj.ID = id
boundary, ctm, drawParam = obj.Boundary, obj.CTM, obj.DrawParam
join = obj.Join
@@ -554,8 +541,13 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject,
fill, stroke = obj.FillColor, (*FillColor)(obj.StrokeColor)
case "ImageObject":
obj := &object.ImageObject
- if e.images[obj.ResourceID].X == 0 || (obj.ImageMask != "" && e.images[obj.ImageMask].X == 0) {
- return GraphicObject{}, fmt.Errorf("image resource not found")
+ if _, err := e.editorImage(obj.ResourceID); err != nil {
+ return GraphicObject{}, err
+ }
+ if obj.ImageMask != "" {
+ if _, err := e.editorImage(obj.ImageMask); err != nil {
+ return GraphicObject{}, err
+ }
}
if obj.Border != nil {
return GraphicObject{}, fmt.Errorf("image borders are not supported for creation")
@@ -633,30 +625,21 @@ func (e *Editor) validateImageClips(clips *Clips) error {
return nil
}
-// cloneEditorObject 通过统一序列化规则复制对象
+// cloneEditorObject 复制对象全部已解析字段,保留不可变的段落信息
// 入参: object 对象内容
// 返回: GraphicObject 独立副本, error 错误信息
func cloneEditorObject(object GraphicObject) (GraphicObject, error) {
- layout := object.TextObject.layout
- data, err := encodeOFDXML(func(x *ofdXML) { x.object(object, true) })
- if err != nil {
- return GraphicObject{}, err
- }
- object = GraphicObject{Type: object.Type}
- var target any
switch object.Type {
case "TextObject":
- target = &object.TextObject
+ return GraphicObject{Type: object.Type, TextObject: cloneEditorData(object.TextObject)}, nil
case "PathObject":
- target = &object.PathObject
+ return GraphicObject{Type: object.Type, PathObject: cloneEditorData(object.PathObject)}, nil
case "ImageObject":
- target = &object.ImageObject
+ return GraphicObject{Type: object.Type, ImageObject: cloneEditorData(object.ImageObject)}, nil
+ case "CompositeObject", "CompositeGraphicUnit":
+ return GraphicObject{Type: object.Type, CompositeGraphicUnit: cloneEditorData(object.CompositeGraphicUnit)}, nil
}
- if err := xml.Unmarshal(data, target); err != nil {
- return GraphicObject{}, err
- }
- object.TextObject.layout = layout
- return object, nil
+ return GraphicObject{}, fmt.Errorf("unsupported object type %q", object.Type)
}
// AddText 添加文字,按显式换行和嵌入字体度量定位,不自动折行或进行复杂文字塑形
@@ -691,12 +674,7 @@ func (e *Editor) UpdateText(page int, id, value, fontID string, size float64) er
if err := e.LayoutText(&object.TextObject, value, layout); err != nil {
return err
}
- object, err = e.prepareObject(id, object)
- if err != nil {
- return err
- }
- e.replaceObject(page, index, object)
- return nil
+ return e.UpdateObject(page, id, object)
}
// TransformObject 以页面原点等比缩放后平移对象,文字同步缩放字号和字距,保留原有排版
@@ -741,11 +719,16 @@ func transformEditorObject(object GraphicObject, dx, dy, scale float64) (Graphic
if scale != 1 {
object.ImageObject.Clips = transformImageClips(object.ImageObject.Clips, Matrix{a: scale, d: scale})
}
+ default:
+ return GraphicObject{}, fmt.Errorf("unsupported object type %q", object.Type)
}
box, err := ParseBox(*boundary)
if err != nil {
return GraphicObject{}, err
}
+ if object.Type == "ImageObject" && *ctm == "" && scale != 1 {
+ *ctm = Matrix{a: box.W, d: box.H}.String()
+ }
*boundary = fmt.Sprintf("%s %s %s %s", ofdNumber(box.X*scale+dx), ofdNumber(box.Y*scale+dy), ofdNumber(box.W*scale), ofdNumber(box.H*scale))
if scale != 1 && (*ctm != "" || object.Type != "TextObject") {
m := NewMatrix(*ctm)
@@ -810,8 +793,8 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
if obj.ReadDirection != 0 || obj.CharDirection != 0 {
return fmt.Errorf("automatic text layout requires horizontal text")
}
- sfnt, ok := e.fonts[obj.Font]
- if !ok {
+ sfnt, err := e.editorFont(obj.Font)
+ if err != nil {
return fmt.Errorf("font resource %q not found", obj.Font)
}
if !finite(obj.Size) || obj.Size <= 0 || !finite(obj.HScale) || obj.HScale < 0 {
@@ -877,7 +860,7 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
}
obj.TextCode = codes
obj.layout = nil
- if options != (TextLayout{}) {
+ if options != (TextLayout{}) || e.objectOrigin(obj.ID) != nil {
obj.layout = &textLayout{value: value, options: options}
}
return nil
@@ -887,7 +870,8 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
// 入参: obj 文字对象
// 返回: error 错误信息
func (e *Editor) prepareText(obj *TextObject) error {
- if e.fonts[obj.Font] == nil || !finite(obj.Size) || obj.Size <= 0 {
+ sfnt, err := e.editorFont(obj.Font)
+ if err != nil || !finite(obj.Size) || obj.Size <= 0 {
return fmt.Errorf("text requires an embedded font and a positive finite size")
}
if len(obj.TextCode) == 0 {
@@ -928,7 +912,7 @@ func (e *Editor) prepareText(obj *TextObject) error {
code.X, code.Y = x, y
runes := textCodeRunes(code.Value)
for _, char := range runes {
- if e.fonts[obj.Font].GlyphIndex(char) == 0 {
+ if sfnt.GlyphIndex(char) == 0 {
return fmt.Errorf("font does not contain U+%04X", char)
}
}
diff --git a/ofdgo_editor_batch.go b/ofdgo_editor_batch.go
index bf0e032..3869a23 100644
--- a/ofdgo_editor_batch.go
+++ b/ofdgo_editor_batch.go
@@ -24,6 +24,22 @@ import (
"github.com/tdewolff/canvas"
)
+// editorObjectPosition 对象所属图层及层内索引。
+type editorObjectPosition struct {
+ layer int
+ index int
+}
+
+// compareEditorPosition 比较对象在页面结构中的先后顺序。
+// 入参: a、b 对象位置
+// 返回: int 比较结果
+func compareEditorPosition(a, b editorObjectPosition) int {
+ if value := cmp.Compare(a.layer, b.layer); value != 0 {
+ return value
+ }
+ return cmp.Compare(a.index, b.index)
+}
+
// Objects 按绘制顺序获取选区的独立快照,保留编辑中的段落信息。
// 入参: page 页面索引, ids 对象标识,不得重复
// 返回: []GraphicObject 独立对象副本, error 错误信息
@@ -32,10 +48,18 @@ func (e *Editor) Objects(page int, ids []string) ([]GraphicObject, error) {
if err != nil {
return nil, err
}
- slices.Sort(indexes)
+ slices.SortFunc(indexes, compareEditorPosition)
objects := make([]GraphicObject, len(indexes))
for i, index := range indexes {
- objects[i], err = cloneEditorObject(e.pages[page].Content.Layer[0].Objects[index])
+ object := e.pages[page].Content.Layer[index.layer].Objects[index.index]
+ capability, err := e.ObjectCapabilities(page, editorObjectID(object))
+ if err != nil {
+ return nil, err
+ }
+ if !capability.Copy {
+ return nil, fmt.Errorf("object %q cannot be copied: %s", editorObjectID(object), capability.Reason)
+ }
+ objects[i], err = cloneEditorObject(object)
if err != nil {
return nil, err
}
@@ -51,33 +75,60 @@ func (e *Editor) CopyObjects(page int, objects []GraphicObject, dx, dy float64)
if !finite(dx) || !finite(dy) {
return nil, fmt.Errorf("copy requires finite offsets")
}
- content, err := e.page(page)
+ _, err := e.page(page)
if err != nil {
return nil, err
}
if len(objects) == 0 {
return nil, nil
}
- before := content.Content.Layer[0].Objects
+ if !e.sourceRGB() {
+ return nil, fmt.Errorf("inserting objects requires an RGB default color space")
+ }
+ if err := e.prepareSourceIDs(); err != nil {
+ return nil, err
+ }
prepared := make([]GraphicObject, len(objects))
result := make([]string, len(objects))
for i, object := range objects {
+ origin := e.objectOrigin(editorObjectID(object))
result[i] = strconv.Itoa(e.maxID + i + 1)
object, err := e.prepareObject(result[i], object)
if err != nil {
return nil, err
}
- object, err = transformEditorObject(object, dx, dy, 1)
- if err != nil {
- return nil, err
- }
- prepared[i], err = e.prepareObject(result[i], object)
- if err != nil {
- return nil, err
+ if dx != 0 || dy != 0 {
+ object, err = transformEditorObject(object, dx, dy, 1)
+ if err != nil {
+ return nil, err
+ }
+ codes := object.TextObject.TextCode
+ object, err = e.prepareObject(result[i], object)
+ if err != nil {
+ return nil, err
+ }
+ if origin != nil {
+ object.TextObject.TextCode = codes
+ }
}
+ prepared[i] = object
}
e.maxID += len(objects)
- e.replaceObjects(page, append(slices.Clone(before), prepared...))
+ for i, object := range objects {
+ if origin := e.objectOrigin(editorObjectID(object)); origin != nil {
+ e.source.origins[result[i]] = origin
+ }
+ }
+ layers := copyEditorPage(e.pages[page]).Content.Layer
+ target := 0
+ if e.originalPage(page) {
+ target = len(e.source.pages[e.pages[page].ID].original.Content.Layer)
+ }
+ if target == len(layers) {
+ layers = append(layers, Layer{ID: e.nextID(), Type: "Body"})
+ }
+ layers[target].Objects = append(layers[target].Objects, prepared...)
+ e.replaceLayers(page, layers)
return result, nil
}
@@ -92,10 +143,20 @@ func (e *Editor) OrderObjects(page int, ids []string, order string) error {
if err != nil || len(indexes) == 0 {
return err
}
- objects := slices.Clone(e.pages[page].Content.Layer[0].Objects)
+ for i, id := range ids {
+ capability, err := e.ObjectCapabilities(page, id)
+ if err != nil {
+ return err
+ }
+ if !capability.Order || indexes[i].layer != indexes[0].layer {
+ return fmt.Errorf("ordering requires editable objects in the same layer")
+ }
+ }
+ layers := copyEditorPage(e.pages[page]).Content.Layer
+ objects := layers[indexes[0].layer].Objects
selected := make([]bool, len(objects))
for _, index := range indexes {
- selected[index] = true
+ selected[index.index] = true
}
switch order {
case "up":
@@ -123,7 +184,8 @@ func (e *Editor) OrderObjects(page int, ids []string, order string) error {
}
objects = ordered
}
- e.replaceObjects(page, objects)
+ layers[indexes[0].layer].Objects = objects
+ e.replaceLayers(page, layers)
return nil
}
@@ -158,7 +220,7 @@ func (e *Editor) DistributeObjects(page int, ids []string, axis string) error {
if c := cmp.Compare(positions[i], positions[j]); c != 0 {
return c
}
- return cmp.Compare(indexes[i], indexes[j])
+ return compareEditorPosition(indexes[i], indexes[j])
})
first, last := order[0], order[len(order)-1]
gap := (positions[last] + sizes[last] - positions[first] - total) / float64(len(order)-1)
@@ -167,7 +229,7 @@ func (e *Editor) DistributeObjects(page int, ids []string, axis string) error {
if canvas.Equal(step, 0) {
step = 0
}
- if step < 0 || step == 0 && indexes[index] > indexes[order[i+1]] {
+ if step < 0 || step == 0 && compareEditorPosition(indexes[index], indexes[order[i+1]]) > 0 {
return fmt.Errorf("overlap prevents ordered equal-gap distribution")
}
}
@@ -193,21 +255,24 @@ func (e *Editor) DistributeObjects(page int, ids []string, axis string) error {
}
updates = append(updates, object)
}
- return e.UpdateObjects(page, updates)
+ return e.updateObjects(page, updates, true)
}
-// replaceObjects 替换正文对象列表,隔离历史快照与后续的增删、排序操作。
-// 入参: page 页面索引, objects 新的正文对象列表
-func (e *Editor) replaceObjects(page int, objects []GraphicObject) {
- before := e.pages[page].Content.Layer[0].Objects
- if reflect.DeepEqual(before, objects) {
+// replaceLayers 替换图层容器,隔离历史快照与后续的增删、排序操作。
+// 入参: page 页面索引, layers 新图层列表
+func (e *Editor) replaceLayers(page int, layers []Layer) {
+ before := copyEditorPage(e.pages[page]).Content.Layer
+ if reflect.DeepEqual(before, layers) {
return
}
- before, after := slices.Clone(before), slices.Clone(objects)
- e.pages[page].Content.Layer[0].Objects = slices.Clone(after)
+ after := copyEditorPage(PageContent{Content: Content{Layer: layers}}).Content.Layer
+ apply := func(e *Editor, layers []Layer) {
+ e.pages[page].Content.Layer = copyEditorPage(PageContent{Content: Content{Layer: layers}}).Content.Layer
+ }
+ apply(e, after)
if change := e.recordChange(); change != nil {
- change.undo = func(e *Editor) { e.pages[page].Content.Layer[0].Objects = slices.Clone(before) }
- change.redo = func(e *Editor) { e.pages[page].Content.Layer[0].Objects = slices.Clone(after) }
+ change.undo = func(e *Editor) { apply(e, before) }
+ change.redo = func(e *Editor) { apply(e, after) }
}
}
@@ -215,6 +280,13 @@ func (e *Editor) replaceObjects(page int, objects []GraphicObject) {
// 入参: page 页面索引, objects 新对象内容,ID不得重复
// 返回: error 错误信息
func (e *Editor) UpdateObjects(page int, objects []GraphicObject) error {
+ return e.updateObjects(page, objects, false)
+}
+
+// updateObjects 校验内容更新或纯几何变换,原子提交跨图层选区。
+// 入参: page 页面索引, objects 新对象, geometry 是否保留原内容的几何操作
+// 返回: error 错误信息
+func (e *Editor) updateObjects(page int, objects []GraphicObject, geometry bool) error {
ids := make([]string, len(objects))
for i, object := range objects {
ids[i] = editorObjectID(object)
@@ -225,9 +297,25 @@ func (e *Editor) UpdateObjects(page int, objects []GraphicObject) error {
}
after := make([]GraphicObject, len(objects))
for i, object := range objects {
- after[i], err = e.prepareObject(ids[i], object)
- if err != nil {
- return err
+ if e.originalPage(page) {
+ capability, err := e.ObjectCapabilities(page, ids[i])
+ if err != nil {
+ return err
+ }
+ if !capability.Transform || !geometry && !e.sourceRGB() {
+ return fmt.Errorf("object %q is read-only for this operation: %s", ids[i], capability.Reason)
+ }
+ }
+ if geometry && e.originalPage(page) {
+ if err := validateEditorGeometry(object); err != nil {
+ return err
+ }
+ after[i] = cloneEditorData(object)
+ } else {
+ after[i], err = e.prepareObject(ids[i], object)
+ if err != nil {
+ return err
+ }
}
}
if reflect.DeepEqual(before, after) {
@@ -235,7 +323,7 @@ func (e *Editor) UpdateObjects(page int, objects []GraphicObject) error {
}
apply := func(e *Editor, objects []GraphicObject) {
for i, index := range indexes {
- e.pages[page].Content.Layer[0].Objects[index] = objects[i]
+ e.pages[page].Content.Layer[index.layer].Objects[index.index] = objects[i]
}
}
apply(e, after)
@@ -261,6 +349,11 @@ func (e *Editor) TransformObjects(page int, ids []string, dx, dy, scale float64)
return nil
}
for i, object := range objects {
+ if e.originalPage(page) {
+ if err := validateEditorGeometry(object); err != nil {
+ return err
+ }
+ }
object, err = cloneEditorObject(object)
if err != nil {
return err
@@ -269,8 +362,11 @@ func (e *Editor) TransformObjects(page int, ids []string, dx, dy, scale float64)
if err != nil {
return err
}
+ if origin := e.objectOrigin(editorObjectID(object)); origin != nil && origin.node.attr("LineWidth") == "0" && object.Type == "TextObject" && object.TextObject.LineWidth == 0 {
+ objects[i].TextObject.LineWidth = 0
+ }
}
- return e.UpdateObjects(page, objects)
+ return e.updateObjects(page, objects, true)
}
// AlignObjects 单对象对齐页面,多对象相互对齐至选区边界,文字采用实际字形范围。
@@ -320,7 +416,7 @@ func (e *Editor) AlignObjects(page int, ids []string, alignment string) error {
return err
}
}
- return e.UpdateObjects(page, objects)
+ return e.updateObjects(page, objects, true)
}
// DeleteObjects 原子删除同页对象,保留其余对象顺序,一次撤销恢复全部。
@@ -331,25 +427,34 @@ func (e *Editor) DeleteObjects(page int, ids []string) error {
if err != nil || len(indexes) == 0 {
return err
}
- before := e.pages[page].Content.Layer[0].Objects
- after := slices.Clone(before)
- slices.Sort(indexes)
- for _, index := range slices.Backward(indexes) {
- after = slices.Delete(after, index, index+1)
+ for _, id := range ids {
+ capability, err := e.ObjectCapabilities(page, id)
+ if err != nil {
+ return err
+ }
+ if !capability.Delete {
+ return fmt.Errorf("object %q cannot be deleted: %s", id, capability.Reason)
+ }
}
- e.replaceObjects(page, after)
+ layers := copyEditorPage(e.pages[page]).Content.Layer
+ slices.SortFunc(indexes, compareEditorPosition)
+ for _, index := range slices.Backward(indexes) {
+ layer := &layers[index.layer]
+ layer.Objects = slices.Delete(layer.Objects, index.index, index.index+1)
+ }
+ e.replaceLayers(page, layers)
return nil
}
// selectedObjects 校验同页选择,返回内部只读对象与对应索引。
// 入参: page 页面索引, ids 对象标识,不得重复
-// 返回: []GraphicObject 所选对象, []int 正文对象索引, error 错误信息
-func (e *Editor) selectedObjects(page int, ids []string) ([]GraphicObject, []int, error) {
+// 返回: []GraphicObject 所选对象, []editorObjectPosition 图层和对象索引, error 错误信息
+func (e *Editor) selectedObjects(page int, ids []string) ([]GraphicObject, []editorObjectPosition, error) {
if _, err := e.page(page); err != nil {
return nil, nil, err
}
objects := make([]GraphicObject, len(ids))
- indexes := make([]int, len(ids))
+ indexes := make([]editorObjectPosition, len(ids))
seen := make(map[string]bool, len(ids))
for i, id := range ids {
if seen[id] {
@@ -360,7 +465,13 @@ func (e *Editor) selectedObjects(page int, ids []string) ([]GraphicObject, []int
if err != nil {
return nil, nil, err
}
- objects[i], indexes[i] = layer.Objects[index], index
+ for j := range e.pages[page].Content.Layer {
+ if &e.pages[page].Content.Layer[j] == layer {
+ indexes[i] = editorObjectPosition{j, index}
+ break
+ }
+ }
+ objects[i] = layer.Objects[index]
}
return objects, indexes, nil
}
@@ -376,6 +487,8 @@ func editorObjectID(object GraphicObject) string {
return object.PathObject.ID
case "ImageObject":
return object.ImageObject.ID
+ case "CompositeObject", "CompositeGraphicUnit":
+ return object.CompositeGraphicUnit.ID
}
return ""
}
@@ -384,6 +497,15 @@ func editorObjectID(object GraphicObject) string {
// 入参: page 页面索引, objects 待度量的对象
// 返回: []Box 对象范围,文字采用字形范围,其余采用Boundary, error 错误信息
func (e *Editor) objectBounds(page int, objects []GraphicObject) ([]Box, error) {
+ for _, object := range objects {
+ capability, err := e.ObjectCapabilities(page, editorObjectID(object))
+ if err != nil {
+ return nil, err
+ }
+ if !capability.Arrange {
+ return nil, fmt.Errorf("object %q cannot be arranged: %s", editorObjectID(object), capability.Reason)
+ }
+ }
textBoxes := make(map[string]Box)
if slices.ContainsFunc(objects, func(o GraphicObject) bool { return o.Type == "TextObject" }) {
reader, err := e.Reader()
diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go
new file mode 100644
index 0000000..7d25e2b
--- /dev/null
+++ b/ofdgo_editor_open.go
@@ -0,0 +1,548 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ofdgo
+
+import (
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "image"
+ "io"
+ "path"
+ "reflect"
+ "strconv"
+ "strings"
+
+ "github.com/tdewolff/font"
+)
+
+// editorSource 保留输入包及按需加载的原始页面。
+type editorSource struct {
+ reader *Reader
+ document *Document
+ info DocInfo
+ directory string
+ pages map[string]*editorSourcePage
+ origins map[string]*editorObjectOrigin
+ idsReady bool
+}
+
+// editorObjectOrigin 保留原对象的XML语义,供复制和局部更新复用。
+type editorObjectOrigin struct {
+ page *editorSourcePage
+ node *editorXML
+ object GraphicObject
+}
+
+// editorSourcePage 保存页面原文、解析快照及可定位的直接对象。
+type editorSourcePage struct {
+ ref Page
+ data []byte
+ root *editorXML
+ original *PageContent
+ nodes map[string]*editorXML
+}
+
+// ObjectCapabilities 已有对象可执行的操作,Reason说明受限原因。
+// Update表示完整替换内容,Transform表示保留文字定位的几何变换,Order仅限当前图层。
+// Arrange表示可准确度量对齐与旋转范围;Reflow表示可重排文字,LayoutKnown表示段落选项可恢复。
+type ObjectCapabilities struct {
+ Update bool
+ Reflow bool
+ LayoutKnown bool
+ Transform bool
+ Arrange bool
+ Copy bool
+ Delete bool
+ Order bool
+ Reason string
+}
+
+// PageCapabilities 页面可执行的操作,Insert表示可以添加RGB对象;新增空白页面始终可用。
+type PageCapabilities struct {
+ Insert bool
+ Copy bool
+ Delete bool
+ Move bool
+ Resize bool
+}
+
+// Editor 打开主文档进行保真编辑,不修改输入包,不以权限声明或签名限制编辑。
+// 原权限和签名数据保留,修改受保护内容会使原签名失效,不重新签名。
+// 页面按需解析,未修改条目直接保留;编辑器及其Reader快照使用期间不得关闭输入Reader。
+// 返回: *Editor 编辑器, error 错误信息
+func (r *Reader) Editor() (*Editor, error) {
+ if r.Zip != nil {
+ seen := make(map[string]bool)
+ for _, file := range r.Zip.File {
+ name := cleanPackagePath(file.Name)
+ if seen[name] {
+ return nil, fmt.Errorf("ambiguous package entry %q", file.Name)
+ }
+ seen[name] = true
+ }
+ }
+ reader := &Reader{Zip: r.Zip, files: r.files}
+ if err := reader.initRoot(); err != nil {
+ return nil, err
+ }
+ doc, err := reader.Doc()
+ if err != nil {
+ return nil, err
+ }
+ maximum := doc.CommonData.MaxUnitID
+ if maximum < 0 {
+ return nil, fmt.Errorf("invalid MaxUnitID")
+ }
+ info, err := reader.DocInfo()
+ if err != nil {
+ return nil, err
+ }
+ e := NewEditor()
+ e.Info, e.maxID = cloneEditorData(*info), maximum
+ directory := path.Join(reader.RootDir, "Edit")
+ for i := 1; editorDirectoryExists(reader, directory); i++ {
+ directory = path.Join(reader.RootDir, "Edit_"+strconv.Itoa(i))
+ }
+ e.source = &editorSource{reader: reader, document: doc, info: cloneEditorData(*info), directory: directory, pages: make(map[string]*editorSourcePage), origins: make(map[string]*editorObjectOrigin)}
+ for _, page := range doc.Pages.Page {
+ if page.ID == "" || e.source.pages[page.ID] != nil {
+ return nil, fmt.Errorf("missing or duplicate page ID %q", page.ID)
+ }
+ e.source.pages[page.ID] = &editorSourcePage{ref: page}
+ e.pages = append(e.pages, PageContent{ID: page.ID})
+ }
+ return e, nil
+}
+
+// editorDirectoryExists 判断包内目录是否已经使用,避免覆盖原始资源。
+// 入参: reader 输入包, directory 目录路径
+// 返回: bool 是否存在
+func editorDirectoryExists(reader *Reader, directory string) bool {
+ for name := range reader.fileIndex {
+ if strings.EqualFold(name, directory) || strings.HasPrefix(strings.ToLower(name), strings.ToLower(directory)+"/") {
+ return true
+ }
+ }
+ for name := range reader.files {
+ if strings.EqualFold(name, directory) || strings.HasPrefix(strings.ToLower(name), strings.ToLower(directory)+"/") {
+ return true
+ }
+ }
+ return false
+}
+
+// prepareSourceIDs 首次新增内容前流式核对原包标识,不依赖可能缺失或过期的MaxUnitID。
+// 返回: error 错误信息
+func (e *Editor) prepareSourceIDs() error {
+ if e.source == nil || e.source.idsReady {
+ return nil
+ }
+ reader := e.source.reader
+ names := make(map[string]bool)
+ for name := range reader.fileIndex {
+ names[name] = true
+ }
+ for name := range reader.files {
+ names[name] = true
+ }
+ maximum := e.maxID
+ for name := range names {
+ if !strings.EqualFold(path.Ext(name), ".xml") {
+ continue
+ }
+ input, err := reader.openFile(name)
+ if err != nil {
+ return err
+ }
+ id, err := editorXMLMaxID(input)
+ input.Close()
+ if err != nil {
+ return fmt.Errorf("scan IDs in %s: %w", name, err)
+ }
+ maximum = max(maximum, id)
+ }
+ e.maxID, e.source.idsReady = maximum, true
+ return nil
+}
+
+// editorXMLMaxID 流式读取OFD结构中的最大标识,跳过非OFD的XML附件。
+// 入参: input XML输入流
+// 返回: int 最大标识, error 错误信息
+func editorXMLMaxID(input io.Reader) (int, error) {
+ decoder := xml.NewDecoder(input)
+ maximum, started := 0, false
+ for {
+ token, err := decoder.Token()
+ if err == io.EOF || err != nil && !started {
+ return maximum, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+ node, ok := token.(xml.StartElement)
+ if !ok {
+ continue
+ }
+ if !started {
+ switch node.Name.Local {
+ case "OFD", "Document", "Page", "Res", "PageAnnot", "Annotations", "Attachments", "CustomTags":
+ default:
+ return 0, nil
+ }
+ started = true
+ }
+ for _, attr := range node.Attr {
+ if attr.Name.Space == "" && attr.Name.Local == "ID" {
+ id, err := strconv.Atoi(attr.Value)
+ if err == nil {
+ maximum = max(maximum, id)
+ }
+ }
+ }
+ }
+}
+
+// resourceDirectory 获取新增二进制资源的目录。
+// 返回: string 包内路径
+func (e *Editor) resourceDirectory() string {
+ if e.source != nil {
+ return e.source.directory + "/Res"
+ }
+ return "Doc_0/Res"
+}
+
+// originalPage 判断索引是否指向原文档页面。
+// 入参: index 页面索引
+// 返回: bool 是否为原始页面
+func (e *Editor) originalPage(index int) bool {
+ return e.source != nil && index >= 0 && index < len(e.pages) && e.source.pages[e.pages[index].ID] != nil
+}
+
+// PageCapabilities 获取页面操作范围;原始页面暂不复制、删除,避免破坏模板与外部引用。
+// 入参: index 页面索引
+// 返回: PageCapabilities 操作能力, error 错误信息
+func (e *Editor) PageCapabilities(index int) (PageCapabilities, error) {
+ if index < 0 || index >= len(e.pages) {
+ return PageCapabilities{}, fmt.Errorf("page index %d out of range", index)
+ }
+ original := e.originalPage(index)
+ return PageCapabilities{Insert: e.sourceRGB(), Copy: !original, Delete: !original, Move: true, Resize: true}, nil
+}
+
+// sourceRGB 判断新建RGB对象能否直接使用文档默认颜色空间。
+// 返回: bool 是否为8位RGB文档
+func (e *Editor) sourceRGB() bool {
+ if e.source == nil || e.source.document.CommonData.DefaultCS == 0 {
+ return true
+ }
+ space := e.source.reader.colorSpaceCache[strconv.Itoa(e.source.document.CommonData.DefaultCS)]
+ return space != nil && space.Type == "RGB" && (space.BitsPerComponent == 0 || space.BitsPerComponent == 8) && len(space.Palette) == 0
+}
+
+// loadSourcePage 首次访问时解析原始页面,不加载图片或字体二进制数据。
+// 入参: index 页面索引
+// 返回: error 错误信息
+func (e *Editor) loadSourcePage(index int) error {
+ source := e.source.pages[e.pages[index].ID]
+ if source == nil || source.original != nil {
+ return nil
+ }
+ reader := e.source.reader
+ data, err := reader.readFile(reader.ResPath(source.ref.BaseLoc))
+ if err != nil {
+ return err
+ }
+ root, err := parseEditorXML(data)
+ if err != nil {
+ return err
+ }
+ page, err := reader.PageContent(source.ref)
+ if err != nil {
+ return err
+ }
+ nodes := make(map[string]*editorXML)
+ if content := root.child("Content"); content != nil {
+ for _, layer := range content.children {
+ if layer.name.Local != "Layer" {
+ continue
+ }
+ for _, node := range layer.children {
+ id := node.attr("ID")
+ if id != "" {
+ if nodes[id] != nil {
+ return fmt.Errorf("duplicate object ID %q", id)
+ }
+ nodes[id] = node
+ }
+ }
+ }
+ }
+ source.data, source.root, source.nodes = data, root, nodes
+ source.original = page
+ for _, layer := range page.Content.Layer {
+ for _, object := range layer.Objects {
+ id := editorObjectID(object)
+ if node := nodes[id]; node != nil {
+ e.source.origins[id] = &editorObjectOrigin{page: source, node: node, object: object}
+ }
+ }
+ }
+ e.pages[index] = copyEditorPage(*page)
+ return nil
+}
+
+// ObjectCapabilities 获取对象可执行的操作,不支持的内容保持原文,不强制转换。
+// 字体缺失或子集不包含原文时仍可移动、删除,不允许重新排版或复制。
+// 显式提供可用字体后可通过UpdateObject替换;原文布局未知时不自动推断段落选项。
+// 入参: page 页面索引, id 对象标识
+// 返回: ObjectCapabilities 操作能力, error 错误信息
+func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, error) {
+ layer, index, err := e.findObject(page, id)
+ if err != nil {
+ return ObjectCapabilities{}, err
+ }
+ object := layer.Objects[index]
+ all := ObjectCapabilities{Update: true, Reflow: object.Type == "TextObject" && object.TextObject.ReadDirection == 0 && object.TextObject.CharDirection == 0, LayoutKnown: object.Type == "TextObject" && (e.objectOrigin(id) == nil || object.TextObject.layout != nil), Transform: true, Arrange: true, Copy: true, Delete: true, Order: true}
+ if !e.originalPage(page) {
+ return all, nil
+ }
+ source := e.source.pages[e.pages[page].ID]
+ node := source.nodes[id]
+ if node == nil {
+ for _, original := range source.original.Content.Layer {
+ for _, object := range original.Objects {
+ if editorObjectID(object) == id {
+ return ObjectCapabilities{Reason: "nested objects are read-only"}, nil
+ }
+ }
+ }
+ return all, nil
+ }
+ if layer.DrawParam != "" || !editorXMLSupported(node) {
+ return ObjectCapabilities{Reason: "object uses unsupported editing features"}, nil
+ }
+ for _, attr := range node.parent.attrs {
+ if attr.Name.Space == "xmlns" || attr.Name.Space == "" && attr.Name.Local == "xmlns" {
+ continue
+ }
+ if attr.Name.Space != "" || attr.Name.Local != "ID" && attr.Name.Local != "Type" && attr.Name.Local != "DrawParam" {
+ return ObjectCapabilities{Reason: "layer uses unsupported editing features"}, nil
+ }
+ }
+ if err := validateEditorGeometry(object); err != nil {
+ return ObjectCapabilities{Reason: err.Error()}, nil
+ }
+ all.Order = e.sourceLayerOrderable(page, layer.ID)
+ if _, err := e.prepareObject(id, object); err != nil {
+ all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, err.Error()
+ }
+ if !e.sourceRGB() {
+ all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, "document uses a non-RGB default color space"
+ }
+ if object.Type == "TextObject" {
+ all.Arrange = all.Update
+ if _, err := object.TextObject.TextFrame(); err != nil {
+ all.Reflow = false
+ }
+ }
+ return all, nil
+}
+
+// objectOrigin 获取原对象或其副本的保真来源。
+// 入参: id 对象标识
+// 返回: *editorObjectOrigin 原始对象,无来源时为nil
+func (e *Editor) objectOrigin(id string) *editorObjectOrigin {
+ if e.source == nil {
+ return nil
+ }
+ return e.source.origins[id]
+}
+
+// sourceLayerOrderable 判断图层是否仅含可独立排序的直接对象。
+// 入参: page 页面索引, id 图层标识
+// 返回: bool 是否可排序
+func (e *Editor) sourceLayerOrderable(page int, id string) bool {
+ if !e.originalPage(page) {
+ return true
+ }
+ content := e.source.pages[e.pages[page].ID].root.child("Content")
+ if content != nil {
+ for _, layer := range content.children {
+ if layer.attr("ID") == id {
+ for _, child := range layer.children {
+ if !editorXMLSupported(child) {
+ return false
+ }
+ }
+ }
+ }
+ }
+ return true
+}
+
+// editorGeometry 获取基本对象的边界与变换。
+// 入参: object 对象
+// 返回: string 边界, string 变换矩阵
+func editorGeometry(object GraphicObject) (string, string) {
+ switch object.Type {
+ case "TextObject":
+ return object.TextObject.Boundary, object.TextObject.CTM
+ case "PathObject":
+ return object.PathObject.Boundary, object.PathObject.CTM
+ case "ImageObject":
+ return object.ImageObject.Boundary, object.ImageObject.CTM
+ }
+ return "", ""
+}
+
+// editorFont 按需解析原文档内嵌字体,不用系统回退替换原字体。
+// 入参: id 字体资源标识
+// 返回: *font.SFNT 字体, error 错误信息
+func (e *Editor) editorFont(id string) (*font.SFNT, error) {
+ if sfnt := e.fonts[id]; sfnt != nil {
+ return sfnt, nil
+ }
+ if e.source != nil {
+ reader := e.source.reader
+ if f := reader.fontCache[id]; f != nil && f.FontFile != "" {
+ data, err := reader.ResData(f.FontFile)
+ if err != nil {
+ return nil, err
+ }
+ data, err = font.ToSFNT(data)
+ if err != nil {
+ return nil, err
+ }
+ sfnt, err := font.ParseSFNT(data, 0)
+ if err != nil {
+ return nil, err
+ }
+ e.fonts[id] = sfnt
+ return sfnt, nil
+ }
+ }
+ return nil, fmt.Errorf("embedded font %q not found", id)
+}
+
+// editorImage 按需读取原图片尺寸,不解码完整像素。
+// 入参: id 图片资源标识
+// 返回: image.Point 图片尺寸, error 错误信息
+func (e *Editor) editorImage(id string) (image.Point, error) {
+ if size := e.images[id]; size.X > 0 {
+ return size, nil
+ }
+ if e.source == nil {
+ return image.Point{}, fmt.Errorf("image resource %q not found", id)
+ }
+ reader := e.source.reader
+ input, err := reader.openFile(reader.ResPath(reader.ResMap[id]))
+ if err != nil {
+ return image.Point{}, err
+ }
+ defer input.Close()
+ config, _, err := image.DecodeConfig(input)
+ if err != nil {
+ return image.Point{}, err
+ }
+ size := image.Pt(config.Width, config.Height)
+ e.images[id] = size
+ return size, nil
+}
+
+// cloneEditorData 深复制编辑快照,未导出的排版信息保持不可变共享。
+// 入参: value 原值
+// 返回: T 独立副本
+func cloneEditorData[T any](value T) T {
+ return cloneEditorValue(reflect.ValueOf(value)).Interface().(T)
+}
+
+// cloneEditorValue 复制结构体中的可变指针和切片。
+// 入参: value 原值
+// 返回: reflect.Value 独立副本
+func cloneEditorValue(value reflect.Value) reflect.Value {
+ switch value.Kind() {
+ case reflect.Pointer:
+ if !value.IsNil() {
+ result := reflect.New(value.Type().Elem())
+ result.Elem().Set(cloneEditorValue(value.Elem()))
+ return result
+ }
+ case reflect.Slice:
+ if !value.IsNil() {
+ result := reflect.MakeSlice(value.Type(), value.Len(), value.Len())
+ for i := range value.Len() {
+ result.Index(i).Set(cloneEditorValue(value.Index(i)))
+ }
+ return result
+ }
+ case reflect.Struct:
+ result := reflect.New(value.Type()).Elem()
+ result.Set(value)
+ for i := range value.NumField() {
+ if result.Field(i).CanSet() && value.Type().Field(i).IsExported() {
+ result.Field(i).Set(cloneEditorValue(value.Field(i)))
+ }
+ }
+ return result
+ }
+ return value
+}
+
+// editorObjectXML 编码可编辑对象,包含独立命名空间声明。
+// 入参: object 对象
+// 返回: []byte XML片段, error 错误信息
+func editorObjectXML(object GraphicObject) ([]byte, error) {
+ data, err := encodeOFDXML(func(x *ofdXML) { x.object(object, true) })
+ return bytes.TrimPrefix(data, []byte(xml.Header)), err
+}
+
+// validateEditorGeometry 校验几何操作产生的数值,不重新排版或替换字体。
+// 入参: object 变换后的对象
+// 返回: error 错误信息
+func validateEditorGeometry(object GraphicObject) error {
+ boundary, ctm := editorGeometry(object)
+ if _, err := creationBox(boundary); err != nil {
+ return err
+ }
+ if ctm != "" {
+ if _, err := creationNumbers(ctm, 6); err != nil {
+ return err
+ }
+ }
+ if object.Type == "TextObject" {
+ obj := object.TextObject
+ if !finite(obj.Size) || obj.Size <= 0 || !finite(obj.LineWidth) {
+ return fmt.Errorf("invalid text geometry")
+ }
+ for _, code := range obj.TextCode {
+ for _, value := range []string{code.X, code.Y} {
+ if value != "" {
+ if _, err := creationNumbers(value, 1); err != nil {
+ return err
+ }
+ }
+ }
+ for _, value := range []string{code.DeltaX, code.DeltaY} {
+ if value != "" {
+ if err := creationDeltas(value, len(textCodeRunes(code.Value))-1); err != nil {
+ return err
+ }
+ }
+ }
+ }
+ }
+ return nil
+}
diff --git a/ofdgo_editor_package.go b/ofdgo_editor_package.go
new file mode 100644
index 0000000..c71b0af
--- /dev/null
+++ b/ofdgo_editor_package.go
@@ -0,0 +1,450 @@
+// 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 (
+ "archive/zip"
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "maps"
+ "path"
+ "reflect"
+ "slices"
+ "strconv"
+ "strings"
+)
+
+// sourceParts 生成编辑过的XML和新增资源,不读取未修改页面或原始二进制资源。
+// 返回: map[string][]byte 替换及新增条目, error 错误信息
+func (e *Editor) sourceParts() (map[string][]byte, error) {
+ parts := make(map[string][]byte)
+ source := e.source
+ reader := source.reader
+ pageRefs := make([]Page, len(e.pages))
+ for i, page := range e.pages {
+ original := source.pages[page.ID]
+ if original != nil {
+ pageRefs[i] = original.ref
+ if original.original == nil {
+ continue
+ }
+ data, err := e.sourcePageXML(i, original)
+ if err != nil {
+ return nil, err
+ }
+ if !bytes.Equal(data, original.data) {
+ parts[reader.ResPath(original.ref.BaseLoc)] = data
+ }
+ } else {
+ name := path.Join(source.directory, "Pages", page.ID+".xml")
+ data, err := e.sourceNewPageXML(page)
+ if err != nil {
+ return nil, err
+ }
+ parts[name] = data
+ pageRefs[i] = Page{ID: page.ID, BaseLoc: "/" + name}
+ }
+ }
+ fonts, images, resourceFiles := e.usedResources()
+ commonData := source.document.CommonData
+ for _, name := range append(slices.Clone(commonData.DocumentRes), commonData.PublicRes...) {
+ resourceFiles = slices.DeleteFunc(resourceFiles, func(file string) bool {
+ return strings.EqualFold(file, reader.ResPath(name))
+ })
+ }
+ if len(fonts)+len(images) != 0 {
+ resourcePath := path.Join(source.directory, "Resources.xml")
+ resourceFiles = append(resourceFiles, resourcePath)
+ data, err := encodeOFDXML(func(x *ofdXML) { x.resources(fonts, images) })
+ if err != nil {
+ return nil, err
+ }
+ parts[resourcePath] = data
+ for _, resource := range append(fonts, images...) {
+ if resource.name != "" {
+ parts[resource.name] = resource.data
+ }
+ }
+ }
+ pagesChanged := !reflect.DeepEqual(pageRefs, source.document.Pages.Page)
+ if len(parts) != 0 || pagesChanged {
+ name := reader.ResPath(reader.OFD.DocBody[0].DocRoot)
+ data, err := reader.readFile(name)
+ if err != nil {
+ return nil, err
+ }
+ root, err := parseEditorXML(data)
+ if err != nil {
+ return nil, err
+ }
+ common := root.child("CommonData")
+ if common == nil {
+ return nil, fmt.Errorf("document has no CommonData")
+ }
+ var patches []editorXMLPatch
+ if e.maxID != source.document.CommonData.MaxUnitID {
+ value := editorXMLText("MaxUnitID", strconv.Itoa(e.maxID))
+ if maxID := common.child("MaxUnitID"); maxID != nil {
+ patches = append(patches, editorXMLPatch{maxID.start, maxID.end, value})
+ } else {
+ patches = append(patches, editorXMLPatch{common.open, common.open, value})
+ }
+ }
+ if len(resourceFiles) != 0 {
+ position := common.close
+ for _, child := range common.children {
+ if child.name.Local == "TemplatePage" || child.name.Local == "DefaultCS" {
+ position = child.start
+ break
+ }
+ }
+ var references []byte
+ for _, name := range resourceFiles {
+ references = append(references, editorXMLText("DocumentRes", "/"+name)...)
+ }
+ patches = append(patches, editorXMLPatch{position, position, references})
+ }
+ if pagesChanged {
+ pages := root.child("Pages")
+ if pages == nil {
+ return nil, fmt.Errorf("document has no Pages")
+ }
+ var encoded bytes.Buffer
+ for _, page := range pageRefs {
+ var original *editorXML
+ for _, child := range pages.children {
+ if child.attr("ID") == page.ID {
+ original = child
+ break
+ }
+ }
+ if original != nil {
+ encoded.Write(data[original.start:original.end])
+ } else {
+ item, err := encodeOFDXML(func(x *ofdXML) {
+ x.root("Page", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: page.ID}, {Name: xml.Name{Local: "BaseLoc"}, Value: page.BaseLoc}})
+ x.end("Page")
+ })
+ if err != nil {
+ return nil, err
+ }
+ encoded.Write(bytes.TrimPrefix(item, []byte(xml.Header)))
+ }
+ }
+ patches = append(patches, editorXMLContent(data, pages, encoded.Bytes()))
+ }
+ updated := editorPatchXML(data, patches)
+ if !bytes.Equal(data, updated) {
+ parts[name] = updated
+ }
+ }
+ if !reflect.DeepEqual(e.Info, source.info) {
+ data, err := e.sourceInfoXML()
+ if err != nil {
+ return nil, err
+ }
+ parts["OFD.xml"] = data
+ }
+ for name, data := range maps.Clone(parts) {
+ if file, ok := reader.packageFile(name); ok {
+ actual := cleanPackagePath(file.Name)
+ if actual != name {
+ delete(parts, name)
+ parts[actual] = data
+ }
+ }
+ }
+ return parts, nil
+}
+
+// sourcePageXML 仅改写已修改的直接对象、页面尺寸和新增图层。
+// 入参: index 页面索引, source 原页面
+// 返回: []byte 页面XML, error 错误信息
+func (e *Editor) sourcePageXML(index int, source *editorSourcePage) ([]byte, error) {
+ page := e.pages[index]
+ if reflect.DeepEqual(page, *source.original) {
+ return source.data, nil
+ }
+ if source.root.open == source.root.end {
+ copy := *source
+ copy.data = editorPatchXML(source.data, []editorXMLPatch{editorXMLContent(source.data, source.root, nil)})
+ var err error
+ copy.root, err = parseEditorXML(copy.data)
+ if err != nil {
+ return nil, err
+ }
+ source = ©
+ }
+ data := source.data
+ var patches []editorXMLPatch
+ if page.Area.PhysicalBox != source.original.Area.PhysicalBox {
+ area := source.root.child("Area")
+ if area != nil {
+ areaData := data[area.start:area.end]
+ areaRoot, err := parseEditorXML(areaData)
+ if err != nil {
+ return nil, err
+ }
+ updated := editorXMLSetText(areaData, areaRoot, [][2]string{{"PhysicalBox", page.Area.PhysicalBox}})
+ patches = append(patches, editorXMLPatch{area.start, area.end, updated})
+ } else {
+ value := []byte("")
+ value = append(value, editorXMLText("PhysicalBox", page.Area.PhysicalBox)...)
+ value = append(value, []byte("")...)
+ patches = append(patches, editorXMLPatch{source.root.open, source.root.open, value})
+ }
+ }
+ for layerIndex, original := range source.original.Content.Layer {
+ current := page.Content.Layer[layerIndex]
+ before := make(map[string]GraphicObject)
+ for _, object := range original.Objects {
+ before[editorObjectID(object)] = object
+ }
+ after := make(map[string]GraphicObject)
+ var ordered []GraphicObject
+ for _, object := range current.Objects {
+ id := editorObjectID(object)
+ after[id] = object
+ if source.nodes[id] != nil {
+ ordered = append(ordered, object)
+ }
+ }
+ orderable := e.sourceLayerOrderable(index, original.ID)
+ position := 0
+ for _, object := range original.Objects {
+ id := editorObjectID(object)
+ node := source.nodes[id]
+ if node == nil {
+ continue
+ }
+ next, exists := after[id]
+ if orderable {
+ exists = position < len(ordered)
+ if exists {
+ next = ordered[position]
+ }
+ position++
+ }
+ var encoded []byte
+ if exists {
+ nextID := editorObjectID(next)
+ origin := source.nodes[nextID]
+ if reflect.DeepEqual(before[nextID], next) {
+ encoded = data[origin.start:origin.end]
+ } else {
+ var err error
+ encoded, err = editorXMLObject(data, origin, before[nextID], next)
+ if err != nil {
+ return nil, err
+ }
+ }
+ }
+ if !bytes.Equal(encoded, data[node.start:node.end]) {
+ patches = append(patches, editorXMLPatch{node.start, node.end, encoded})
+ }
+ }
+ }
+ var added []byte
+ for _, layer := range page.Content.Layer[len(source.original.Content.Layer):] {
+ if len(layer.Objects) == 0 {
+ continue
+ }
+ encoded, err := e.sourceLayerXML(layer)
+ if err != nil {
+ return nil, err
+ }
+ added = append(added, bytes.TrimPrefix(encoded, []byte(xml.Header))...)
+ }
+ if len(added) != 0 {
+ content := source.root.child("Content")
+ if content == nil {
+ value := []byte("")
+ value = append(value, added...)
+ value = append(value, []byte("")...)
+ patches = append(patches, editorXMLPatch{source.root.close, source.root.close, value})
+ } else if content.open == content.end {
+ patches = append(patches, editorXMLContent(data, content, added))
+ } else {
+ patches = append(patches, editorXMLPatch{content.close, content.close, added})
+ }
+ }
+ return editorPatchXML(data, patches), nil
+}
+
+// sourceLayerXML 写出新增图层,对复制对象保留原始定位和显式默认值。
+// 入参: layer 图层
+// 返回: []byte 图层XML, error 错误信息
+func (e *Editor) sourceLayerXML(layer Layer) ([]byte, error) {
+ var content []byte
+ for _, object := range layer.Objects {
+ var data []byte
+ var err error
+ if origin := e.objectOrigin(editorObjectID(object)); origin != nil {
+ data, err = editorXMLObject(origin.page.data, origin.node, origin.object, object)
+ if err == nil {
+ data, err = editorXMLStandalone(data, origin.node)
+ }
+ } else {
+ data, err = editorObjectXML(object)
+ }
+ if err != nil {
+ return nil, err
+ }
+ content = append(content, data...)
+ }
+ return editorXMLContainer("Layer", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: layer.ID}, {Name: xml.Name{Local: "Type"}, Value: layer.Type}}, content)
+}
+
+// sourceNewPageXML 编码新增页面,同时保留复制对象的原文语义。
+// 入参: page 页面
+// 返回: []byte 页面XML, error 错误信息
+func (e *Editor) sourceNewPageXML(page PageContent) ([]byte, error) {
+ area, err := editorXMLContainer("Area", nil, editorXMLText("PhysicalBox", page.Area.PhysicalBox))
+ if err != nil {
+ return nil, err
+ }
+ var layers []byte
+ for _, layer := range page.Content.Layer {
+ data, err := e.sourceLayerXML(layer)
+ if err != nil {
+ return nil, err
+ }
+ layers = append(layers, data...)
+ }
+ content, err := editorXMLContainer("Content", nil, layers)
+ if err != nil {
+ return nil, err
+ }
+ return editorXMLContainer("Page", nil, append(area, content...))
+}
+
+// sourceInfoXML 更新公开元数据,保留原创建程序和未识别字段。
+// 返回: []byte 根索引XML, error 错误信息
+func (e *Editor) sourceInfoXML() ([]byte, error) {
+ data, err := e.source.reader.readFile("OFD.xml")
+ if err != nil {
+ return nil, err
+ }
+ root, err := parseEditorXML(data)
+ if err != nil {
+ return nil, err
+ }
+ body := root.child("DocBody")
+ if body == nil {
+ return nil, fmt.Errorf("document has no DocBody")
+ }
+ info := body.child("DocInfo")
+ if info == nil {
+ return nil, fmt.Errorf("document has no DocInfo")
+ }
+ before, after := e.source.info, e.Info
+ if !reflect.DeepEqual(before.CustomDatas, after.CustomDatas) {
+ return nil, fmt.Errorf("editing imported custom metadata is not supported")
+ }
+ var values [][2]string
+ for _, field := range [][3]string{
+ {"DocID", before.DocID, after.DocID}, {"Title", before.Title, after.Title}, {"Author", before.Author, after.Author},
+ {"Subject", before.Subject, after.Subject}, {"Abstract", before.Abstract, after.Abstract},
+ {"CreationDate", before.CreationDate, after.CreationDate}, {"ModDate", before.ModDate, after.ModDate},
+ } {
+ if field[1] != field[2] {
+ values = append(values, [2]string{field[0], field[2]})
+ }
+ }
+ return editorXMLSetText(data, info, values), nil
+}
+
+// writeSource 将未修改ZIP条目直接复制到新包,逐项写入改动,不持有原资源解压副本。
+// 入参: writer 输出流
+// 返回: int64 写入字节数, error 错误信息
+func (e *Editor) writeSource(writer io.Writer) (int64, error) {
+ parts, err := e.sourceParts()
+ if err != nil {
+ return 0, err
+ }
+ reader := e.source.reader
+ remaining := maps.Clone(reader.files)
+ if remaining == nil {
+ remaining = make(map[string][]byte)
+ }
+ maps.Copy(remaining, parts)
+ output := &ofdCountingWriter{writer: writer}
+ archive := zip.NewWriter(output)
+ if reader.Zip != nil {
+ _ = archive.SetComment(reader.Zip.Comment)
+ for _, file := range reader.Zip.File {
+ if file.FileInfo().IsDir() {
+ header := file.FileHeader
+ if _, err := archive.CreateHeader(&header); err != nil {
+ return output.count, err
+ }
+ continue
+ }
+ name := cleanPackagePath(file.Name)
+ if data, ok := remaining[name]; ok {
+ header := file.FileHeader
+ entry, err := archive.CreateHeader(&header)
+ if err != nil {
+ return output.count, err
+ }
+ if _, err := entry.Write(data); err != nil {
+ return output.count, err
+ }
+ delete(remaining, name)
+ } else if err := archive.Copy(file); err != nil {
+ return output.count, err
+ }
+ }
+ }
+ for _, name := range slices.Sorted(maps.Keys(remaining)) {
+ method := uint16(zip.Store)
+ if strings.HasSuffix(strings.ToLower(name), ".xml") {
+ method = zip.Deflate
+ }
+ entry, err := archive.CreateHeader(&zip.FileHeader{Name: name, Method: method})
+ if err != nil {
+ return output.count, err
+ }
+ if _, err := entry.Write(remaining[name]); err != nil {
+ return output.count, err
+ }
+ }
+ err = archive.Close()
+ return output.count, err
+}
+
+// sourceReader 生成原包与修改条目组成的独立预览快照,不进行ZIP压缩。
+// 返回: *Reader 阅读器, error 错误信息
+func (e *Editor) sourceReader() (*Reader, error) {
+ parts, err := e.sourceParts()
+ if err != nil {
+ return nil, err
+ }
+ files := maps.Clone(e.source.reader.files)
+ if files == nil {
+ files = make(map[string][]byte)
+ }
+ maps.Copy(files, parts)
+ reader := &Reader{Zip: e.source.reader.Zip, files: files}
+ if err := reader.initRoot(); err != nil {
+ return nil, err
+ }
+ if _, err := reader.Doc(); err != nil {
+ return nil, err
+ }
+ return reader, nil
+}
diff --git a/ofdgo_editor_transform.go b/ofdgo_editor_transform.go
index 5ef06b7..9fe2a6c 100644
--- a/ofdgo_editor_transform.go
+++ b/ofdgo_editor_transform.go
@@ -91,8 +91,13 @@ func (e *Editor) orientObjects(page int, ids []string, matrix Matrix) error {
boundary, ctm = &object.PathObject.Boundary, &object.PathObject.CTM
case "ImageObject":
boundary, ctm = &object.ImageObject.Boundary, &object.ImageObject.CTM
+ default:
+ return fmt.Errorf("unsupported object type %q", object.Type)
}
before, _ := ParseBox(*boundary)
+ if object.Type == "ImageObject" && *ctm == "" {
+ *ctm = Matrix{a: before.W, d: before.H}.String()
+ }
after := matrix.TransformBox(before)
local := TranslationMatrix(-after.X, -after.Y).Multiply(matrix).Multiply(TranslationMatrix(before.X, before.Y))
*ctm = local.Multiply(NewMatrix(*ctm)).String()
@@ -102,7 +107,7 @@ func (e *Editor) orientObjects(page int, ids []string, matrix Matrix) error {
}
objects[i] = object
}
- return e.UpdateObjects(page, objects)
+ return e.updateObjects(page, objects, true)
}
// transformImageClips 同步变换不随对象CTM变化的裁剪区域,不修改原始裁剪数据。
@@ -174,6 +179,9 @@ func cropImageObject(image *ImageObject, box Box) error {
return fmt.Errorf("crop must stay within the image bounds")
}
before, _ := ParseBox(image.Boundary)
+ if image.CTM == "" {
+ image.CTM = Matrix{a: before.W, d: before.H}.String()
+ }
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()
}
@@ -225,10 +233,16 @@ func (e *Editor) fitImage(obj *ImageObject, mode string) error {
return err
}
m := NewMatrix(obj.CTM)
+ if obj.CTM == "" {
+ m = Matrix{a: box.W, d: box.H}
+ }
if !axisAlignedMatrix(m) {
return fmt.Errorf("image fitting requires an axis-aligned transform")
}
- size := e.images[obj.ResourceID]
+ size, err := e.editorImage(obj.ResourceID)
+ if err != nil {
+ return err
+ }
w, h := float64(size.X), float64(size.Y)
if m.a == 0 {
w, h = h, w
diff --git a/ofdgo_editor_xml.go b/ofdgo_editor_xml.go
new file mode 100644
index 0000000..ea54685
--- /dev/null
+++ b/ofdgo_editor_xml.go
@@ -0,0 +1,417 @@
+// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package ofdgo
+
+import (
+ "bytes"
+ "encoding/xml"
+ "fmt"
+ "io"
+ "slices"
+ "strings"
+)
+
+// editorXML 保存XML节点的原文位置,不重写未修改的内容。
+type editorXML struct {
+ name xml.Name
+ attrs []xml.Attr
+ start, open, close, end int
+ children []*editorXML
+ parent *editorXML
+}
+
+// editorXMLPatch 替换原文中的连续区间。
+type editorXMLPatch struct {
+ start, end int
+ data []byte
+}
+
+// parseEditorXML 解析节点位置,保留命名空间、注释和原始格式。
+// 入参: data XML原文
+// 返回: *editorXML 根节点, error 错误信息
+func parseEditorXML(data []byte) (*editorXML, error) {
+ d := xml.NewDecoder(bytes.NewReader(data))
+ var root *editorXML
+ var stack []*editorXML
+ for {
+ start := int(d.InputOffset())
+ token, err := d.Token()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, err
+ }
+ switch token := token.(type) {
+ case xml.StartElement:
+ node := &editorXML{name: token.Name, attrs: token.Attr, start: start, open: int(d.InputOffset())}
+ if len(stack) == 0 {
+ if root != nil {
+ return nil, fmt.Errorf("multiple XML roots")
+ }
+ root = node
+ } else {
+ parent := stack[len(stack)-1]
+ node.parent = parent
+ parent.children = append(parent.children, node)
+ }
+ stack = append(stack, node)
+ case xml.EndElement:
+ node := stack[len(stack)-1]
+ node.close, node.end = start, int(d.InputOffset())
+ stack = stack[:len(stack)-1]
+ }
+ }
+ if root == nil {
+ return nil, fmt.Errorf("empty XML document")
+ }
+ return root, nil
+}
+
+// child 查找OFD直接子节点。
+// 入参: name 节点名称
+// 返回: *editorXML 节点,不存在时为nil
+func (n *editorXML) child(name string) *editorXML {
+ for _, child := range n.children {
+ if child.name.Local == name && (child.name.Space == n.name.Space || child.name.Space == ofdNamespace || child.name.Space == "") {
+ return child
+ }
+ }
+ return nil
+}
+
+// attr 获取无命名空间的属性值。
+// 入参: name 属性名称
+// 返回: string 属性值
+func (n *editorXML) attr(name string) string {
+ for _, attr := range n.attrs {
+ if attr.Name.Space == "" && attr.Name.Local == name {
+ return attr.Value
+ }
+ }
+ return ""
+}
+
+// editorPatchXML 按位置应用互不重叠的替换。
+// 入参: data 原文, patches 修改区间
+// 返回: []byte 修改后的XML
+func editorPatchXML(data []byte, patches []editorXMLPatch) []byte {
+ slices.SortStableFunc(patches, func(a, b editorXMLPatch) int {
+ if a.start != b.start {
+ return a.start - b.start
+ }
+ return a.end - b.end
+ })
+ var result bytes.Buffer
+ position := 0
+ for _, patch := range patches {
+ result.Write(data[position:patch.start])
+ result.Write(patch.data)
+ position = patch.end
+ }
+ result.Write(data[position:])
+ return result.Bytes()
+}
+
+// editorXMLContent 替换节点内部内容,兼容自闭合节点。
+// 入参: data 原文, node 节点, content 新内容
+// 返回: editorXMLPatch 修改区间
+func editorXMLContent(data []byte, node *editorXML, content []byte) editorXMLPatch {
+ if node.open != node.end {
+ return editorXMLPatch{node.open, node.close, content}
+ }
+ header := data[node.start:node.open]
+ end := bytes.IndexAny(header[1:], " \t\r\n/>") + 1
+ name := header[1:end]
+ replacement := append(bytes.Clone(header[:len(header)-2]), '>')
+ replacement = append(replacement, content...)
+ replacement = append(replacement, []byte(""+string(name)+">")...)
+ return editorXMLPatch{node.start, node.end, replacement}
+}
+
+// editorXMLText 编码带独立命名空间的文本节点。
+// 入参: name 节点名称, value 文本
+// 返回: []byte XML片段
+func editorXMLText(name, value string) []byte {
+ var escaped bytes.Buffer
+ _ = xml.EscapeText(&escaped, []byte(value))
+ return []byte("" + escaped.String() + "")
+}
+
+// editorXMLSetText 更新或追加直接子节点,保留其他节点。
+// 入参: data 原文, node 父节点, values 需要修改的字段
+// 返回: []byte 修改后的XML
+func editorXMLSetText(data []byte, node *editorXML, values [][2]string) []byte {
+ var patches []editorXMLPatch
+ var added []byte
+ order := []string{"PhysicalBox", "ApplicationBox", "ContentBox", "BleedBox"}
+ if node.name.Local == "DocInfo" {
+ order = []string{"DocID", "Title", "Author", "Subject", "Abstract", "CreationDate", "ModDate", "DocUsage", "Cover", "Keywords", "Creator", "CreatorVersion", "CustomDatas"}
+ }
+ for _, value := range values {
+ child := node.child(value[0])
+ var encoded []byte
+ if value[1] != "" {
+ encoded = editorXMLText(value[0], value[1])
+ }
+ if child != nil {
+ patches = append(patches, editorXMLPatch{child.start, child.end, encoded})
+ } else if node.open == node.end {
+ added = append(added, encoded...)
+ } else if len(encoded) != 0 {
+ position := node.close
+ for _, next := range node.children {
+ if slices.Index(order, next.name.Local) > slices.Index(order, value[0]) {
+ position = next.start
+ break
+ }
+ }
+ patches = append(patches, editorXMLPatch{position, position, encoded})
+ }
+ }
+ if len(added) != 0 {
+ patches = append(patches, editorXMLContent(data, node, added))
+ }
+ return editorPatchXML(data, patches)
+}
+
+// editorXMLSupported 判断原文是否仅包含创建器能够保留的对象字段。
+// 入参: node 对象或子节点
+// 返回: bool 是否支持完整编辑
+func editorXMLSupported(node *editorXML) bool {
+ if node.name.Space != "" && node.name.Space != ofdNamespace {
+ return false
+ }
+ var allowed, children string
+ switch node.name.Local {
+ case "TextObject":
+ allowed, children = "ID Boundary CTM Font Size HScale Weight ReadDirection CharDirection LineWidth MiterLimit Join Italic Visible Stroke Fill Alpha", "FillColor StrokeColor TextCode"
+ case "PathObject", "Path":
+ allowed, children = "Boundary CTM LineWidth MiterLimit Join Cap Rule DashPattern DashOffset Visible Stroke Fill Alpha", "StrokeColor FillColor AbbreviatedData"
+ if node.name.Local == "PathObject" {
+ allowed += " ID"
+ }
+ case "ImageObject":
+ allowed, children = "ID Boundary CTM ResourceID ImageMask Visible Alpha", "Clips"
+ case "TextCode":
+ allowed = "X Y DeltaX DeltaY"
+ case "FillColor", "StrokeColor":
+ allowed = "Value Alpha"
+ case "Clips":
+ allowed, children = "TransFlag", "Clip"
+ case "Clip":
+ children = "Area"
+ case "Area":
+ allowed, children = "CTM", "Path"
+ case "AbbreviatedData":
+ default:
+ return false
+ }
+ for _, attr := range node.attrs {
+ if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" && attr.Name.Space == "" {
+ continue
+ }
+ if attr.Name.Space != "" || !slices.Contains(strings.Fields(allowed), attr.Name.Local) {
+ return false
+ }
+ }
+ for _, child := range node.children {
+ if !slices.Contains(strings.Fields(children), child.name.Local) || !editorXMLSupported(child) {
+ return false
+ }
+ }
+ return true
+}
+
+// editorXMLObject 修改对象发生变化的属性与内容,保留原文中的显式默认值。
+// 入参: data 页面原文, node 原对象节点, before 原对象, after 新对象
+// 返回: []byte 对象XML, error 错误信息
+func editorXMLObject(data []byte, node *editorXML, before, after GraphicObject) ([]byte, error) {
+ if before.Type != after.Type {
+ return editorObjectXML(after)
+ }
+ oldXML, err := editorObjectXML(before)
+ if err != nil {
+ return nil, err
+ }
+ newXML, err := editorObjectXML(after)
+ if err != nil {
+ return nil, err
+ }
+ return editorXMLMerge(data, node, oldXML, newXML)
+}
+
+// editorXMLMerge 按标准编码的差异更新原节点,递归保留未变化的显式默认值与命名空间。
+// 入参: data 原文, node 原节点, oldXML 修改前编码, newXML 修改后编码
+// 返回: []byte 更新节点, error 错误信息
+func editorXMLMerge(data []byte, node *editorXML, oldXML, newXML []byte) ([]byte, error) {
+ oldNode, err := parseEditorXML(oldXML)
+ if err != nil {
+ return nil, err
+ }
+ newNode, err := parseEditorXML(newXML)
+ if err != nil {
+ return nil, err
+ }
+ changes := make(map[string]string)
+ for _, attr := range append(oldNode.attrs, newNode.attrs...) {
+ if attr.Name.Space == "" && oldNode.attr(attr.Name.Local) != newNode.attr(attr.Name.Local) {
+ changes[attr.Name.Local] = newNode.attr(attr.Name.Local)
+ }
+ }
+ contentChanged := !bytes.Equal(oldXML[oldNode.open:oldNode.close], newXML[newNode.open:newNode.close])
+ if len(changes) == 0 && !contentChanged {
+ return bytes.Clone(data[node.start:node.end]), nil
+ }
+ d := xml.NewDecoder(bytes.NewReader(data[node.start:node.open]))
+ token, err := d.RawToken()
+ if err != nil {
+ return nil, err
+ }
+ start := token.(xml.StartElement)
+ var result bytes.Buffer
+ name := start.Name.Local
+ if start.Name.Space != "" {
+ name = start.Name.Space + ":" + name
+ }
+ result.WriteString("<" + name)
+ for _, attr := range start.Attr {
+ if attr.Name.Space == "" {
+ if value, ok := changes[attr.Name.Local]; ok {
+ attr.Value = value
+ delete(changes, attr.Name.Local)
+ if value == "" {
+ continue
+ }
+ }
+ }
+ if contentChanged && attr.Name.Space == "xmlns" && attr.Name.Local == "ofd" {
+ continue
+ }
+ key := attr.Name.Local
+ if attr.Name.Space != "" {
+ key = attr.Name.Space + ":" + key
+ }
+ result.WriteString(" " + key + "=\"")
+ _ = xml.EscapeText(&result, []byte(attr.Value))
+ result.WriteByte('"')
+ }
+ for _, attr := range newNode.attrs {
+ if value, ok := changes[attr.Name.Local]; ok && value != "" {
+ result.WriteString(" " + attr.Name.Local + "=\"")
+ _ = xml.EscapeText(&result, []byte(value))
+ result.WriteByte('"')
+ }
+ }
+ if contentChanged {
+ result.WriteString(" xmlns:ofd=\"" + ofdNamespace + "\"")
+ }
+ result.WriteByte('>')
+ if contentChanged {
+ matching := len(node.children) > 0 && len(node.children) == len(oldNode.children) && len(node.children) == len(newNode.children)
+ if matching {
+ for i, child := range node.children {
+ if child.name.Local != oldNode.children[i].name.Local || child.name.Local != newNode.children[i].name.Local {
+ matching = false
+ break
+ }
+ }
+ }
+ if matching {
+ position := node.open
+ for i, child := range node.children {
+ before, after := oldNode.children[i], newNode.children[i]
+ updated, err := editorXMLMerge(data, child, oldXML[before.start:before.end], newXML[after.start:after.end])
+ if err != nil {
+ return nil, err
+ }
+ result.Write(data[position:child.start])
+ result.Write(updated)
+ position = child.end
+ }
+ result.Write(data[position:node.close])
+ } else {
+ result.Write(newXML[newNode.open:newNode.close])
+ }
+ } else if node.open != node.end {
+ result.Write(data[node.open:node.close])
+ }
+ result.WriteString("" + name + ">")
+ return result.Bytes(), nil
+}
+
+// editorXMLContainer 用标准编码器封装已编码的子节点。
+// 入参: name 节点名称, attrs 属性, content 子节点XML
+// 返回: []byte XML片段, error 错误信息
+func editorXMLContainer(name string, attrs ofdAttrs, content []byte) ([]byte, error) {
+ var output bytes.Buffer
+ attrs.add("xmlns:ofd", ofdNamespace)
+ value := struct {
+ Content []byte `xml:",innerxml"`
+ }{content}
+ err := xml.NewEncoder(&output).EncodeElement(value, xml.StartElement{Name: xml.Name{Local: "ofd:" + name}, Attr: attrs})
+ return output.Bytes(), err
+}
+
+// editorXMLStandalone 补齐原对象继承的命名空间,使跨页复制不依赖原父节点。
+// 入参: data 对象XML, source 原节点
+// 返回: []byte 独立XML片段, error 错误信息
+func editorXMLStandalone(data []byte, source *editorXML) ([]byte, error) {
+ root, err := parseEditorXML(data)
+ if err != nil {
+ return nil, err
+ }
+ namespaces := make(map[string]string)
+ for node := source.parent; node != nil; node = node.parent {
+ for _, attr := range node.attrs {
+ key := ""
+ if attr.Name.Space == "xmlns" {
+ key = "xmlns:" + attr.Name.Local
+ }
+ if attr.Name.Space == "" && attr.Name.Local == "xmlns" {
+ key = "xmlns"
+ }
+ if key != "" {
+ if _, exists := namespaces[key]; !exists {
+ namespaces[key] = attr.Value
+ }
+ }
+ }
+ }
+ for _, attr := range root.attrs {
+ if attr.Name.Space == "xmlns" {
+ delete(namespaces, "xmlns:"+attr.Name.Local)
+ }
+ if attr.Name.Space == "" && attr.Name.Local == "xmlns" {
+ delete(namespaces, "xmlns")
+ }
+ }
+ var added bytes.Buffer
+ keys := make([]string, 0, len(namespaces))
+ for key := range namespaces {
+ keys = append(keys, key)
+ }
+ slices.Sort(keys)
+ for _, key := range keys {
+ added.WriteString(" " + key + "=\"")
+ _ = xml.EscapeText(&added, []byte(namespaces[key]))
+ added.WriteByte('"')
+ }
+ position := root.open - 1
+ if data[position-1] == '/' {
+ position--
+ }
+ return editorPatchXML(data, []editorXMLPatch{{position, position, added.Bytes()}}), nil
+}
diff --git a/ofdgo_reader.go b/ofdgo_reader.go
index 78c47b3..811d451 100644
--- a/ofdgo_reader.go
+++ b/ofdgo_reader.go
@@ -34,6 +34,7 @@ type Reader struct {
RootDir string
ResMap map[string]string
resourcesRead map[string]bool
+ resourceFiles map[string]string
fontCache map[string]*Font
fontResourcesRead bool
fontFilesChecked map[string]bool
@@ -84,6 +85,7 @@ func (r *Reader) initRoot() error {
r.OFD = &ofd
r.ResMap = make(map[string]string)
r.resourcesRead = make(map[string]bool)
+ r.resourceFiles = make(map[string]string)
r.fontCache = make(map[string]*Font)
r.fontFilesChecked = make(map[string]bool)
r.colorSpaceCache = make(map[string]*ColorSpace)
@@ -102,6 +104,9 @@ func (r *Reader) readFile(name string) ([]byte, error) {
return bytes.Clone(data), nil
}
if f, ok := r.packageFile(name); ok {
+ if data, ok := r.files[cleanPackagePath(f.Name)]; ok {
+ return bytes.Clone(data), nil
+ }
return readZipFile(f)
}
return nil, fmt.Errorf("file not found: %s", name)
@@ -116,6 +121,9 @@ func (r *Reader) openFile(name string) (io.ReadCloser, error) {
return io.NopCloser(bytes.NewReader(data)), nil
}
if f, ok := r.packageFile(name); ok {
+ if data, ok := r.files[cleanPackagePath(f.Name)]; ok {
+ return io.NopCloser(bytes.NewReader(data)), nil
+ }
return f.Open()
}
return nil, fmt.Errorf("file not found: %s", name)
@@ -217,6 +225,7 @@ func (r *Reader) loadRes(resPath string) {
if mm.MediaFile != "" {
if finalPath := resolveResourcePath(resPath, baseLoc, mm.MediaFile); finalPath != "" {
r.ResMap[mm.ID] = finalPath
+ r.resourceFiles[mm.ID] = fullPath
}
}
}
@@ -226,6 +235,7 @@ func (r *Reader) loadRes(resPath string) {
f.FontFile = resolveResourcePath(resPath, baseLoc, f.FontFile)
}
r.fontCache[f.ID] = f
+ r.resourceFiles[f.ID] = fullPath
}
for i := range res.DrawParams.DrawParam {
dp := &res.DrawParams.DrawParam[i]
diff --git a/ofdgo_sign_verify.go b/ofdgo_sign_verify.go
index 9f0f0d4..fc11b06 100644
--- a/ofdgo_sign_verify.go
+++ b/ofdgo_sign_verify.go
@@ -440,6 +440,9 @@ func parseSignatureFile(data []byte) (*SignatureFile, error) {
// 返回: []byte 文件数据, error 错误信息
func (r *Reader) readFileExact(name string) ([]byte, error) {
name = cleanPackagePath(name)
+ if data, ok := r.files[name]; ok {
+ return bytes.Clone(data), nil
+ }
if f, ok := r.fileIndex[name]; ok {
return readZipFile(f)
}
diff --git a/ofdgo_writer.go b/ofdgo_writer.go
index 21447a1..e857656 100644
--- a/ofdgo_writer.go
+++ b/ofdgo_writer.go
@@ -21,6 +21,8 @@ import (
"encoding/xml"
"fmt"
"io"
+ "maps"
+ "slices"
"strconv"
"time"
)
@@ -34,6 +36,9 @@ func (e *Editor) WriteTo(writer io.Writer) (int64, error) {
if err := e.validate(); err != nil {
return 0, err
}
+ if e.source != nil {
+ return e.writeSource(writer)
+ }
output := &ofdCountingWriter{writer: writer}
archive := zip.NewWriter(output)
err := e.writeParts(func(name string, data []byte, compressed bool) error {
@@ -61,6 +66,9 @@ func (e *Editor) Reader() (*Reader, error) {
if err := e.validate(); err != nil {
return nil, err
}
+ if e.source != nil {
+ return e.sourceReader()
+ }
r := &Reader{files: make(map[string][]byte)}
if err := e.writeParts(func(name string, data []byte, _ bool) error {
r.files[name] = data
@@ -83,10 +91,15 @@ func (e *Editor) validate() error {
if len(e.pages) == 0 {
return fmt.Errorf("document must contain at least one page")
}
- if data, err := hex.DecodeString(e.Info.DocID); err != nil || len(data) != 16 {
- return fmt.Errorf("DocID must contain 32 hexadecimal characters")
+ if e.source == nil || e.Info.DocID != e.source.info.DocID {
+ if data, err := hex.DecodeString(e.Info.DocID); err != nil || len(data) != 16 {
+ return fmt.Errorf("DocID must contain 32 hexadecimal characters")
+ }
}
- for _, date := range []string{e.Info.CreationDate, e.Info.ModDate} {
+ for i, date := range []string{e.Info.CreationDate, e.Info.ModDate} {
+ if e.source != nil && date == []string{e.source.info.CreationDate, e.source.info.ModDate}[i] {
+ continue
+ }
if date != "" {
if _, err := time.Parse("2006-01-02", date); err != nil {
return fmt.Errorf("invalid document date: %w", err)
@@ -100,7 +113,7 @@ func (e *Editor) validate() error {
// 入参: write 条目写入方法
// 返回: error 错误信息
func (e *Editor) writeParts(write func(string, []byte, bool) error) error {
- fonts, images := e.usedResources()
+ fonts, images, _ := e.usedResources()
writeXML := func(name string, encode func(*ofdXML)) error {
data, err := encodeOFDXML(encode)
if err != nil {
@@ -185,20 +198,42 @@ func (e *Editor) writeParts(write func(string, []byte, bool) error) error {
return nil
}
-// usedResources 按注册顺序筛选当前页面引用的字体和图片,不修改资源池
-// 返回: []editorResource 字体资源, []editorResource 图片资源
-func (e *Editor) usedResources() (fonts, images []editorResource) {
+// usedResources 筛选当前引用的新增资源及需要跨页复用的原资源文件,不修改资源池。
+// 返回: []editorResource 新增字体, []editorResource 新增图片, []string 原资源文件
+func (e *Editor) usedResources() (fonts, images []editorResource, sourceFiles []string) {
used := make(map[string]bool)
+ promoted := make(map[string]bool)
for _, page := range e.pages {
+ var original map[string]GraphicObject
+ if e.source != nil {
+ original = make(map[string]GraphicObject)
+ if source := e.source.pages[page.ID]; source != nil && source.original != nil {
+ for _, layer := range source.original.Content.Layer {
+ for _, object := range layer.Objects {
+ original[editorObjectID(object)] = object
+ }
+ }
+ }
+ }
for _, layer := range page.Content.Layer {
for _, object := range layer.Objects {
+ before, exists := original[editorObjectID(object)]
switch object.Type {
case "TextObject":
used[object.TextObject.Font] = true
+ if e.source != nil && (!exists || before.Type != object.Type || before.TextObject.Font != object.TextObject.Font) {
+ promoted[object.TextObject.Font] = true
+ }
case "ImageObject":
used[object.ImageObject.ResourceID] = true
+ if e.source != nil && (!exists || before.Type != object.Type || before.ImageObject.ResourceID != object.ImageObject.ResourceID) {
+ promoted[object.ImageObject.ResourceID] = true
+ }
if object.ImageObject.ImageMask != "" {
used[object.ImageObject.ImageMask] = true
+ if e.source != nil && (!exists || before.Type != object.Type || before.ImageObject.ImageMask != object.ImageObject.ImageMask) {
+ promoted[object.ImageObject.ImageMask] = true
+ }
}
}
}
@@ -207,12 +242,20 @@ func (e *Editor) usedResources() (fonts, images []editorResource) {
for _, resource := range e.resources {
if resource.font != nil && used[resource.font.ID] {
fonts = append(fonts, resource)
+ delete(promoted, resource.font.ID)
}
if resource.image != nil && used[resource.image.ID] {
images = append(images, resource)
+ delete(promoted, resource.image.ID)
}
}
- return fonts, images
+ files := make(map[string]bool)
+ for id := range promoted {
+ if name := e.source.reader.resourceFiles[id]; name != "" {
+ files[name] = true
+ }
+ }
+ return fonts, images, slices.Sorted(maps.Keys(files))
}
// page 写出页面尺寸、图层和对象
@@ -224,19 +267,25 @@ func (x *ofdXML) page(page PageContent) {
x.end("Area")
x.start("Content", nil)
for _, layer := range page.Content.Layer {
- var attrs ofdAttrs
- attrs.add("ID", layer.ID)
- attrs.add("Type", layer.Type)
- x.start("Layer", attrs)
- for _, object := range layer.Objects {
- x.object(object, false)
- }
- x.end("Layer")
+ x.layer(layer)
}
x.end("Content")
x.end("Page")
}
+// layer 写出新建图层及其对象。
+// 入参: layer 图层
+func (x *ofdXML) layer(layer Layer) {
+ var attrs ofdAttrs
+ attrs.add("ID", layer.ID)
+ attrs.add("Type", layer.Type)
+ x.start("Layer", attrs)
+ for _, object := range layer.Objects {
+ x.object(object, false)
+ }
+ x.end("Layer")
+}
+
// resources 写出文档资源索引
// 入参: fonts 字体资源, images 图片资源
func (x *ofdXML) resources(fonts, images []editorResource) {