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("")...) + 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("") + 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) {