mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 13:56:32 +08:00
1 parent
81e6657c65
commit
0508029387
13 files changed
+1121
-1065
No files matched your search
@@ -26,6 +26,7 @@ let wasmRequestID = 0;
|
||||
let wasmRecoveryTimer = 0;
|
||||
let textMeasure = null;
|
||||
let credentialRequest = null;
|
||||
let cancelPageTouch = null;
|
||||
|
||||
const state = {
|
||||
composite: null,
|
||||
@@ -5570,8 +5571,6 @@ el.finishPageSelection.addEventListener("click", () => {
|
||||
updateControls();
|
||||
});
|
||||
|
||||
let cancelPageTouch = null;
|
||||
|
||||
el.pageList.addEventListener("pointerdown", event => {
|
||||
cancelPageTouch?.();
|
||||
state.pageTouchPointer = null;
|
||||
|
||||
+410
-410
@@ -45,6 +45,79 @@ type exportWriter struct {
|
||||
write js.Value
|
||||
}
|
||||
|
||||
// apiResult 浏览器接口返回结果
|
||||
type apiResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
MissingGlyphs *ofdgo.MissingGlyphError `json:"missingGlyphs,omitempty"`
|
||||
ReasonCode ofdgo.EditReason `json:"reasonCode,omitempty"`
|
||||
}
|
||||
|
||||
// editorClipboard 当前编辑文档中的对象快照与剪贴板标识
|
||||
type editorClipboard struct {
|
||||
token string
|
||||
objects []ofdgo.GraphicObject
|
||||
composite *ofdgo.CompositeSelection
|
||||
annotations *ofdgo.AnnotationSelection
|
||||
}
|
||||
|
||||
// editorInfo 编辑文档信息与操作状态
|
||||
type editorInfo struct {
|
||||
DocumentInfo
|
||||
Revision uint64 `json:"revision"`
|
||||
CanUndo bool `json:"canUndo"`
|
||||
CanRedo bool `json:"canRedo"`
|
||||
PageCapabilities []uint `json:"pageCapabilities"`
|
||||
EditWarnings []string `json:"editWarnings,omitempty"`
|
||||
}
|
||||
|
||||
// editorPageInfo 页面操作结果与目标页面
|
||||
type editorPageInfo struct {
|
||||
editorInfo
|
||||
PageIndex int `json:"pageIndex"`
|
||||
}
|
||||
|
||||
// editorSelectionInfo 对象复制结果及新选区
|
||||
type editorSelectionInfo struct {
|
||||
editorInfo
|
||||
SelectedIDs []string `json:"selectedIDs"`
|
||||
}
|
||||
|
||||
// editorOutlineInfo 目录操作后的位置
|
||||
type editorOutlineInfo struct {
|
||||
editorInfo
|
||||
OutlinePath []int `json:"outlinePath"`
|
||||
}
|
||||
|
||||
// annotationOptions 保存注解面板的输入,资源字节单独传输
|
||||
type annotationOptions struct {
|
||||
Kind, ID, Text, Old, Creator, Pages, URI, Color string
|
||||
Base string
|
||||
X, Y, Width, Height, Size, Angle float64
|
||||
Alpha int
|
||||
Tile bool
|
||||
Keep bool
|
||||
Target int
|
||||
Area *ofdgo.Box
|
||||
}
|
||||
|
||||
// currentSession 当前WebUI文档会话
|
||||
var currentSession *Session
|
||||
|
||||
// currentEditor 当前编辑文档
|
||||
var currentEditor *ofdgo.Editor
|
||||
|
||||
// pendingImport 待插页文档,仅保留文件数据与索引,不创建渲染会话
|
||||
var pendingImport *ofdgo.Reader
|
||||
|
||||
// copiedObjects 当前对象剪贴板,不保存字体或图片的重复数据
|
||||
var copiedObjects *editorClipboard
|
||||
|
||||
// copiedStyle 当前文档中的独立样式快照
|
||||
var copiedStyle *ofdgo.GraphicObject
|
||||
|
||||
// Write 将数据块交给浏览器保存
|
||||
// 入参: data 导出数据
|
||||
// 返回: int 写入长度, error 错误信息
|
||||
@@ -80,25 +153,6 @@ func awaitExport(fn js.Value, args ...any) error {
|
||||
return <-done
|
||||
}
|
||||
|
||||
// currentSession 当前WebUI文档会话
|
||||
var currentSession *Session
|
||||
|
||||
// pendingImport 待插页文档,仅保留文件数据与索引,不创建渲染会话
|
||||
var pendingImport *ofdgo.Reader
|
||||
|
||||
// copiedStyle 当前文档中的独立样式快照
|
||||
var copiedStyle *ofdgo.GraphicObject
|
||||
|
||||
// apiResult 浏览器接口返回结果
|
||||
type apiResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Data any `json:"data,omitempty"`
|
||||
MissingGlyphs *ofdgo.MissingGlyphError `json:"missingGlyphs,omitempty"`
|
||||
ReasonCode ofdgo.EditReason `json:"reasonCode,omitempty"`
|
||||
}
|
||||
|
||||
// RunWASM 注册浏览器WASM接口并阻塞运行
|
||||
func RunWASM() {
|
||||
registerCallback("ofdgoOpen", openDocument)
|
||||
@@ -297,45 +351,6 @@ func openDocument(args []js.Value) (any, error) {
|
||||
return currentSession.Summary(), nil
|
||||
}
|
||||
|
||||
// credentialsFromJS 解析本次打开的口令或本机证书私钥
|
||||
// 入参: value 解锁表单数据
|
||||
// 返回: ofdgo.Credentials 会话凭据, error 错误信息
|
||||
func credentialsFromJS(value js.Value) (ofdgo.Credentials, error) {
|
||||
credentials := ofdgo.Credentials{UserName: value.Get("userName").String()}
|
||||
if keyValue := value.Get("key"); !keyValue.IsNull() && !keyValue.IsUndefined() {
|
||||
key, err := bytesFromJS(keyValue)
|
||||
if err != nil {
|
||||
return credentials, err
|
||||
}
|
||||
defer clear(key)
|
||||
certificate, err := bytesFromJS(value.Get("certificate"))
|
||||
if err != nil {
|
||||
return credentials, err
|
||||
}
|
||||
var password []byte
|
||||
if field := value.Get("keyPassword"); !field.IsUndefined() && !field.IsNull() {
|
||||
password, err = bytesFromJS(field)
|
||||
if err != nil {
|
||||
return credentials, err
|
||||
}
|
||||
defer clear(password)
|
||||
}
|
||||
signer, certificate, err := ofdgo.ParseSignatureIdentity(certificate, key, password)
|
||||
if err != nil {
|
||||
return credentials, ofdgo.ErrInvalidCredentials
|
||||
}
|
||||
decrypter, ok := signer.(crypto.Decrypter)
|
||||
if !ok {
|
||||
return credentials, ofdgo.ErrInvalidCredentials
|
||||
}
|
||||
credentials.Certificate, credentials.Decrypter = certificate, decrypter
|
||||
return credentials, nil
|
||||
}
|
||||
var err error
|
||||
credentials.Password, err = bytesFromJS(value.Get("password"))
|
||||
return credentials, err
|
||||
}
|
||||
|
||||
// configureDocument 更新当前会话的字体和注解设置
|
||||
// 入参: args 浏览器参数
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
@@ -378,70 +393,6 @@ func documentInfo(args []js.Value) (any, error) {
|
||||
return currentSession.Info(), nil
|
||||
}
|
||||
|
||||
// securityFilesFromJS 读取会话内的本机证据文件
|
||||
// 入参: value 二进制数组列表
|
||||
// 返回: [][]byte 文件内容, error 错误信息
|
||||
func securityFilesFromJS(value js.Value) ([][]byte, error) {
|
||||
if value.IsUndefined() || value.IsNull() {
|
||||
return nil, nil
|
||||
}
|
||||
files := make([][]byte, value.Length())
|
||||
for i := range files {
|
||||
var err error
|
||||
files[i], err = bytesFromJS(value.Index(i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// verifySignatures 按本机信任锚和离线证据重新验签,不设置联网回调
|
||||
// 入参: args 信任根、辅助证书、时间戳及撤销证据
|
||||
// 返回: any 分项报告, error 错误信息
|
||||
func verifySignatures(args []js.Value) (any, error) {
|
||||
if currentSession == nil || len(args) == 0 {
|
||||
return nil, fmt.Errorf("missing signature verification arguments")
|
||||
}
|
||||
input := args[0]
|
||||
files := make(map[string][][]byte)
|
||||
for _, name := range []string{"roots", "certificates", "timestampRoots", "tokens", "crls", "ocsp"} {
|
||||
data, err := securityFilesFromJS(input.Get(name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files[name] = data
|
||||
}
|
||||
now := time.Now()
|
||||
policy := ofdgo.SignaturePolicy{RejectWeakAlgorithms: true, RequireDocumentCoverage: true,
|
||||
RequireTimestamp: input.Get("requireTimestamp").Bool(), RequireRevocation: input.Get("requireRevocation").Bool()}
|
||||
options := []ofdgo.SignatureVerifyOption{
|
||||
ofdgo.WithSignatureVerifyTime(now), ofdgo.WithSignaturePolicy(policy),
|
||||
ofdgo.WithSignatureTrustCerts(files["roots"]...), ofdgo.WithSignatureCerts(files["certificates"]...),
|
||||
}
|
||||
if policy.RequireTimestamp || len(files["timestampRoots"])+len(files["tokens"]) > 0 {
|
||||
options = append(options, ofdgo.WithSignatureTimestamp(ofdgo.SignatureTimestampOptions{
|
||||
TrustCerts: files["timestampRoots"], Certificates: files["certificates"], Tokens: files["tokens"], Required: policy.RequireTimestamp, VerifyTime: &now,
|
||||
}))
|
||||
}
|
||||
if policy.RequireRevocation || len(files["crls"])+len(files["ocsp"]) > 0 {
|
||||
options = append(options, ofdgo.WithSignatureRevocation(ofdgo.SignatureRevocationOptions{
|
||||
SignatureRevocationEvidence: ofdgo.SignatureRevocationEvidence{CRLs: files["crls"], OCSPResponses: files["ocsp"]},
|
||||
Certificates: append(files["certificates"], files["roots"]...), Required: policy.RequireRevocation, VerifyTime: &now,
|
||||
}))
|
||||
}
|
||||
reports, err := currentSession.Reader.VerifySignatures(options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infos := make([]SignatureInfo, len(reports))
|
||||
for i, report := range reports {
|
||||
infos[i] = signatureInfo(report)
|
||||
}
|
||||
currentSession.signatures, currentSession.signatureError, currentSession.signaturesRead = infos, nil, true
|
||||
return map[string]any{"signatures": infos, "signatureCount": len(infos), "signatureError": ""}, nil
|
||||
}
|
||||
|
||||
// exportAttachment 分块导出附件
|
||||
// 入参: args 浏览器参数
|
||||
// 返回: any 附件数据, error 错误信息
|
||||
@@ -820,42 +771,6 @@ func encodeResult(result apiResult) string {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// currentEditor 当前编辑文档
|
||||
var currentEditor *ofdgo.Editor
|
||||
|
||||
// editorClipboard 当前编辑文档中的对象快照与剪贴板标识
|
||||
type editorClipboard struct {
|
||||
token string
|
||||
objects []ofdgo.GraphicObject
|
||||
composite *ofdgo.CompositeSelection
|
||||
annotations *ofdgo.AnnotationSelection
|
||||
}
|
||||
|
||||
// copiedObjects 当前对象剪贴板,不保存字体或图片的重复数据
|
||||
var copiedObjects *editorClipboard
|
||||
|
||||
// editorInfo 编辑文档信息与操作状态
|
||||
type editorInfo struct {
|
||||
DocumentInfo
|
||||
Revision uint64 `json:"revision"`
|
||||
CanUndo bool `json:"canUndo"`
|
||||
CanRedo bool `json:"canRedo"`
|
||||
PageCapabilities []uint `json:"pageCapabilities"`
|
||||
EditWarnings []string `json:"editWarnings,omitempty"`
|
||||
}
|
||||
|
||||
// editorPageInfo 页面操作结果与目标页面
|
||||
type editorPageInfo struct {
|
||||
editorInfo
|
||||
PageIndex int `json:"pageIndex"`
|
||||
}
|
||||
|
||||
// editorSelectionInfo 对象复制结果及新选区
|
||||
type editorSelectionInfo struct {
|
||||
editorInfo
|
||||
SelectedIDs []string `json:"selectedIDs"`
|
||||
}
|
||||
|
||||
// editorSummary 获取当前编辑文档状态
|
||||
// 返回: editorInfo 文档信息
|
||||
func editorSummary() editorInfo {
|
||||
@@ -1119,18 +1034,6 @@ func changeAnnotations(args []js.Value) (any, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// annotationOptions 保存注解面板的输入,资源字节单独传输
|
||||
type annotationOptions struct {
|
||||
Kind, ID, Text, Old, Creator, Pages, URI, Color string
|
||||
Base string
|
||||
X, Y, Width, Height, Size, Angle float64
|
||||
Alpha int
|
||||
Tile bool
|
||||
Keep bool
|
||||
Target int
|
||||
Area *ofdgo.Box
|
||||
}
|
||||
|
||||
// readAnnotation 获取编辑面板所需的注解内容,不修改文件
|
||||
// 入参: args 页面索引与注解标识
|
||||
// 返回: any 面板数据, error 错误信息
|
||||
@@ -2277,6 +2180,233 @@ func previewText(args []js.Value) (any, error) {
|
||||
return map[string]any{"runs": runs, "scale": scale, "weight": text.Weight, "italic": text.Italic}, nil
|
||||
}
|
||||
|
||||
// updateText 修改文字,null内容保留定位且允许替换字体,null字体数据和颜色沿用对象属性
|
||||
// 入参: args 页码、对象标识、内容、字体数据、字号和颜色
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func updateText(args []js.Value) (any, error) {
|
||||
if currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
}
|
||||
page, id := args[0].Int(), args[1].String()
|
||||
object, err := currentEditor.Object(page, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if object.Type != "TextObject" {
|
||||
return nil, fmt.Errorf("object %q is not text", id)
|
||||
}
|
||||
if !args[3].IsNull() {
|
||||
data, err := bytesFromJS(args[3])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value := object.TextObject.Text()
|
||||
if !args[2].IsNull() {
|
||||
value = args[2].String()
|
||||
}
|
||||
if err := checkFontGlyphs(data, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
object.TextObject.Font, err = currentEditor.AddFont(FontFile{Data: data}, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !args[2].IsNull() {
|
||||
if args[4].Float() == object.TextObject.Size {
|
||||
style := ofdgo.TextStyle{Font: object.TextObject.Font}
|
||||
if !args[5].IsNull() {
|
||||
var fill ofdgo.FillColor
|
||||
if err := setEditorColor(&fill, args[5].String()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
style.Color = fill.Value
|
||||
}
|
||||
revision := currentEditor.Revision()
|
||||
if err := currentEditor.UpdateTextContent(page, id, args[2].String(), style); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentEditor.Revision() == revision {
|
||||
return editorSummary(), nil
|
||||
}
|
||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||
}
|
||||
object.TextObject.Size = args[4].Float()
|
||||
_, layout := object.TextObject.TextLayout()
|
||||
if err := currentEditor.LayoutText(&object.TextObject, args[2].String(), layout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !args[5].IsNull() {
|
||||
if err := setTextColor(&object.TextObject, args[5].String()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
revision := currentEditor.Revision()
|
||||
if err := currentEditor.UpdateObject(page, id, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentEditor.Revision() == revision {
|
||||
return editorSummary(), nil
|
||||
}
|
||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||
}
|
||||
|
||||
// checkFontGlyphs 只检查候选字体,不注册资源或改变文档
|
||||
// 入参: data 字体数据, value 待替换文字
|
||||
// 返回: error 缺字或字体格式错误
|
||||
func checkFontGlyphs(data []byte, value string) error {
|
||||
missing, err := (FontFile{Data: data}).MissingGlyphs(0, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if missing != "" {
|
||||
return &ofdgo.MissingGlyphError{Characters: missing}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkTextFont 为画布输入预检候选字体
|
||||
// 入参: args 字体数据和输入文字
|
||||
// 返回: any 空结果, error 缺字或字体格式错误
|
||||
func checkTextFont(args []js.Value) (any, error) {
|
||||
data, err := bytesFromJS(args[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, checkFontGlyphs(data, args[1].String())
|
||||
}
|
||||
|
||||
// styleText 批量修改文字样式,字体预检通过后交由库原子提交
|
||||
// 入参: args 页码、对象标识、字体数据、字号和颜色,null字体与颜色保持原值
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func styleText(args []js.Value) (any, error) {
|
||||
return changeObjects(func() error {
|
||||
page, ids := args[0].Int(), stringsFromJS(args[1])
|
||||
style := ofdgo.TextStyle{Size: args[3].Float()}
|
||||
if !args[2].IsNull() {
|
||||
data, err := bytesFromJS(args[2])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var value strings.Builder
|
||||
for _, id := range ids {
|
||||
object, err := currentEditor.Object(page, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if object.Type != "TextObject" {
|
||||
return fmt.Errorf("object %q is not text", id)
|
||||
}
|
||||
value.WriteString(object.TextObject.Text())
|
||||
}
|
||||
if err := checkFontGlyphs(data, value.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
style.Font, err = currentEditor.AddFont(FontFile{Data: data}, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !args[4].IsNull() {
|
||||
var color ofdgo.FillColor
|
||||
if err := setEditorColor(&color, args[4].String()); err != nil {
|
||||
return err
|
||||
}
|
||||
style.Color = color.Value
|
||||
}
|
||||
return currentEditor.StyleText(page, ids, style)
|
||||
})
|
||||
}
|
||||
|
||||
// setTextColor 将浏览器RGB色值写入文字对象,保留颜色透明度
|
||||
// 入参: object 文字对象, value 十六进制色值
|
||||
// 返回: error 错误信息
|
||||
func setTextColor(object *ofdgo.TextObject, value string) error {
|
||||
if object.FillColor == nil {
|
||||
object.FillColor = &ofdgo.FillColor{}
|
||||
}
|
||||
return setEditorColor(object.FillColor, value)
|
||||
}
|
||||
|
||||
// insertText 使用选定字体创建文字对象
|
||||
// 入参: args 页码、内容、字体数据、横纵坐标、字号、颜色、框宽、折行、对齐、行距、字距及左右和首行缩进
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func insertText(args []js.Value) (any, error) {
|
||||
if currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
}
|
||||
data, err := bytesFromJS(args[2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fontID, err := currentEditor.AddFont(FontFile{Data: data}, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page := args[0].Int()
|
||||
content, err := currentSession.pageContent(page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
box, err := currentSession.pageBox(page, content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x, y := args[3].Float(), args[4].Float()
|
||||
width := args[7].Float()
|
||||
if width == 0 {
|
||||
width = 80
|
||||
}
|
||||
box = ofdgo.Box{X: x, Y: y, W: math.Min(width, box.W-x), H: box.H - y}
|
||||
object := ofdgo.GraphicObject{Type: "TextObject", TextObject: ofdgo.TextObject{
|
||||
Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H),
|
||||
Font: fontID, Size: args[5].Float(),
|
||||
}}
|
||||
if err := currentEditor.LayoutText(&object.TextObject, args[1].String(), ofdgo.TextLayout{Wrap: args[8].Bool(), Align: args[9].String(), LineHeight: args[10].Float(), LetterSpacing: args[11].Float(), LeftIndent: args[12].Float(), RightIndent: args[13].Float(), FirstLineIndent: args[14].Float()}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setTextColor(&object.TextObject, args[6].String()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return insertEditorObject(page, object, args[15:])
|
||||
}
|
||||
|
||||
// layoutText 调整文字框和段落排版,保留软换行前的原文
|
||||
// 入参: args 页码、对象标识、本地左侧偏移、宽度、折行、对齐、行距、字距及左右和首行缩进
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func layoutText(args []js.Value) (any, error) {
|
||||
if currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
}
|
||||
page, id := args[0].Int(), args[1].String()
|
||||
object, err := currentEditor.Object(page, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if object.Type != "TextObject" {
|
||||
return nil, fmt.Errorf("object is not text")
|
||||
}
|
||||
if !args[2].IsNull() {
|
||||
object.TextObject, err = object.TextObject.ResizeTextFrame(args[2].Float(), args[3].Float())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
value, _ := object.TextObject.TextLayout()
|
||||
if err := currentEditor.LayoutText(&object.TextObject, value, ofdgo.TextLayout{Wrap: args[4].Bool(), Align: args[5].String(), LineHeight: args[6].Float(), LetterSpacing: args[7].Float(), LeftIndent: args[8].Float(), RightIndent: args[9].Float(), FirstLineIndent: args[10].Float()}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
revision := currentEditor.Revision()
|
||||
if err := currentEditor.UpdateObject(page, id, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentEditor.Revision() == revision {
|
||||
return editorSummary(), nil
|
||||
}
|
||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||
}
|
||||
|
||||
// replaceImage 替换图片资源并更新预览
|
||||
// 入参: args 页码、对象标识、图片数据和适应方式
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
@@ -2651,12 +2781,6 @@ func batchPages(args []js.Value) (any, error) {
|
||||
return editorPageInfo{info.(editorInfo), index}, nil
|
||||
}
|
||||
|
||||
// editorOutlineInfo 目录操作后的位置
|
||||
type editorOutlineInfo struct {
|
||||
editorInfo
|
||||
OutlinePath []int `json:"outlinePath"`
|
||||
}
|
||||
|
||||
// indexesFromJS 读取整数索引列表
|
||||
// 入参: value JavaScript数组
|
||||
// 返回: []int 索引列表
|
||||
@@ -3007,155 +3131,6 @@ func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) {
|
||||
return editorSummary(), nil
|
||||
}
|
||||
|
||||
// updateText 修改文字,null内容保留定位且允许替换字体,null字体数据和颜色沿用对象属性
|
||||
// 入参: args 页码、对象标识、内容、字体数据、字号和颜色
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func updateText(args []js.Value) (any, error) {
|
||||
if currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
}
|
||||
page, id := args[0].Int(), args[1].String()
|
||||
object, err := currentEditor.Object(page, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if object.Type != "TextObject" {
|
||||
return nil, fmt.Errorf("object %q is not text", id)
|
||||
}
|
||||
if !args[3].IsNull() {
|
||||
data, err := bytesFromJS(args[3])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value := object.TextObject.Text()
|
||||
if !args[2].IsNull() {
|
||||
value = args[2].String()
|
||||
}
|
||||
if err := checkFontGlyphs(data, value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
object.TextObject.Font, err = currentEditor.AddFont(FontFile{Data: data}, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !args[2].IsNull() {
|
||||
if args[4].Float() == object.TextObject.Size {
|
||||
style := ofdgo.TextStyle{Font: object.TextObject.Font}
|
||||
if !args[5].IsNull() {
|
||||
var fill ofdgo.FillColor
|
||||
if err := setEditorColor(&fill, args[5].String()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
style.Color = fill.Value
|
||||
}
|
||||
revision := currentEditor.Revision()
|
||||
if err := currentEditor.UpdateTextContent(page, id, args[2].String(), style); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentEditor.Revision() == revision {
|
||||
return editorSummary(), nil
|
||||
}
|
||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||
}
|
||||
object.TextObject.Size = args[4].Float()
|
||||
_, layout := object.TextObject.TextLayout()
|
||||
if err := currentEditor.LayoutText(&object.TextObject, args[2].String(), layout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if !args[5].IsNull() {
|
||||
if err := setTextColor(&object.TextObject, args[5].String()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
revision := currentEditor.Revision()
|
||||
if err := currentEditor.UpdateObject(page, id, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentEditor.Revision() == revision {
|
||||
return editorSummary(), nil
|
||||
}
|
||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||
}
|
||||
|
||||
// checkFontGlyphs 只检查候选字体,不注册资源或改变文档
|
||||
// 入参: data 字体数据, value 待替换文字
|
||||
// 返回: error 缺字或字体格式错误
|
||||
func checkFontGlyphs(data []byte, value string) error {
|
||||
missing, err := (FontFile{Data: data}).MissingGlyphs(0, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if missing != "" {
|
||||
return &ofdgo.MissingGlyphError{Characters: missing}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkTextFont 为画布输入预检候选字体
|
||||
// 入参: args 字体数据和输入文字
|
||||
// 返回: any 空结果, error 缺字或字体格式错误
|
||||
func checkTextFont(args []js.Value) (any, error) {
|
||||
data, err := bytesFromJS(args[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, checkFontGlyphs(data, args[1].String())
|
||||
}
|
||||
|
||||
// styleText 批量修改文字样式,字体预检通过后交由库原子提交
|
||||
// 入参: args 页码、对象标识、字体数据、字号和颜色,null字体与颜色保持原值
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func styleText(args []js.Value) (any, error) {
|
||||
return changeObjects(func() error {
|
||||
page, ids := args[0].Int(), stringsFromJS(args[1])
|
||||
style := ofdgo.TextStyle{Size: args[3].Float()}
|
||||
if !args[2].IsNull() {
|
||||
data, err := bytesFromJS(args[2])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var value strings.Builder
|
||||
for _, id := range ids {
|
||||
object, err := currentEditor.Object(page, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if object.Type != "TextObject" {
|
||||
return fmt.Errorf("object %q is not text", id)
|
||||
}
|
||||
value.WriteString(object.TextObject.Text())
|
||||
}
|
||||
if err := checkFontGlyphs(data, value.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
style.Font, err = currentEditor.AddFont(FontFile{Data: data}, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if !args[4].IsNull() {
|
||||
var color ofdgo.FillColor
|
||||
if err := setEditorColor(&color, args[4].String()); err != nil {
|
||||
return err
|
||||
}
|
||||
style.Color = color.Value
|
||||
}
|
||||
return currentEditor.StyleText(page, ids, style)
|
||||
})
|
||||
}
|
||||
|
||||
// setTextColor 将浏览器RGB色值写入文字对象,保留颜色透明度
|
||||
// 入参: object 文字对象, value 十六进制色值
|
||||
// 返回: error 错误信息
|
||||
func setTextColor(object *ofdgo.TextObject, value string) error {
|
||||
if object.FillColor == nil {
|
||||
object.FillColor = &ofdgo.FillColor{}
|
||||
}
|
||||
return setEditorColor(object.FillColor, value)
|
||||
}
|
||||
|
||||
// setEditorColor 写入当前文档的RGB纯色,保留透明度并替换原颜色空间和索引
|
||||
// 入参: fill 颜色, value 十六进制色值
|
||||
// 返回: error 错误信息
|
||||
@@ -3294,84 +3269,6 @@ func updatePathStyle(args []js.Value) (any, error) {
|
||||
})
|
||||
}
|
||||
|
||||
// insertText 使用选定字体创建文字对象
|
||||
// 入参: args 页码、内容、字体数据、横纵坐标、字号、颜色、框宽、折行、对齐、行距、字距及左右和首行缩进
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func insertText(args []js.Value) (any, error) {
|
||||
if currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
}
|
||||
data, err := bytesFromJS(args[2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fontID, err := currentEditor.AddFont(FontFile{Data: data}, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page := args[0].Int()
|
||||
content, err := currentSession.pageContent(page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
box, err := currentSession.pageBox(page, content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x, y := args[3].Float(), args[4].Float()
|
||||
width := args[7].Float()
|
||||
if width == 0 {
|
||||
width = 80
|
||||
}
|
||||
box = ofdgo.Box{X: x, Y: y, W: math.Min(width, box.W-x), H: box.H - y}
|
||||
object := ofdgo.GraphicObject{Type: "TextObject", TextObject: ofdgo.TextObject{
|
||||
Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H),
|
||||
Font: fontID, Size: args[5].Float(),
|
||||
}}
|
||||
if err := currentEditor.LayoutText(&object.TextObject, args[1].String(), ofdgo.TextLayout{Wrap: args[8].Bool(), Align: args[9].String(), LineHeight: args[10].Float(), LetterSpacing: args[11].Float(), LeftIndent: args[12].Float(), RightIndent: args[13].Float(), FirstLineIndent: args[14].Float()}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := setTextColor(&object.TextObject, args[6].String()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return insertEditorObject(page, object, args[15:])
|
||||
}
|
||||
|
||||
// layoutText 调整文字框和段落排版,保留软换行前的原文
|
||||
// 入参: args 页码、对象标识、本地左侧偏移、宽度、折行、对齐、行距、字距及左右和首行缩进
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func layoutText(args []js.Value) (any, error) {
|
||||
if currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
}
|
||||
page, id := args[0].Int(), args[1].String()
|
||||
object, err := currentEditor.Object(page, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if object.Type != "TextObject" {
|
||||
return nil, fmt.Errorf("object is not text")
|
||||
}
|
||||
if !args[2].IsNull() {
|
||||
object.TextObject, err = object.TextObject.ResizeTextFrame(args[2].Float(), args[3].Float())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
value, _ := object.TextObject.TextLayout()
|
||||
if err := currentEditor.LayoutText(&object.TextObject, value, ofdgo.TextLayout{Wrap: args[4].Bool(), Align: args[5].String(), LineHeight: args[6].Float(), LetterSpacing: args[7].Float(), LeftIndent: args[8].Float(), RightIndent: args[9].Float(), FirstLineIndent: args[10].Float()}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
revision := currentEditor.Revision()
|
||||
if err := currentEditor.UpdateObject(page, id, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentEditor.Revision() == revision {
|
||||
return editorSummary(), nil
|
||||
}
|
||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||
}
|
||||
|
||||
// editorBox 将库层毫米边界传递给画布交互层
|
||||
// 入参: box 毫米坐标中的矩形范围
|
||||
// 返回: map[string]any 可直接传递给JavaScript的横纵坐标和宽高
|
||||
@@ -3631,14 +3528,107 @@ func editorOperationProgress(callback js.Value) func(string, int, int) error {
|
||||
}
|
||||
}
|
||||
|
||||
// saveDocument 分块写出当前编辑文档或指定页面
|
||||
// 入参: args 数据写出回调、可选准备进度回调和可选页面索引
|
||||
// 返回: any 保存结果, error 错误信息
|
||||
func saveDocument(args []js.Value) (any, error) {
|
||||
if currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
// credentialsFromJS 解析本次打开的口令或本机证书私钥
|
||||
// 入参: value 解锁表单数据
|
||||
// 返回: ofdgo.Credentials 会话凭据, error 错误信息
|
||||
func credentialsFromJS(value js.Value) (ofdgo.Credentials, error) {
|
||||
credentials := ofdgo.Credentials{UserName: value.Get("userName").String()}
|
||||
if keyValue := value.Get("key"); !keyValue.IsNull() && !keyValue.IsUndefined() {
|
||||
key, err := bytesFromJS(keyValue)
|
||||
if err != nil {
|
||||
return credentials, err
|
||||
}
|
||||
return saveEditor(currentEditor, args)
|
||||
defer clear(key)
|
||||
certificate, err := bytesFromJS(value.Get("certificate"))
|
||||
if err != nil {
|
||||
return credentials, err
|
||||
}
|
||||
var password []byte
|
||||
if field := value.Get("keyPassword"); !field.IsUndefined() && !field.IsNull() {
|
||||
password, err = bytesFromJS(field)
|
||||
if err != nil {
|
||||
return credentials, err
|
||||
}
|
||||
defer clear(password)
|
||||
}
|
||||
signer, certificate, err := ofdgo.ParseSignatureIdentity(certificate, key, password)
|
||||
if err != nil {
|
||||
return credentials, ofdgo.ErrInvalidCredentials
|
||||
}
|
||||
decrypter, ok := signer.(crypto.Decrypter)
|
||||
if !ok {
|
||||
return credentials, ofdgo.ErrInvalidCredentials
|
||||
}
|
||||
credentials.Certificate, credentials.Decrypter = certificate, decrypter
|
||||
return credentials, nil
|
||||
}
|
||||
var err error
|
||||
credentials.Password, err = bytesFromJS(value.Get("password"))
|
||||
return credentials, err
|
||||
}
|
||||
|
||||
// securityFilesFromJS 读取会话内的本机证据文件
|
||||
// 入参: value 二进制数组列表
|
||||
// 返回: [][]byte 文件内容, error 错误信息
|
||||
func securityFilesFromJS(value js.Value) ([][]byte, error) {
|
||||
if value.IsUndefined() || value.IsNull() {
|
||||
return nil, nil
|
||||
}
|
||||
files := make([][]byte, value.Length())
|
||||
for i := range files {
|
||||
var err error
|
||||
files[i], err = bytesFromJS(value.Index(i))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// verifySignatures 按本机信任锚和离线证据重新验签,不设置联网回调
|
||||
// 入参: args 信任根、辅助证书、时间戳及撤销证据
|
||||
// 返回: any 分项报告, error 错误信息
|
||||
func verifySignatures(args []js.Value) (any, error) {
|
||||
if currentSession == nil || len(args) == 0 {
|
||||
return nil, fmt.Errorf("missing signature verification arguments")
|
||||
}
|
||||
input := args[0]
|
||||
files := make(map[string][][]byte)
|
||||
for _, name := range []string{"roots", "certificates", "timestampRoots", "tokens", "crls", "ocsp"} {
|
||||
data, err := securityFilesFromJS(input.Get(name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files[name] = data
|
||||
}
|
||||
now := time.Now()
|
||||
policy := ofdgo.SignaturePolicy{RejectWeakAlgorithms: true, RequireDocumentCoverage: true,
|
||||
RequireTimestamp: input.Get("requireTimestamp").Bool(), RequireRevocation: input.Get("requireRevocation").Bool()}
|
||||
options := []ofdgo.SignatureVerifyOption{
|
||||
ofdgo.WithSignatureVerifyTime(now), ofdgo.WithSignaturePolicy(policy),
|
||||
ofdgo.WithSignatureTrustCerts(files["roots"]...), ofdgo.WithSignatureCerts(files["certificates"]...),
|
||||
}
|
||||
if policy.RequireTimestamp || len(files["timestampRoots"])+len(files["tokens"]) > 0 {
|
||||
options = append(options, ofdgo.WithSignatureTimestamp(ofdgo.SignatureTimestampOptions{
|
||||
TrustCerts: files["timestampRoots"], Certificates: files["certificates"], Tokens: files["tokens"], Required: policy.RequireTimestamp, VerifyTime: &now,
|
||||
}))
|
||||
}
|
||||
if policy.RequireRevocation || len(files["crls"])+len(files["ocsp"]) > 0 {
|
||||
options = append(options, ofdgo.WithSignatureRevocation(ofdgo.SignatureRevocationOptions{
|
||||
SignatureRevocationEvidence: ofdgo.SignatureRevocationEvidence{CRLs: files["crls"], OCSPResponses: files["ocsp"]},
|
||||
Certificates: append(files["certificates"], files["roots"]...), Required: policy.RequireRevocation, VerifyTime: &now,
|
||||
}))
|
||||
}
|
||||
reports, err := currentSession.Reader.VerifySignatures(options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infos := make([]SignatureInfo, len(reports))
|
||||
for i, report := range reports {
|
||||
infos[i] = signatureInfo(report)
|
||||
}
|
||||
currentSession.signatures, currentSession.signatureError, currentSession.signaturesRead = infos, nil, true
|
||||
return map[string]any{"signatures": infos, "signatureCount": len(infos), "signatureError": ""}, nil
|
||||
}
|
||||
|
||||
// saveEncrypted 以独立快照加密另存,不修改当前文档的输出策略
|
||||
@@ -3790,6 +3780,16 @@ func saveSigned(args []js.Value) (any, error) {
|
||||
return successResult(map[string]any{"mime": "application/ofd", "label": "OFD"}), nil
|
||||
}
|
||||
|
||||
// saveDocument 分块写出当前编辑文档或指定页面
|
||||
// 入参: args 数据写出回调、可选准备进度回调和可选页面索引
|
||||
// 返回: any 保存结果, error 错误信息
|
||||
func saveDocument(args []js.Value) (any, error) {
|
||||
if currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being edited")
|
||||
}
|
||||
return saveEditor(currentEditor, args)
|
||||
}
|
||||
|
||||
// saveEditor 统一分块写出及进度取消,加密策略由库层执行
|
||||
// 入参: editor 待写出文档, args 写出回调、进度回调和可选页面索引
|
||||
// 返回: any 保存结果, error 错误信息
|
||||
|
||||
+191
-191
@@ -39,6 +39,97 @@ type FontFile = ofdgo.FontFile
|
||||
// FontInfo OFD字体信息
|
||||
type FontInfo = ofdgo.FontInfo
|
||||
|
||||
// Session WebUI文档会话
|
||||
type Session struct {
|
||||
Reader *ofdgo.Reader
|
||||
Renderer *ofdgo.Renderer
|
||||
fontFS *ofdgo.FontFS
|
||||
doc *ofdgo.Document
|
||||
pageCache map[int]*ofdgo.PageContent
|
||||
boxCache []pageBoxInfo
|
||||
textCache map[int]*ofdgo.PageText
|
||||
svgFonts map[string][]byte
|
||||
fontScan *ofdgo.FontInfoScanner
|
||||
fontInfos []FontInfo
|
||||
fontsRead bool
|
||||
fontAnnotations bool
|
||||
signatures []SignatureInfo
|
||||
signatureError error
|
||||
signaturesRead bool
|
||||
editing bool
|
||||
}
|
||||
|
||||
// pageBoxInfo 按页索引缓存真实区域,valid区分未读取与零值区域
|
||||
type pageBoxInfo struct {
|
||||
box ofdgo.Box
|
||||
valid bool
|
||||
}
|
||||
|
||||
// DocumentInfo 文档信息
|
||||
type DocumentInfo struct {
|
||||
Encryption EncryptionInfo `json:"encryption"`
|
||||
Version string `json:"version"`
|
||||
DocType string `json:"docType"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Subject string `json:"subject"`
|
||||
CreationDate string `json:"creationDate"`
|
||||
ModDate string `json:"modDate"`
|
||||
PageCount int `json:"pageCount"`
|
||||
FontCount int `json:"fontCount"`
|
||||
SignatureCount int `json:"signatureCount"`
|
||||
SignatureError string `json:"signatureError,omitempty"`
|
||||
AttachmentError string `json:"attachmentError,omitempty"`
|
||||
Attachments []AttachmentInfo `json:"attachments,omitempty"`
|
||||
Fonts []FontInfo `json:"fonts"`
|
||||
Signatures []SignatureInfo `json:"signatures"`
|
||||
Pages []PageInfo `json:"pages"`
|
||||
Outlines []OutlineInfo `json:"outlines,omitempty"`
|
||||
DetailsPending bool `json:"detailsPending,omitempty"`
|
||||
}
|
||||
|
||||
// OutlineInfo 目录节点信息
|
||||
type OutlineInfo = ofdgo.OutlineInfo
|
||||
|
||||
// AttachmentInfo 可见附件信息,Size单位为KB
|
||||
type AttachmentInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Size *float64 `json:"size,omitempty"`
|
||||
FileName string `json:"fileName"`
|
||||
}
|
||||
|
||||
// PageInfo 页面信息
|
||||
type PageInfo struct {
|
||||
Index int `json:"index"`
|
||||
ID string `json:"id"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}
|
||||
|
||||
// PageSVG 页面SVG结果
|
||||
type PageSVG struct {
|
||||
Index int `json:"index"`
|
||||
Number int `json:"number"`
|
||||
ID string `json:"id"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
SVG string `json:"svg"`
|
||||
Links []ofdgo.PageLink `json:"-"`
|
||||
Fonts []ofdgo.SVGFont `json:"-"`
|
||||
Images []ofdgo.SVGImage `json:"-"`
|
||||
Annotations []ofdgo.AnnotationInfo `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// ExportFormat 导出格式
|
||||
type ExportFormat struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Extension string `json:"extension"`
|
||||
MIME string `json:"mime"`
|
||||
}
|
||||
|
||||
// SignatureInfo 签名验证信息
|
||||
type SignatureInfo struct {
|
||||
DocIndex int `json:"docIndex"`
|
||||
@@ -144,55 +235,6 @@ type SignatureStampInfo struct {
|
||||
Height float64 `json:"height,omitempty"`
|
||||
}
|
||||
|
||||
// Session WebUI文档会话
|
||||
type Session struct {
|
||||
Reader *ofdgo.Reader
|
||||
Renderer *ofdgo.Renderer
|
||||
fontFS *ofdgo.FontFS
|
||||
doc *ofdgo.Document
|
||||
pageCache map[int]*ofdgo.PageContent
|
||||
boxCache []pageBoxInfo
|
||||
textCache map[int]*ofdgo.PageText
|
||||
svgFonts map[string][]byte
|
||||
fontScan *ofdgo.FontInfoScanner
|
||||
fontInfos []FontInfo
|
||||
fontsRead bool
|
||||
fontAnnotations bool
|
||||
signatures []SignatureInfo
|
||||
signatureError error
|
||||
signaturesRead bool
|
||||
editing bool
|
||||
}
|
||||
|
||||
// pageBoxInfo 按页索引缓存真实区域,valid区分未读取与零值区域
|
||||
type pageBoxInfo struct {
|
||||
box ofdgo.Box
|
||||
valid bool
|
||||
}
|
||||
|
||||
// DocumentInfo 文档信息
|
||||
type DocumentInfo struct {
|
||||
Encryption EncryptionInfo `json:"encryption"`
|
||||
Version string `json:"version"`
|
||||
DocType string `json:"docType"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
Subject string `json:"subject"`
|
||||
CreationDate string `json:"creationDate"`
|
||||
ModDate string `json:"modDate"`
|
||||
PageCount int `json:"pageCount"`
|
||||
FontCount int `json:"fontCount"`
|
||||
SignatureCount int `json:"signatureCount"`
|
||||
SignatureError string `json:"signatureError,omitempty"`
|
||||
AttachmentError string `json:"attachmentError,omitempty"`
|
||||
Attachments []AttachmentInfo `json:"attachments,omitempty"`
|
||||
Fonts []FontInfo `json:"fonts"`
|
||||
Signatures []SignatureInfo `json:"signatures"`
|
||||
Pages []PageInfo `json:"pages"`
|
||||
Outlines []OutlineInfo `json:"outlines,omitempty"`
|
||||
DetailsPending bool `json:"detailsPending,omitempty"`
|
||||
}
|
||||
|
||||
// EncryptionInfo 仅向界面传递非敏感加密状态
|
||||
type EncryptionInfo struct {
|
||||
Encrypted bool `json:"encrypted"`
|
||||
@@ -201,55 +243,6 @@ type EncryptionInfo struct {
|
||||
Layers int `json:"layers,omitempty"`
|
||||
}
|
||||
|
||||
// encryptionInfo 获取当前文档的加密来源,不包含凭据
|
||||
// 返回: EncryptionInfo 加密状态
|
||||
func (s *Session) encryptionInfo() EncryptionInfo {
|
||||
info := s.Reader.Encryption()
|
||||
return EncryptionInfo{Encrypted: info.Encrypted, Method: info.Method, Users: info.Users, Layers: info.Layers}
|
||||
}
|
||||
|
||||
// OutlineInfo 目录节点信息
|
||||
type OutlineInfo = ofdgo.OutlineInfo
|
||||
|
||||
// AttachmentInfo 可见附件信息,Size单位为KB
|
||||
type AttachmentInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Format string `json:"format,omitempty"`
|
||||
Size *float64 `json:"size,omitempty"`
|
||||
FileName string `json:"fileName"`
|
||||
}
|
||||
|
||||
// PageInfo 页面信息
|
||||
type PageInfo struct {
|
||||
Index int `json:"index"`
|
||||
ID string `json:"id"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
}
|
||||
|
||||
// PageSVG 页面SVG结果
|
||||
type PageSVG struct {
|
||||
Index int `json:"index"`
|
||||
Number int `json:"number"`
|
||||
ID string `json:"id"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
SVG string `json:"svg"`
|
||||
Links []ofdgo.PageLink `json:"-"`
|
||||
Fonts []ofdgo.SVGFont `json:"-"`
|
||||
Images []ofdgo.SVGImage `json:"-"`
|
||||
Annotations []ofdgo.AnnotationInfo `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
// ExportFormat 导出格式
|
||||
type ExportFormat struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Extension string `json:"extension"`
|
||||
MIME string `json:"mime"`
|
||||
}
|
||||
|
||||
// supportedExportFormats 导出格式列表
|
||||
var supportedExportFormats = []ExportFormat{
|
||||
{Value: "pdf", Label: "PDF", Extension: "pdf", MIME: "application/pdf"},
|
||||
@@ -260,30 +253,6 @@ var supportedExportFormats = []ExportFormat{
|
||||
{Value: "txt", Label: "TXT", Extension: "txt", MIME: "text/plain"},
|
||||
}
|
||||
|
||||
// ExportFormats 获取导出格式
|
||||
// 返回: []ExportFormat 导出格式列表
|
||||
func ExportFormats() []ExportFormat {
|
||||
formats := make([]ExportFormat, len(supportedExportFormats))
|
||||
copy(formats, supportedExportFormats)
|
||||
return formats
|
||||
}
|
||||
|
||||
// exportFormat 获取导出格式
|
||||
// 入参: value 格式值
|
||||
// 返回: ExportFormat 导出格式, bool 是否支持
|
||||
func exportFormat(value string) (ExportFormat, bool) {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "jpeg" {
|
||||
value = "jpg"
|
||||
}
|
||||
for _, format := range supportedExportFormats {
|
||||
if format.Value == value {
|
||||
return format, true
|
||||
}
|
||||
}
|
||||
return ExportFormat{}, false
|
||||
}
|
||||
|
||||
// Open 打开浏览器内存中的OFD文档
|
||||
// 入参: data OFD文件数据, opts 打开选项
|
||||
// 返回: *Session 文档会话, error 错误信息
|
||||
@@ -647,6 +616,64 @@ func (s *Session) SVGFontData(name string) ([]byte, error) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// pageContent 读取页面,复用最近一次解析结果
|
||||
// 入参: index 页面索引
|
||||
// 返回: *ofdgo.PageContent 页面内容, error 错误信息
|
||||
func (s *Session) pageContent(index int) (*ofdgo.PageContent, error) {
|
||||
if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil {
|
||||
return nil, fmt.Errorf("ofd document is not opened")
|
||||
}
|
||||
if page, ok := s.pageCache[index]; ok {
|
||||
return page, nil
|
||||
}
|
||||
clear(s.pageCache)
|
||||
page, err := s.Reader.PageContentByIndex(index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.pageCache[index] = page
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// pageBox 获取页面物理区域
|
||||
// 入参: index 页面索引, page 页面内容
|
||||
// 返回: ofdgo.Box 页面物理区域, error 错误信息
|
||||
func (s *Session) pageBox(index int, page *ofdgo.PageContent) (ofdgo.Box, error) {
|
||||
if cached := s.boxCache[index]; cached.valid {
|
||||
return cached.box, nil
|
||||
}
|
||||
box, err := s.Renderer.GetPageBox(page)
|
||||
if err != nil {
|
||||
return ofdgo.Box{}, err
|
||||
}
|
||||
s.boxCache[index] = pageBoxInfo{box: box, valid: true}
|
||||
return box, nil
|
||||
}
|
||||
|
||||
// ExportFormats 获取导出格式
|
||||
// 返回: []ExportFormat 导出格式列表
|
||||
func ExportFormats() []ExportFormat {
|
||||
formats := make([]ExportFormat, len(supportedExportFormats))
|
||||
copy(formats, supportedExportFormats)
|
||||
return formats
|
||||
}
|
||||
|
||||
// exportFormat 获取导出格式
|
||||
// 入参: value 格式值
|
||||
// 返回: ExportFormat 导出格式, bool 是否支持
|
||||
func exportFormat(value string) (ExportFormat, bool) {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "jpeg" {
|
||||
value = "jpg"
|
||||
}
|
||||
for _, format := range supportedExportFormats {
|
||||
if format.Value == value {
|
||||
return format, true
|
||||
}
|
||||
}
|
||||
return ExportFormat{}, false
|
||||
}
|
||||
|
||||
// ExportPage 导出单页
|
||||
// 入参: index 页面索引, value 导出格式, dpi 图片DPI, writer 输出流
|
||||
// 返回: ExportFormat 导出格式, error 错误信息
|
||||
@@ -666,6 +693,48 @@ func (s *Session) ExportPage(index int, value string, dpi float64, writer io.Wri
|
||||
return format, renderer.RenderTo(page, writer, format.Value)
|
||||
}
|
||||
|
||||
// ExportDocument 导出文档为PDF、TXT或逐页打包ZIP
|
||||
// 入参: value 导出格式, dpi 图片DPI, writer 输出流, indices 零基页面索引,省略则全部
|
||||
// 返回: ExportFormat 导出格式, error 错误信息
|
||||
func (s *Session) ExportDocument(value string, dpi float64, writer io.Writer, indices ...int) (ExportFormat, error) {
|
||||
format, ok := exportFormat(value)
|
||||
if !ok {
|
||||
return ExportFormat{}, fmt.Errorf("unsupported export format %s", value)
|
||||
}
|
||||
if format.Value == "pdf" {
|
||||
return format, s.ExportPDF(writer, indices...)
|
||||
}
|
||||
if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil {
|
||||
return ExportFormat{}, fmt.Errorf("ofd document is not opened")
|
||||
}
|
||||
if format.Value == "txt" {
|
||||
return format, s.Renderer.RenderToMultiPageText(writer, indices...)
|
||||
}
|
||||
renderer := *s.Renderer
|
||||
if dpi > 0 && (format.Value == "png" || format.Value == "jpg") {
|
||||
renderer.DPI = dpi
|
||||
}
|
||||
err := renderer.RenderToZIP(writer, format.Value, indices...)
|
||||
return ExportFormat{Value: "zip", Label: "ZIP", Extension: "zip", MIME: "application/zip"}, err
|
||||
}
|
||||
|
||||
// ExportPDF 导出文档为PDF
|
||||
// 入参: writer 输出流, indices 零基页面索引,省略则全部
|
||||
// 返回: error 错误信息
|
||||
func (s *Session) ExportPDF(writer io.Writer, indices ...int) error {
|
||||
if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil {
|
||||
return fmt.Errorf("ofd document is not opened")
|
||||
}
|
||||
return s.Renderer.RenderToMultiPagePDF(writer, indices...)
|
||||
}
|
||||
|
||||
// encryptionInfo 获取当前文档的加密来源,不包含凭据
|
||||
// 返回: EncryptionInfo 加密状态
|
||||
func (s *Session) encryptionInfo() EncryptionInfo {
|
||||
info := s.Reader.Encryption()
|
||||
return EncryptionInfo{Encrypted: info.Encrypted, Method: info.Method, Users: info.Users, Layers: info.Layers}
|
||||
}
|
||||
|
||||
// signatureInfos 获取签名验证信息
|
||||
// 返回: []SignatureInfo 签名验证信息, error 错误信息
|
||||
func (s *Session) signatureInfos() ([]SignatureInfo, error) {
|
||||
@@ -855,72 +924,3 @@ func signatureStampInfos(positions []ofdgo.SignatureStampPosition) []SignatureSt
|
||||
}
|
||||
return infos
|
||||
}
|
||||
|
||||
// ExportDocument 导出文档为PDF、TXT或逐页打包ZIP
|
||||
// 入参: value 导出格式, dpi 图片DPI, writer 输出流, indices 零基页面索引,省略则全部
|
||||
// 返回: ExportFormat 导出格式, error 错误信息
|
||||
func (s *Session) ExportDocument(value string, dpi float64, writer io.Writer, indices ...int) (ExportFormat, error) {
|
||||
format, ok := exportFormat(value)
|
||||
if !ok {
|
||||
return ExportFormat{}, fmt.Errorf("unsupported export format %s", value)
|
||||
}
|
||||
if format.Value == "pdf" {
|
||||
return format, s.ExportPDF(writer, indices...)
|
||||
}
|
||||
if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil {
|
||||
return ExportFormat{}, fmt.Errorf("ofd document is not opened")
|
||||
}
|
||||
if format.Value == "txt" {
|
||||
return format, s.Renderer.RenderToMultiPageText(writer, indices...)
|
||||
}
|
||||
renderer := *s.Renderer
|
||||
if dpi > 0 && (format.Value == "png" || format.Value == "jpg") {
|
||||
renderer.DPI = dpi
|
||||
}
|
||||
err := renderer.RenderToZIP(writer, format.Value, indices...)
|
||||
return ExportFormat{Value: "zip", Label: "ZIP", Extension: "zip", MIME: "application/zip"}, err
|
||||
}
|
||||
|
||||
// ExportPDF 导出文档为PDF
|
||||
// 入参: writer 输出流, indices 零基页面索引,省略则全部
|
||||
// 返回: error 错误信息
|
||||
func (s *Session) ExportPDF(writer io.Writer, indices ...int) error {
|
||||
if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil {
|
||||
return fmt.Errorf("ofd document is not opened")
|
||||
}
|
||||
return s.Renderer.RenderToMultiPagePDF(writer, indices...)
|
||||
}
|
||||
|
||||
// pageContent 读取页面,复用最近一次解析结果
|
||||
// 入参: index 页面索引
|
||||
// 返回: *ofdgo.PageContent 页面内容, error 错误信息
|
||||
func (s *Session) pageContent(index int) (*ofdgo.PageContent, error) {
|
||||
if s == nil || s.Reader == nil || s.Renderer == nil || s.doc == nil {
|
||||
return nil, fmt.Errorf("ofd document is not opened")
|
||||
}
|
||||
if page, ok := s.pageCache[index]; ok {
|
||||
return page, nil
|
||||
}
|
||||
clear(s.pageCache)
|
||||
page, err := s.Reader.PageContentByIndex(index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.pageCache[index] = page
|
||||
return page, nil
|
||||
}
|
||||
|
||||
// pageBox 获取页面物理区域
|
||||
// 入参: index 页面索引, page 页面内容
|
||||
// 返回: ofdgo.Box 页面物理区域, error 错误信息
|
||||
func (s *Session) pageBox(index int, page *ofdgo.PageContent) (ofdgo.Box, error) {
|
||||
if cached := s.boxCache[index]; cached.valid {
|
||||
return cached.box, nil
|
||||
}
|
||||
box, err := s.Renderer.GetPageBox(page)
|
||||
if err != nil {
|
||||
return ofdgo.Box{}, err
|
||||
}
|
||||
s.boxCache[index] = pageBoxInfo{box: box, valid: true}
|
||||
return box, nil
|
||||
}
|
||||
+3
-55
@@ -20,7 +20,6 @@ import (
|
||||
"image"
|
||||
"io"
|
||||
"reflect"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ErrBackendUnavailable 表示未配置所需后端,不会自动切换其他实现
|
||||
@@ -106,7 +105,7 @@ type EPSBackend interface {
|
||||
// RenderBackends 按职责组合后端,nil表示明确禁用,不隐式回退
|
||||
// 实例及其Renderer需串行使用,第三方实现无需引用任何内置绘图库
|
||||
type RenderBackends struct {
|
||||
Resources FontResourceBackend
|
||||
FontResources FontResourceBackend
|
||||
Fonts FontBackend
|
||||
Geometry GeometryBackend
|
||||
Compiler PageCompiler
|
||||
@@ -118,7 +117,7 @@ type RenderBackends struct {
|
||||
|
||||
// BackendInfo 描述各项能力实际使用的后端,空标识表示未配置
|
||||
type BackendInfo struct {
|
||||
Resources string `json:"resources"`
|
||||
FontResources string `json:"fontResources"`
|
||||
Fonts string `json:"fonts"`
|
||||
Geometry string `json:"geometry"`
|
||||
Compiler string `json:"compiler"`
|
||||
@@ -128,61 +127,10 @@ type BackendInfo struct {
|
||||
EPS string `json:"eps"`
|
||||
}
|
||||
|
||||
// NewRenderBackends 创建已注册后端组合,各项能力通过Info报告实际提供者
|
||||
// 入参: name 已注册后端标识
|
||||
// 返回: RenderBackends 后端组合, error 未知后端错误
|
||||
func NewRenderBackends(name string) (RenderBackends, error) {
|
||||
backendRegistry.RLock()
|
||||
factory := backendRegistry.factories[name]
|
||||
backendRegistry.RUnlock()
|
||||
if factory == nil {
|
||||
return RenderBackends{}, fmt.Errorf("unknown render backend %q", name)
|
||||
}
|
||||
return factory(), nil
|
||||
}
|
||||
|
||||
// RenderBackendInfos 列出已注册组合及其实际能力,供原生程序和WASM共用
|
||||
// 返回: map[string]BackendInfo 后端能力
|
||||
func RenderBackendInfos() map[string]BackendInfo {
|
||||
backendRegistry.RLock()
|
||||
factories := make(map[string]func() RenderBackends, len(backendRegistry.factories))
|
||||
for name, factory := range backendRegistry.factories {
|
||||
factories[name] = factory
|
||||
}
|
||||
backendRegistry.RUnlock()
|
||||
result := make(map[string]BackendInfo, len(factories))
|
||||
for name, factory := range factories {
|
||||
result[name] = factory().Info()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// backendRegistry 保存后端工厂,不共享有状态的后端实例
|
||||
var backendRegistry = struct {
|
||||
sync.RWMutex
|
||||
factories map[string]func() RenderBackends
|
||||
}{factories: make(map[string]func() RenderBackends)}
|
||||
|
||||
// RegisterRenderBackend 注册后端组合,工厂应返回独立实例,重复标识不会覆盖已有实现
|
||||
// 入参: name 后端标识, factory 后端工厂
|
||||
// 返回: error 无效或重复注册错误
|
||||
func RegisterRenderBackend(name string, factory func() RenderBackends) error {
|
||||
if name == "" || factory == nil {
|
||||
return fmt.Errorf("backend name and factory are required")
|
||||
}
|
||||
backendRegistry.Lock()
|
||||
defer backendRegistry.Unlock()
|
||||
if _, exists := backendRegistry.factories[name]; exists {
|
||||
return fmt.Errorf("render backend %q already registered", name)
|
||||
}
|
||||
backendRegistry.factories[name] = factory
|
||||
return nil
|
||||
}
|
||||
|
||||
// Info 返回配置中各能力的提供者,不依赖WebUI推断
|
||||
// 返回: BackendInfo 后端能力
|
||||
func (b RenderBackends) Info() BackendInfo {
|
||||
return BackendInfo{Resources: backendName(b.Resources), Fonts: backendName(b.Fonts), Geometry: backendName(b.Geometry), Compiler: backendName(b.Compiler), Raster: backendName(b.Raster), SVG: backendName(b.SVG), PDF: backendName(b.PDF), EPS: backendName(b.EPS)}
|
||||
return BackendInfo{FontResources: backendName(b.FontResources), Fonts: backendName(b.Fonts), Geometry: backendName(b.Geometry), Compiler: backendName(b.Compiler), Raster: backendName(b.Raster), SVG: backendName(b.SVG), PDF: backendName(b.PDF), EPS: backendName(b.EPS)}
|
||||
}
|
||||
|
||||
// backendName 获取已配置的后端标识
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// backendRegistry 保存后端工厂,不共享有状态的后端实例
|
||||
var backendRegistry = struct {
|
||||
sync.RWMutex
|
||||
factories map[string]func() RenderBackends
|
||||
}{factories: make(map[string]func() RenderBackends)}
|
||||
|
||||
// RegisterRenderBackend 注册后端组合,工厂应返回独立实例,重复标识不会覆盖已有实现
|
||||
// 入参: name 后端标识, factory 后端工厂
|
||||
// 返回: error 无效或重复注册错误
|
||||
func RegisterRenderBackend(name string, factory func() RenderBackends) error {
|
||||
if name == "" || factory == nil {
|
||||
return fmt.Errorf("backend name and factory are required")
|
||||
}
|
||||
backendRegistry.Lock()
|
||||
defer backendRegistry.Unlock()
|
||||
if _, exists := backendRegistry.factories[name]; exists {
|
||||
return fmt.Errorf("render backend %q already registered", name)
|
||||
}
|
||||
backendRegistry.factories[name] = factory
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewRenderBackends 创建已注册后端组合,各项能力通过Info报告实际提供者
|
||||
// 入参: name 已注册后端标识
|
||||
// 返回: RenderBackends 后端组合, error 未知后端错误
|
||||
func NewRenderBackends(name string) (RenderBackends, error) {
|
||||
backendRegistry.RLock()
|
||||
factory := backendRegistry.factories[name]
|
||||
backendRegistry.RUnlock()
|
||||
if factory == nil {
|
||||
return RenderBackends{}, fmt.Errorf("unknown render backend %q", name)
|
||||
}
|
||||
return factory(), nil
|
||||
}
|
||||
|
||||
// RenderBackendInfos 列出已注册组合及其实际能力,供原生程序和WASM共用
|
||||
// 返回: map[string]BackendInfo 后端能力
|
||||
func RenderBackendInfos() map[string]BackendInfo {
|
||||
backendRegistry.RLock()
|
||||
factories := make(map[string]func() RenderBackends, len(backendRegistry.factories))
|
||||
for name, factory := range backendRegistry.factories {
|
||||
factories[name] = factory
|
||||
}
|
||||
backendRegistry.RUnlock()
|
||||
result := make(map[string]BackendInfo, len(factories))
|
||||
for name, factory := range factories {
|
||||
result[name] = factory().Info()
|
||||
}
|
||||
return result
|
||||
}
|
||||
+1
-35
@@ -85,7 +85,7 @@ func (e *Editor) SetPageCompiler(compiler PageCompiler) {
|
||||
// 入参: backends 后端组合,未配置的能力不会沿用默认实现
|
||||
func (e *Editor) SetRenderBackends(backends RenderBackends) {
|
||||
fontsChanged := !sameBackend(e.backends.Fonts, backends.Fonts)
|
||||
if !sameBackend(e.backends.Resources, backends.Resources) {
|
||||
if !sameBackend(e.backends.FontResources, backends.FontResources) {
|
||||
e.fonts = make(map[string]*FontResource)
|
||||
for i := range e.resources {
|
||||
e.resources[i].subset = nil
|
||||
@@ -565,40 +565,6 @@ func (e *Editor) nextID() string {
|
||||
return strconv.Itoa(e.maxID)
|
||||
}
|
||||
|
||||
// AddFont 注册OpenType字体,集合字体按索引提取,重复资源复用标识,引用后写入文档
|
||||
// 入参: file 字体文件, index 集合内字体索引,单字体为0
|
||||
// 返回: string 字体资源标识, error 错误信息
|
||||
func (e *Editor) AddFont(file FontFile, index int) (string, error) {
|
||||
if e.backends.Resources == nil {
|
||||
return "", fmt.Errorf("font resources: %w", ErrBackendUnavailable)
|
||||
}
|
||||
parsed, err := e.backends.Resources.OpenFontResource(file, index)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data := parsed.Data
|
||||
key := editorResourceKey{checksum: sha256.Sum256(data)}
|
||||
if id, ok := e.resourceID[key]; ok {
|
||||
return id, nil
|
||||
}
|
||||
if err := e.prepareSourceIDs(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
id := e.nextID()
|
||||
definition := parsed.Font
|
||||
definition.ID = id
|
||||
resource := editorResource{
|
||||
name: e.packageName("Res/Fonts/Font_" + id + parsed.Extension),
|
||||
data: data,
|
||||
font: &definition,
|
||||
}
|
||||
resource.font.FontFile = "/" + resource.name
|
||||
e.resources = append(e.resources, resource)
|
||||
e.fonts[id] = parsed
|
||||
e.resourceID[key] = id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// AddImage 注册PNG或JPEG图片,重复资源复用标识,引用后写入文档
|
||||
// 不透明纯黑白PNG仅在无损JBIG2编码更小时转换,其他图片保留原始编码
|
||||
// 入参: data 图片数据
|
||||
|
||||
+49
-151
@@ -16,14 +16,12 @@ package ofdgo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/tdewolff/font"
|
||||
)
|
||||
|
||||
// editorFontUsage 汇总原文档字体的字符、显式字形及无法确定的引用
|
||||
@@ -33,6 +31,47 @@ type editorFontUsage struct {
|
||||
unsafe bool
|
||||
}
|
||||
|
||||
// editorFontSubset 保存单个新增字体最近一次裁剪结果,不保留历史字形集合
|
||||
type editorFontSubset struct {
|
||||
glyphs []uint16
|
||||
data []byte
|
||||
mapped bool
|
||||
}
|
||||
|
||||
// AddFont 注册OpenType字体,集合字体按索引提取,重复资源复用标识,引用后写入文档
|
||||
// 入参: file 字体文件, index 集合内字体索引,单字体为0
|
||||
// 返回: string 字体资源标识, error 错误信息
|
||||
func (e *Editor) AddFont(file FontFile, index int) (string, error) {
|
||||
if e.backends.FontResources == nil {
|
||||
return "", fmt.Errorf("font resources: %w", ErrBackendUnavailable)
|
||||
}
|
||||
parsed, err := e.backends.FontResources.OpenFontResource(file, index)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data := parsed.Data
|
||||
key := editorResourceKey{checksum: sha256.Sum256(data)}
|
||||
if id, ok := e.resourceID[key]; ok {
|
||||
return id, nil
|
||||
}
|
||||
if err := e.prepareSourceIDs(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
id := e.nextID()
|
||||
definition := parsed.Font
|
||||
definition.ID = id
|
||||
resource := editorResource{
|
||||
name: e.packageName("Res/Fonts/Font_" + id + parsed.Extension),
|
||||
data: data,
|
||||
font: &definition,
|
||||
}
|
||||
resource.font.FontFile = "/" + resource.name
|
||||
e.resources = append(e.resources, resource)
|
||||
e.fonts[id] = parsed
|
||||
e.resourceID[key] = id
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// newEditorFontUsage 创建字体用字记录
|
||||
// 返回: *editorFontUsage 用字记录
|
||||
func newEditorFontUsage() *editorFontUsage {
|
||||
@@ -84,110 +123,11 @@ func (u *editorFontUsage) merge(other *editorFontUsage, unsafe bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// subsetSourceFont 裁剪静态TrueType原有字体,保留字形编号、复合依赖、度量及提示指令
|
||||
// 集合、字形替换、可变、彩色和未知表保持原样,不对不完整的引用或异常字体猜测修复
|
||||
// 入参: data 字体数据, usage 全包用字记录
|
||||
// 返回: []byte 更小的字体子集,无确定收益时为空
|
||||
func subsetSourceFont(data []byte, usage *editorFontUsage) []byte {
|
||||
if usage.unsafe || len(usage.chars)+len(usage.glyphs) == 0 || bytes.HasPrefix(data, []byte("ttcf")) {
|
||||
return nil
|
||||
}
|
||||
sfnt, err := font.ParseSFNT(data, 0)
|
||||
if err != nil || !sfnt.IsTrueType {
|
||||
return nil
|
||||
}
|
||||
for tag := range sfnt.Tables {
|
||||
switch tag {
|
||||
case "cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp", "kern", "vhea", "vmtx", "hdmx", "LTSH", "VDMX", "GDEF", "GPOS", "DSIG", "FFTM":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var unicodeTables []uint16
|
||||
subtables := make(map[uint32]uint16)
|
||||
for i, record := range sfnt.Cmap.EncodingRecords {
|
||||
if record.Format == 14 {
|
||||
return nil
|
||||
}
|
||||
offset := binary.BigEndian.Uint32(sfnt.Tables["cmap"][8+8*i:])
|
||||
index, ok := subtables[offset]
|
||||
if !ok {
|
||||
index = uint16(len(subtables))
|
||||
subtables[offset] = index
|
||||
}
|
||||
if record.PlatformID == 0 || record.PlatformID == 3 && (record.EncodingID == 1 || record.EncodingID == 10) {
|
||||
unicodeTables = append(unicodeTables, index)
|
||||
}
|
||||
}
|
||||
if len(unicodeTables) == 0 {
|
||||
return nil
|
||||
}
|
||||
glyphs := maps.Clone(usage.glyphs)
|
||||
glyphs[0] = true
|
||||
mapping := make(map[rune]uint16, len(usage.chars))
|
||||
for char := range usage.chars {
|
||||
id := sfnt.GlyphIndex(char)
|
||||
for _, index := range unicodeTables {
|
||||
if alternate, ok := sfnt.Cmap.Subtables[index].Get(char); ok && alternate != id {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
glyphs[id] = true
|
||||
if id != 0 {
|
||||
mapping[char] = id
|
||||
}
|
||||
}
|
||||
for id := range glyphs {
|
||||
if id >= sfnt.NumGlyphs() {
|
||||
return nil
|
||||
}
|
||||
dependencies, err := sfnt.Glyf.Dependencies(id)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, dependency := range dependencies {
|
||||
glyphs[dependency] = true
|
||||
}
|
||||
}
|
||||
var glyf, loca []byte
|
||||
for id := 0; id <= int(sfnt.NumGlyphs()); id++ {
|
||||
if sfnt.Head.IndexToLocFormat == 0 {
|
||||
loca = binary.BigEndian.AppendUint16(loca, uint16(len(glyf)/2))
|
||||
} else {
|
||||
loca = binary.BigEndian.AppendUint32(loca, uint32(len(glyf)))
|
||||
}
|
||||
if id < int(sfnt.NumGlyphs()) && glyphs[uint16(id)] {
|
||||
glyf = append(glyf, sfnt.Glyf.Get(uint16(id))...)
|
||||
if len(glyf)%2 != 0 {
|
||||
glyf = append(glyf, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
tables := maps.Clone(sfnt.Tables)
|
||||
tables["glyf"], tables["loca"] = glyf, loca
|
||||
tables["cmap"] = buildCmapTable(uint16(sfnt.NumGlyphs()), mapping)
|
||||
tables["head"] = bytes.Clone(tables["head"])
|
||||
clear(tables["head"][8:12])
|
||||
delete(tables, "DSIG")
|
||||
result, err := serializeOTF(tables)
|
||||
if err != nil || len(result) >= len(data) {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// editorFontSubset 保存单个新增字体最近一次裁剪结果,不保留历史字形集合
|
||||
type editorFontSubset struct {
|
||||
glyphs []uint16
|
||||
data []byte
|
||||
mapped bool
|
||||
}
|
||||
|
||||
// subsetFonts 收集新增字体实际使用的字形,仅裁剪保存结果,保留完整编辑字体
|
||||
// 入参: progress 保存进度回调
|
||||
// 返回: map[string][]byte 包内字体子集, error 错误信息
|
||||
func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error) {
|
||||
if e.backends.Resources == nil {
|
||||
if e.backends.FontResources == nil {
|
||||
return nil, nil
|
||||
}
|
||||
used := make(map[string]map[uint16]bool)
|
||||
@@ -196,16 +136,16 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error)
|
||||
if resource.font == nil {
|
||||
continue
|
||||
}
|
||||
sfnt := e.fonts[resource.font.ID]
|
||||
if sfnt == nil {
|
||||
resourceFont := e.fonts[resource.font.ID]
|
||||
if resourceFont == nil {
|
||||
var err error
|
||||
sfnt, err = e.backends.Resources.OpenFontResource(FontFile{Data: resource.data}, 0)
|
||||
resourceFont, err = e.backends.FontResources.OpenFontResource(FontFile{Data: resource.data}, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.fonts[resource.font.ID] = sfnt
|
||||
e.fonts[resource.font.ID] = resourceFont
|
||||
}
|
||||
if sfnt.CanSubset {
|
||||
if resourceFont.CanSubset {
|
||||
used[resource.font.ID] = make(map[uint16]bool)
|
||||
}
|
||||
}
|
||||
@@ -320,7 +260,7 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error)
|
||||
}
|
||||
slices.Sort(ids)
|
||||
if resource.subset == nil || resource.subset.mapped != mapped[resource.font.ID] || !slices.Equal(resource.subset.glyphs, ids) {
|
||||
data, err := e.backends.Resources.SubsetFont(resource.data, ids, mapped[resource.font.ID])
|
||||
data, err := e.backends.FontResources.SubsetFont(resource.data, ids, mapped[resource.font.ID])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("subset font %s: %w", resource.font.FontName, err)
|
||||
}
|
||||
@@ -338,45 +278,3 @@ func (e *Editor) subsetFonts(progress editorProgress) (map[string][]byte, error)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// subsetEditorFont 裁剪TrueType字形及复合依赖,保留字符映射、名称、度量和提示指令
|
||||
// 入参: data 字体数据, glyphs 已排序的字形编号,包含0, mapped 是否保留显式字形编号
|
||||
// 返回: []byte 字体子集, error 错误信息
|
||||
func subsetEditorFont(data []byte, glyphs []uint16, mapped bool) ([]byte, error) {
|
||||
sfnt, err := font.ParseSFNT(bytes.Clone(data), 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mapped {
|
||||
usage := newEditorFontUsage()
|
||||
for _, id := range glyphs {
|
||||
usage.glyphs[id] = true
|
||||
for _, char := range sfnt.Cmap.ToUnicode(id) {
|
||||
usage.chars[char] = true
|
||||
}
|
||||
}
|
||||
tables := make(map[string][]byte)
|
||||
for _, tag := range []string{"cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp"} {
|
||||
if table := sfnt.Tables[tag]; table != nil {
|
||||
tables[tag] = table
|
||||
}
|
||||
}
|
||||
fixed, err := serializeOTF(tables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if subset := subsetSourceFont(fixed, usage); subset != nil {
|
||||
return subset, nil
|
||||
}
|
||||
return fixed, nil
|
||||
}
|
||||
subset, err := sfnt.Subset(glyphs, font.SubsetOptions{Tables: []string{
|
||||
"cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp",
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subset.Tables["name"] = sfnt.Tables["name"]
|
||||
clear(subset.Tables["head"][8:12])
|
||||
return serializeOTF(subset.Tables)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
// 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"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// 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
|
||||
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]})
|
||||
}
|
||||
}
|
||||
data = editorXMLSetText(data, info, values)
|
||||
if !reflect.DeepEqual(before.CustomDatas, after.CustomDatas) {
|
||||
root, err = parseEditorXML(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return editorCustomDatas(data, root.child("DocBody").child("DocInfo"), after.CustomDatas)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// editorCustomDatas 更新自定义字段,保留容器扩展与同名字段的未知属性和子节点
|
||||
// 入参: data 原XML, info 文档信息节点, values 新字段
|
||||
// 返回: []byte 修改后的XML, error 编码错误
|
||||
func editorCustomDatas(data []byte, info *editorXML, values *CustomDatas) ([]byte, error) {
|
||||
container := info.child("CustomDatas")
|
||||
var items []CustomData
|
||||
if values != nil {
|
||||
items = values.CustomData
|
||||
}
|
||||
byName := make(map[string][]*editorXML)
|
||||
var nodes []*editorXML
|
||||
if container != nil {
|
||||
for _, node := range container.children {
|
||||
if node.name.Local == "CustomData" && (node.name.Space == ofdNamespace || node.name.Space == container.name.Space) {
|
||||
nodes = append(nodes, node)
|
||||
byName[node.attr("Name")] = append(byName[node.attr("Name")], node)
|
||||
}
|
||||
}
|
||||
}
|
||||
var encoded [][]byte
|
||||
for _, item := range items {
|
||||
matches := byName[item.Name]
|
||||
if len(matches) == 0 {
|
||||
entry, err := xml.Marshal(struct {
|
||||
XMLName xml.Name
|
||||
CustomData
|
||||
}{xml.Name{Space: ofdNamespace, Local: "CustomData"}, item})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded = append(encoded, entry)
|
||||
continue
|
||||
}
|
||||
node := matches[0]
|
||||
byName[item.Name] = matches[1:]
|
||||
var original CustomData
|
||||
if err := xml.Unmarshal(data[node.start:node.end], &original); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if original.Value == item.Value {
|
||||
encoded = append(encoded, data[node.start:node.end])
|
||||
continue
|
||||
}
|
||||
var content bytes.Buffer
|
||||
if err := xml.EscapeText(&content, []byte(item.Value)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range node.children {
|
||||
content.Write(data[child.start:child.end])
|
||||
}
|
||||
patch := editorXMLContent(data, node, content.Bytes())
|
||||
entry := append(bytes.Clone(data[node.start:patch.start]), patch.data...)
|
||||
entry = append(entry, data[patch.end:node.end]...)
|
||||
encoded = append(encoded, entry)
|
||||
}
|
||||
if container == nil {
|
||||
if len(encoded) == 0 {
|
||||
return data, nil
|
||||
}
|
||||
content := []byte("<ofd:CustomDatas xmlns:ofd=\"" + ofdNamespace + "\">")
|
||||
content = append(content, bytes.Join(encoded, nil)...)
|
||||
content = append(content, []byte("</ofd:CustomDatas>")...)
|
||||
if info.open == info.end {
|
||||
return editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, info, content)}), nil
|
||||
}
|
||||
return editorPatchXML(data, []editorXMLPatch{{info.close, info.close, content}}), nil
|
||||
}
|
||||
var patches []editorXMLPatch
|
||||
for i, node := range nodes {
|
||||
var entry []byte
|
||||
if i < len(encoded) {
|
||||
entry = encoded[i]
|
||||
}
|
||||
patches = append(patches, editorXMLPatch{node.start, node.end, entry})
|
||||
}
|
||||
if len(encoded) > len(nodes) {
|
||||
added := bytes.Join(encoded[len(nodes):], nil)
|
||||
if container.open == container.end {
|
||||
patches = append(patches, editorXMLContent(data, container, added))
|
||||
} else {
|
||||
patches = append(patches, editorXMLPatch{container.close, container.close, added})
|
||||
}
|
||||
}
|
||||
return editorPatchXML(data, patches), nil
|
||||
}
|
||||
@@ -421,133 +421,6 @@ func (e *Editor) sourceNewPageXML(page PageContent) ([]byte, error) {
|
||||
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
|
||||
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]})
|
||||
}
|
||||
}
|
||||
data = editorXMLSetText(data, info, values)
|
||||
if !reflect.DeepEqual(before.CustomDatas, after.CustomDatas) {
|
||||
root, err = parseEditorXML(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return editorCustomDatas(data, root.child("DocBody").child("DocInfo"), after.CustomDatas)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// editorCustomDatas 更新自定义字段,保留容器扩展与同名字段的未知属性和子节点
|
||||
// 入参: data 原XML, info 文档信息节点, values 新字段
|
||||
// 返回: []byte 修改后的XML, error 编码错误
|
||||
func editorCustomDatas(data []byte, info *editorXML, values *CustomDatas) ([]byte, error) {
|
||||
container := info.child("CustomDatas")
|
||||
var items []CustomData
|
||||
if values != nil {
|
||||
items = values.CustomData
|
||||
}
|
||||
byName := make(map[string][]*editorXML)
|
||||
var nodes []*editorXML
|
||||
if container != nil {
|
||||
for _, node := range container.children {
|
||||
if node.name.Local == "CustomData" && (node.name.Space == ofdNamespace || node.name.Space == container.name.Space) {
|
||||
nodes = append(nodes, node)
|
||||
byName[node.attr("Name")] = append(byName[node.attr("Name")], node)
|
||||
}
|
||||
}
|
||||
}
|
||||
var encoded [][]byte
|
||||
for _, item := range items {
|
||||
matches := byName[item.Name]
|
||||
if len(matches) == 0 {
|
||||
entry, err := xml.Marshal(struct {
|
||||
XMLName xml.Name
|
||||
CustomData
|
||||
}{xml.Name{Space: ofdNamespace, Local: "CustomData"}, item})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
encoded = append(encoded, entry)
|
||||
continue
|
||||
}
|
||||
node := matches[0]
|
||||
byName[item.Name] = matches[1:]
|
||||
var original CustomData
|
||||
if err := xml.Unmarshal(data[node.start:node.end], &original); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if original.Value == item.Value {
|
||||
encoded = append(encoded, data[node.start:node.end])
|
||||
continue
|
||||
}
|
||||
var content bytes.Buffer
|
||||
if err := xml.EscapeText(&content, []byte(item.Value)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, child := range node.children {
|
||||
content.Write(data[child.start:child.end])
|
||||
}
|
||||
patch := editorXMLContent(data, node, content.Bytes())
|
||||
entry := append(bytes.Clone(data[node.start:patch.start]), patch.data...)
|
||||
entry = append(entry, data[patch.end:node.end]...)
|
||||
encoded = append(encoded, entry)
|
||||
}
|
||||
if container == nil {
|
||||
if len(encoded) == 0 {
|
||||
return data, nil
|
||||
}
|
||||
content := []byte("<ofd:CustomDatas xmlns:ofd=\"" + ofdNamespace + "\">")
|
||||
content = append(content, bytes.Join(encoded, nil)...)
|
||||
content = append(content, []byte("</ofd:CustomDatas>")...)
|
||||
if info.open == info.end {
|
||||
return editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, info, content)}), nil
|
||||
}
|
||||
return editorPatchXML(data, []editorXMLPatch{{info.close, info.close, content}}), nil
|
||||
}
|
||||
var patches []editorXMLPatch
|
||||
for i, node := range nodes {
|
||||
var entry []byte
|
||||
if i < len(encoded) {
|
||||
entry = encoded[i]
|
||||
}
|
||||
patches = append(patches, editorXMLPatch{node.start, node.end, entry})
|
||||
}
|
||||
if len(encoded) > len(nodes) {
|
||||
added := bytes.Join(encoded[len(nodes):], nil)
|
||||
if container.open == container.end {
|
||||
patches = append(patches, editorXMLContent(data, container, added))
|
||||
} else {
|
||||
patches = append(patches, editorXMLPatch{container.close, container.close, added})
|
||||
}
|
||||
}
|
||||
return editorPatchXML(data, patches), nil
|
||||
}
|
||||
|
||||
// writeSource 将未修改ZIP条目直接复制到新包,逐项写入改动,不持有原资源解压副本
|
||||
// 入参: writer 输出流, fonts 新增字体子集, progress 保存进度回调
|
||||
// 返回: int64 写入字节数, error 错误信息
|
||||
|
||||
@@ -173,14 +173,14 @@ func (e *Editor) compactSourceResources(parts map[string][]byte, progress editor
|
||||
}
|
||||
key := strings.ToLower(cleanPackagePath(name))
|
||||
usage := fontFiles[key]
|
||||
if e.backends.Resources == nil || usage == nil || usage.unsafe || refs.files[key] || removed[key] || parts[name] != nil {
|
||||
if e.backends.FontResources == nil || usage == nil || usage.unsafe || refs.files[key] || removed[key] || parts[name] != nil {
|
||||
continue
|
||||
}
|
||||
data, err := reader.readFile(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subset, err := e.backends.Resources.SubsetSourceFont(data, FontUsage{Characters: slices.Sorted(maps.Keys(usage.chars)), Glyphs: slices.Sorted(maps.Keys(usage.glyphs)), Unsafe: usage.unsafe})
|
||||
subset, err := e.backends.FontResources.SubsetSourceFont(data, FontUsage{Characters: slices.Sorted(maps.Keys(usage.chars)), Glyphs: slices.Sorted(maps.Keys(usage.glyphs)), Unsafe: usage.unsafe})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("subset font %s: %w", name, err)
|
||||
}
|
||||
|
||||
@@ -14,13 +14,6 @@
|
||||
|
||||
package ofdgo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
|
||||
"github.com/tdewolff/font"
|
||||
)
|
||||
|
||||
// FontResource 保存独立字体数据、标准元信息与只读度量,不包含绘图库类型
|
||||
type FontResource struct {
|
||||
FontMetrics
|
||||
@@ -45,87 +38,3 @@ type FontResourceBackend interface {
|
||||
SubsetFont(data []byte, glyphs []uint16, preserve bool) ([]byte, error)
|
||||
SubsetSourceFont(data []byte, usage FontUsage) ([]byte, error)
|
||||
}
|
||||
|
||||
// SFNTBackend 提供默认字体资源处理,不依赖Canvas绘图库
|
||||
type SFNTBackend struct{}
|
||||
|
||||
// Name 返回资源后端标识
|
||||
// 返回: string 后端标识
|
||||
func (SFNTBackend) Name() string { return "sfnt" }
|
||||
|
||||
// OpenFontResource 提取独立字体并读取名称、样式和裁剪能力
|
||||
// 入参: file 字体文件, index 集合索引
|
||||
// 返回: *FontResource 字体资源, error 解析错误
|
||||
func (SFNTBackend) OpenFontResource(file FontFile, index int) (*FontResource, error) {
|
||||
data, err := file.Face(index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sfnt, err := font.ParseSFNT(data, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := editorFontName(sfnt, font.NamePostScript, font.NameFull, font.NameFontFamily)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("font has no name")
|
||||
}
|
||||
extension := ".ttf"
|
||||
if sfnt.IsCFF {
|
||||
extension = ".otf"
|
||||
}
|
||||
return &FontResource{
|
||||
FontMetrics: sfntResourceMetrics{sfnt}, Data: data, Extension: extension,
|
||||
Font: Font{FontName: name, FamilyName: editorFontName(sfnt, font.NamePreferredFamily, font.NameFontFamily), Charset: "unicode", Bold: sfnt.Head.MacStyle[0], Italic: sfnt.Head.MacStyle[1], FixedWidth: sfnt.Post.IsFixedPitch != 0},
|
||||
CanSubset: sfntSubsettable(sfnt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sfntSubsettable 判断默认资源后端能否无损裁剪该字体
|
||||
// 入参: sfnt 已解析字体
|
||||
// 返回: bool 是否支持裁剪
|
||||
func sfntSubsettable(sfnt *font.SFNT) bool {
|
||||
return sfnt.IsTrueType && !slices.ContainsFunc([]string{"fvar", "COLR", "CBDT", "sbix", "SVG "}, func(tag string) bool { return sfnt.Tables[tag] != nil })
|
||||
}
|
||||
|
||||
// sfntResourceMetrics 隔离资源解析器的度量实现
|
||||
type sfntResourceMetrics struct{ *font.SFNT }
|
||||
|
||||
// Write 返回未改动元信息的字体数据
|
||||
// 返回: []byte 字体数据
|
||||
func (f sfntResourceMetrics) Write() []byte { return fontSFNTData(f.SFNT) }
|
||||
|
||||
// SubsetFont 裁剪新建字体并按要求保留显式字形编号
|
||||
// 入参: data 字体数据, glyphs 用字, preserve 是否保留编号
|
||||
// 返回: []byte 子集, error 裁剪错误
|
||||
func (SFNTBackend) SubsetFont(data []byte, glyphs []uint16, preserve bool) ([]byte, error) {
|
||||
return subsetEditorFont(data, glyphs, preserve)
|
||||
}
|
||||
|
||||
// SubsetSourceFont 按全包引用裁剪原字体,不确定时保留原资源
|
||||
// 入参: data 字体数据, usage 用字信息
|
||||
// 返回: []byte 子集, error 裁剪错误
|
||||
func (SFNTBackend) SubsetSourceFont(data []byte, usage FontUsage) ([]byte, error) {
|
||||
u := newEditorFontUsage()
|
||||
u.unsafe = usage.Unsafe
|
||||
for _, char := range usage.Characters {
|
||||
u.chars[char] = true
|
||||
}
|
||||
for _, glyph := range usage.Glyphs {
|
||||
u.glyphs[glyph] = true
|
||||
}
|
||||
return subsetSourceFont(data, u), nil
|
||||
}
|
||||
|
||||
// editorFontName 获取字体中的名称
|
||||
// 入参: sfnt 字体, names 名称类型,按顺序查找
|
||||
// 返回: string 字体名称
|
||||
func editorFontName(sfnt *font.SFNT, names ...font.NameID) string {
|
||||
for _, name := range names {
|
||||
for _, record := range sfnt.Name.Get(name) {
|
||||
if value := record.String(); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
// 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/binary"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"github.com/tdewolff/font"
|
||||
)
|
||||
|
||||
// SFNTBackend 提供默认字体资源处理,不依赖Canvas绘图库
|
||||
type SFNTBackend struct{}
|
||||
|
||||
// sfntResourceMetrics 隔离资源解析器的度量实现
|
||||
type sfntResourceMetrics struct{ *font.SFNT }
|
||||
|
||||
// Name 返回资源后端标识
|
||||
// 返回: string 后端标识
|
||||
func (SFNTBackend) Name() string { return "sfnt" }
|
||||
|
||||
// OpenFontResource 提取独立字体并读取名称、样式和裁剪能力
|
||||
// 入参: file 字体文件, index 集合索引
|
||||
// 返回: *FontResource 字体资源, error 解析错误
|
||||
func (SFNTBackend) OpenFontResource(file FontFile, index int) (*FontResource, error) {
|
||||
data, err := file.Face(index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sfnt, err := font.ParseSFNT(data, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
name := sfntFontName(sfnt, font.NamePostScript, font.NameFull, font.NameFontFamily)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("font has no name")
|
||||
}
|
||||
extension := ".ttf"
|
||||
if sfnt.IsCFF {
|
||||
extension = ".otf"
|
||||
}
|
||||
return &FontResource{
|
||||
FontMetrics: sfntResourceMetrics{sfnt}, Data: data, Extension: extension,
|
||||
Font: Font{FontName: name, FamilyName: sfntFontName(sfnt, font.NamePreferredFamily, font.NameFontFamily), Charset: "unicode", Bold: sfnt.Head.MacStyle[0], Italic: sfnt.Head.MacStyle[1], FixedWidth: sfnt.Post.IsFixedPitch != 0},
|
||||
CanSubset: sfntSubsettable(sfnt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SubsetFont 裁剪新建字体并按要求保留显式字形编号
|
||||
// 入参: data 字体数据, glyphs 用字, preserve 是否保留编号
|
||||
// 返回: []byte 子集, error 裁剪错误
|
||||
func (SFNTBackend) SubsetFont(data []byte, glyphs []uint16, preserve bool) ([]byte, error) {
|
||||
return subsetSFNTFont(data, glyphs, preserve)
|
||||
}
|
||||
|
||||
// SubsetSourceFont 按全包引用裁剪原字体,不确定时保留原资源
|
||||
// 入参: data 字体数据, usage 用字信息
|
||||
// 返回: []byte 子集, error 裁剪错误
|
||||
func (SFNTBackend) SubsetSourceFont(data []byte, usage FontUsage) ([]byte, error) {
|
||||
u := newEditorFontUsage()
|
||||
u.unsafe = usage.Unsafe
|
||||
for _, char := range usage.Characters {
|
||||
u.chars[char] = true
|
||||
}
|
||||
for _, glyph := range usage.Glyphs {
|
||||
u.glyphs[glyph] = true
|
||||
}
|
||||
return subsetSourceSFNTFont(data, u), nil
|
||||
}
|
||||
|
||||
// Write 返回未改动元信息的字体数据
|
||||
// 返回: []byte 字体数据
|
||||
func (f sfntResourceMetrics) Write() []byte { return fontSFNTData(f.SFNT) }
|
||||
|
||||
// sfntSubsettable 判断默认资源后端能否无损裁剪该字体
|
||||
// 入参: sfnt 已解析字体
|
||||
// 返回: bool 是否支持裁剪
|
||||
func sfntSubsettable(sfnt *font.SFNT) bool {
|
||||
return sfnt.IsTrueType && !slices.ContainsFunc([]string{"fvar", "COLR", "CBDT", "sbix", "SVG "}, func(tag string) bool { return sfnt.Tables[tag] != nil })
|
||||
}
|
||||
|
||||
// sfntFontName 获取字体中的名称
|
||||
// 入参: sfnt 字体, names 名称类型,按顺序查找
|
||||
// 返回: string 字体名称
|
||||
func sfntFontName(sfnt *font.SFNT, names ...font.NameID) string {
|
||||
for _, name := range names {
|
||||
for _, record := range sfnt.Name.Get(name) {
|
||||
if value := record.String(); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// subsetSFNTFont 裁剪TrueType字形及复合依赖,保留字符映射、名称、度量和提示指令
|
||||
// 入参: data 字体数据, glyphs 已排序的字形编号,包含0, mapped 是否保留显式字形编号
|
||||
// 返回: []byte 字体子集, error 错误信息
|
||||
func subsetSFNTFont(data []byte, glyphs []uint16, mapped bool) ([]byte, error) {
|
||||
sfnt, err := font.ParseSFNT(bytes.Clone(data), 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mapped {
|
||||
usage := newEditorFontUsage()
|
||||
for _, id := range glyphs {
|
||||
usage.glyphs[id] = true
|
||||
for _, char := range sfnt.Cmap.ToUnicode(id) {
|
||||
usage.chars[char] = true
|
||||
}
|
||||
}
|
||||
tables := make(map[string][]byte)
|
||||
for _, tag := range []string{"cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp"} {
|
||||
if table := sfnt.Tables[tag]; table != nil {
|
||||
tables[tag] = table
|
||||
}
|
||||
}
|
||||
fixed, err := serializeOTF(tables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if subset := subsetSourceSFNTFont(fixed, usage); subset != nil {
|
||||
return subset, nil
|
||||
}
|
||||
return fixed, nil
|
||||
}
|
||||
subset, err := sfnt.Subset(glyphs, font.SubsetOptions{Tables: []string{
|
||||
"cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp",
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
subset.Tables["name"] = sfnt.Tables["name"]
|
||||
clear(subset.Tables["head"][8:12])
|
||||
return serializeOTF(subset.Tables)
|
||||
}
|
||||
|
||||
// subsetSourceSFNTFont 裁剪静态TrueType原有字体,保留字形编号、复合依赖、度量及提示指令
|
||||
// 集合、字形替换、可变、彩色和未知表保持原样,不对不完整的引用或异常字体猜测修复
|
||||
// 入参: data 字体数据, usage 全包用字记录
|
||||
// 返回: []byte 更小的字体子集,无确定收益时为空
|
||||
func subsetSourceSFNTFont(data []byte, usage *editorFontUsage) []byte {
|
||||
if usage.unsafe || len(usage.chars)+len(usage.glyphs) == 0 || bytes.HasPrefix(data, []byte("ttcf")) {
|
||||
return nil
|
||||
}
|
||||
sfnt, err := font.ParseSFNT(data, 0)
|
||||
if err != nil || !sfnt.IsTrueType {
|
||||
return nil
|
||||
}
|
||||
for tag := range sfnt.Tables {
|
||||
switch tag {
|
||||
case "cmap", "head", "hhea", "hmtx", "maxp", "OS/2", "post", "name", "glyf", "loca", "cvt ", "fpgm", "prep", "gasp", "kern", "vhea", "vmtx", "hdmx", "LTSH", "VDMX", "GDEF", "GPOS", "DSIG", "FFTM":
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
var unicodeTables []uint16
|
||||
subtables := make(map[uint32]uint16)
|
||||
for i, record := range sfnt.Cmap.EncodingRecords {
|
||||
if record.Format == 14 {
|
||||
return nil
|
||||
}
|
||||
offset := binary.BigEndian.Uint32(sfnt.Tables["cmap"][8+8*i:])
|
||||
index, ok := subtables[offset]
|
||||
if !ok {
|
||||
index = uint16(len(subtables))
|
||||
subtables[offset] = index
|
||||
}
|
||||
if record.PlatformID == 0 || record.PlatformID == 3 && (record.EncodingID == 1 || record.EncodingID == 10) {
|
||||
unicodeTables = append(unicodeTables, index)
|
||||
}
|
||||
}
|
||||
if len(unicodeTables) == 0 {
|
||||
return nil
|
||||
}
|
||||
glyphs := maps.Clone(usage.glyphs)
|
||||
glyphs[0] = true
|
||||
mapping := make(map[rune]uint16, len(usage.chars))
|
||||
for char := range usage.chars {
|
||||
id := sfnt.GlyphIndex(char)
|
||||
for _, index := range unicodeTables {
|
||||
if alternate, ok := sfnt.Cmap.Subtables[index].Get(char); ok && alternate != id {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
glyphs[id] = true
|
||||
if id != 0 {
|
||||
mapping[char] = id
|
||||
}
|
||||
}
|
||||
for id := range glyphs {
|
||||
if id >= sfnt.NumGlyphs() {
|
||||
return nil
|
||||
}
|
||||
dependencies, err := sfnt.Glyf.Dependencies(id)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
for _, dependency := range dependencies {
|
||||
glyphs[dependency] = true
|
||||
}
|
||||
}
|
||||
var glyf, loca []byte
|
||||
for id := 0; id <= int(sfnt.NumGlyphs()); id++ {
|
||||
if sfnt.Head.IndexToLocFormat == 0 {
|
||||
loca = binary.BigEndian.AppendUint16(loca, uint16(len(glyf)/2))
|
||||
} else {
|
||||
loca = binary.BigEndian.AppendUint32(loca, uint32(len(glyf)))
|
||||
}
|
||||
if id < int(sfnt.NumGlyphs()) && glyphs[uint16(id)] {
|
||||
glyf = append(glyf, sfnt.Glyf.Get(uint16(id))...)
|
||||
if len(glyf)%2 != 0 {
|
||||
glyf = append(glyf, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
tables := maps.Clone(sfnt.Tables)
|
||||
tables["glyf"], tables["loca"] = glyf, loca
|
||||
tables["cmap"] = buildCmapTable(uint16(sfnt.NumGlyphs()), mapping)
|
||||
tables["head"] = bytes.Clone(tables["head"])
|
||||
clear(tables["head"][8:12])
|
||||
delete(tables, "DSIG")
|
||||
result, err := serializeOTF(tables)
|
||||
if err != nil || len(result) >= len(data) {
|
||||
return nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -40,7 +40,7 @@ func init() {
|
||||
// 返回: RenderBackends 默认后端组合
|
||||
func defaultRenderBackends() RenderBackends {
|
||||
backend := CanvasBackend{}
|
||||
return RenderBackends{Resources: SFNTBackend{}, Fonts: backend, Geometry: backend, Compiler: backend, Raster: backend, SVG: backend, PDF: backend, EPS: backend}
|
||||
return RenderBackends{FontResources: SFNTBackend{}, Fonts: backend, Geometry: backend, Compiler: backend, Raster: backend, SVG: backend, PDF: backend, EPS: backend}
|
||||
}
|
||||
|
||||
// Name 返回后端标识
|
||||
|
||||
Reference in new issue
Block a user