feat(编辑阅读): 完善保真编辑与跨文档复制,优化后端缓存和大文件性能
build.yaml / build (push) Successful in 41s

This commit is contained in:
xiaoqidun committed 2026-09-24 13:48:48 +08:00
1 parent 06b5df9173
commit 697a059162
18 files changed
+692 -128

No files matched your search

+14
View File
@@ -183,6 +183,12 @@
<form id="objectStyleForm">
<div class="form-row"><span>组合</span><div class="form-format"><button id="groupObjectsButton" class="button" type="button">组合</button><button id="ungroupObjectButton" class="button" type="button">解组</button></div></div>
<div class="form-row"><span>格式</span><div class="form-format"><button id="copyStyleButton" class="button" type="button" aria-label="复制格式">复制</button><button id="pasteStyleButton" class="button" type="button" aria-label="粘贴格式">粘贴</button></div></div>
<details id="objectLinkFields" hidden>
<summary>链接</summary>
<div class="form-row"><label for="objectLinkKind">目标</label><select id="objectLinkKind"><option value="none">不设</option><option value="uri">网址</option><option value="page">页面</option><option value="keep">原有</option></select></div>
<div class="form-row" id="objectLinkAddressRow" hidden><label for="objectLinkAddress">地址</label><input id="objectLinkAddress" type="text"></div>
<div class="form-row" id="objectLinkPageRow" hidden><label for="objectLinkPage">页码</label><input id="objectLinkPage" type="number" min="1" step="1"></div>
</details>
<div class="form-row"><label for="objectOpacity">透明</label><div class="form-unit"><input id="objectOpacity" type="number" min="0" max="100" step="any" aria-label="透明度" autofocus><span>%</span></div></div>
<details id="objectBorderFields" hidden>
<summary>边框</summary>
@@ -342,6 +348,14 @@
<dialog id="objectPicker" class="form-dialog" aria-label="选取对象">
<form method="dialog"><div id="objectPickerList" class="object-picker-list"></div><div class="form-actions"><button class="button" type="submit">关闭</button></div></form>
</dialog>
<dialog id="sourceTextPanel" class="form-dialog" aria-label="保留排版">
<form id="sourceTextForm">
<h2 class="form-title">保留排版</h2>
<div class="form-row"><label for="sourceTextValue">内容</label><textarea id="sourceTextValue" rows="3" dir="auto" aria-label="文字内容" required></textarea></div>
<div id="sourceTextPreview" aria-label="文字预览"></div>
<div class="form-actions"><span id="sourceTextStatus" class="form-status" role="status"></span><button id="sourceTextCancel" class="button" type="button">取消</button><button class="button primary" type="submit">确定</button></div>
</form>
</dialog>
<dialog id="annotationNote" class="form-dialog" aria-label="注解">
<form id="annotationForm" method="dialog">
<div id="annotationDetail" class="annotation-detail"></div>
+21
View File
@@ -692,6 +692,27 @@ button:not(:disabled):hover {
line-height: 1.5;
}
#sourceTextPanel {
width: 440px;
}
#sourceTextValue {
min-height: 80px;
}
#sourceTextPreview {
height: 160px;
margin-top: 12px;
background: #fff;
overflow: hidden;
}
#sourceTextPreview svg {
display: block;
width: 100%;
height: 100%;
}
.security-dialog {
width: 360px;
}
+90 -3
View File
@@ -284,10 +284,22 @@ const el = {
objectHeight: document.querySelector("#objectHeight"),
objectAspect: document.querySelector("#objectAspect"),
objectStylePanel: document.querySelector("#objectStylePanel"),
sourceTextPanel: document.querySelector("#sourceTextPanel"),
sourceTextForm: document.querySelector("#sourceTextForm"),
sourceTextValue: document.querySelector("#sourceTextValue"),
sourceTextPreview: document.querySelector("#sourceTextPreview"),
sourceTextStatus: document.querySelector("#sourceTextStatus"),
sourceTextCancel: document.querySelector("#sourceTextCancel"),
objectStyleForm: document.querySelector("#objectStyleForm"),
groupObjectsButton: document.querySelector("#groupObjectsButton"),
ungroupObjectButton: document.querySelector("#ungroupObjectButton"),
objectOpacity: document.querySelector("#objectOpacity"),
objectLinkFields: document.querySelector("#objectLinkFields"),
objectLinkKind: document.querySelector("#objectLinkKind"),
objectLinkAddress: document.querySelector("#objectLinkAddress"),
objectLinkPage: document.querySelector("#objectLinkPage"),
objectLinkAddressRow: document.querySelector("#objectLinkAddressRow"),
objectLinkPageRow: document.querySelector("#objectLinkPageRow"),
objectGradientFields: document.querySelector("#objectGradientFields"),
gradientTarget: document.querySelector("#gradientTarget"),
objectBorderFields: document.querySelector("#objectBorderFields"),
@@ -997,6 +1009,23 @@ el.objectBoundsForm.addEventListener("submit", async event => {
if (saved) el.objectBoundsPanel.close();
});
el.objectStyleCancel.addEventListener("click", () => el.objectStylePanel.close());
el.sourceTextCancel.addEventListener("click", () => el.sourceTextPanel.close());
el.sourceTextValue.addEventListener("input", () => {
clearTimeout(state.sourceTextTimer);
state.sourceTextTimer = setTimeout(previewPositionedText, 150);
});
el.sourceTextPanel.addEventListener("close", () => {
clearTimeout(state.sourceTextTimer);
state.sourceText = null;
el.sourceTextPreview.replaceChildren();
});
el.sourceTextForm.addEventListener("submit", async event => {
event.preventDefault();
const item = state.sourceText?.item;
if (!item) return;
if (await changeDocument("ofdgoUpdateText", item, el.sourceTextValue.value, null, item.size, null)) el.sourceTextPanel.close();
});
el.objectLinkKind.addEventListener("change", updateObjectLinkFields);
el.gradientKind.addEventListener("change", () => {
el.gradientControls.hidden = el.gradientKind.value === "keep";
el.gradientAngle.disabled = el.gradientKind.value !== "linear";
@@ -1410,7 +1439,7 @@ function handleKeyDown(event) {
function formDialogOpen() {
return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open
|| el.batchPagesPanel.open || el.objectStylePanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open;
|| el.batchPagesPanel.open || el.objectStylePanel.open || el.sourceTextPanel.open || el.outlinePanel.open || el.objectBoundsPanel.open || el.annotationNote.open || el.annotationCreate.open || el.objectPicker.open;
}
function setObjectDimension(input, value) {
@@ -1458,6 +1487,16 @@ function openObjectStyle() {
el.copyStyleButton.disabled = items.length !== 1 || !canCopyStyle(items[0]);
el.pasteStyleButton.disabled = !state.styleClipboard || !items.every(item => item.type === state.styleClipboard && canCopyStyle(item));
const shared = (key, fallback) => items.every(item => (item[key] ?? fallback) === (items[0][key] ?? fallback)) ? items[0][key] ?? fallback : null;
el.objectLinkFields.hidden = items.length !== 1 || items[0].scoped;
const link = items[0].link ? JSON.parse(items[0].link) : null;
const targetPage = link?.Goto?.Dest ? state.doc.pages.findIndex(page => page.id === link.Goto.Dest.PageID) : -1;
el.objectLinkKind.value = items[0].linkCount > 1 ? "keep" : link?.URI ? "uri" : targetPage >= 0 ? "page" : link ? "keep" : "none";
el.objectLinkKind.disabled = items[0].linkCount > 1;
el.objectLinkAddress.value = link?.URI?.URI || "";
el.objectLinkPage.value = String(targetPage >= 0 ? targetPage + 1 : 1);
el.objectLinkPage.max = String(state.doc?.pages.length || 1);
state.objectLinkOriginal = { kind: el.objectLinkKind.value, address: el.objectLinkAddress.value, page: el.objectLinkPage.value, link };
updateObjectLinkFields();
el.objectBorderFields.hidden = items.length !== 1 || items[0].scoped || items[0].type !== "ImageObject";
const border = items[0].borderStyle ? JSON.parse(items[0].borderStyle) : null;
el.borderEnabled.checked = Boolean(border);
@@ -1517,6 +1556,14 @@ function openObjectStyle() {
function readObjectStyle() {
const style = {}, changed = key => el[key].value !== state.styleOriginal[key];
if (!el.objectLinkFields.hidden) {
const original = state.objectLinkOriginal, kind = el.objectLinkKind.value;
if (kind !== "keep" && (kind !== original.kind || kind === "uri" && el.objectLinkAddress.value !== original.address || kind === "page" && el.objectLinkPage.value !== original.page)) {
style.link = kind === "none" ? null : JSON.stringify(kind === "uri"
? { URI: { ...original.link?.URI, URI: el.objectLinkAddress.value.trim() } }
: { Dest: { ...(original.link?.Goto?.Dest || { Type: "Fit" }), PageID: state.doc.pages[Number(el.objectLinkPage.value) - 1].id } });
}
}
if (!el.objectBorderFields.hidden) {
const border = {};
if (el.borderEnabled.checked !== state.borderOriginal.Enabled) border.Enabled = el.borderEnabled.checked;
@@ -1587,6 +1634,16 @@ function readObjectStyle() {
return style;
}
function updateObjectLinkFields() {
const kind = el.objectLinkKind.value, visible = !el.objectLinkFields.hidden;
el.objectLinkAddressRow.hidden = !visible || kind !== "uri";
el.objectLinkPageRow.hidden = !visible || kind !== "page";
el.objectLinkAddress.disabled = el.objectLinkAddressRow.hidden;
el.objectLinkPage.disabled = el.objectLinkPageRow.hidden;
el.objectLinkAddress.required = visible && kind === "uri";
el.objectLinkPage.required = visible && kind === "page";
}
function gradientForm() {
return {
kind: el.gradientKind.value, angle: el.gradientAngle.value,
@@ -2084,7 +2141,18 @@ async function editCanvasObject(item) {
return;
}
if (item.type === "TextObject") {
if (!canEditObject(item, "textContent")) return;
if (!canEditObject(item, "textContent")) {
if (!item.scoped && canEditObject(item, "rewriteText")) {
state.sourceText = { item, openSeq: state.openSeq };
el.sourceTextValue.value = item.text;
el.sourceTextStatus.textContent = "";
el.sourceTextPreview.replaceChildren();
el.sourceTextPanel.showModal();
el.sourceTextValue.focus();
await previewPositionedText();
}
return;
}
} else if (!canEditObject(item, item.type === "ImageObject" ? "replaceImage" : "update")) return;
if (item.type === "PathObject") {
el.shapeWidth.focus();
@@ -2114,6 +2182,22 @@ async function editCanvasObject(item) {
}
}
async function previewPositionedText() {
const request = state.sourceText, value = el.sourceTextValue.value;
if (!request || !el.sourceTextPanel.open) return;
try {
const svg = await callWASM("ofdgoPreviewPositionedText", request.item.index, request.item.id, value);
if (state.sourceText !== request || request.openSeq !== state.openSeq || el.sourceTextValue.value !== value) return;
el.sourceTextPreview.replaceChildren(parseSVG(svg));
el.sourceTextStatus.textContent = "";
} catch (error) {
if (state.sourceText === request && request.openSeq === state.openSeq && el.sourceTextValue.value === value) {
el.sourceTextPreview.replaceChildren();
el.sourceTextStatus.textContent = error.message;
}
}
}
function mountEditorObjects(index, page, surface) {
const scope = state.composite?.index === index ? state.composite : null;
canvasEditor.mount(index, { ...page, objects: scope ? scope.objects : page.objects.filter(item => state.renderAnnotations || item.type !== "Annotation") }, surface);
@@ -2714,6 +2798,8 @@ async function changeDocument(name, item, ...args) {
el.batchPagesStatus.textContent = err.message;
} else if (el.objectStylePanel.open) {
el.objectStyleStatus.textContent = err.message;
} else if (el.sourceTextPanel.open) {
el.sourceTextStatus.textContent = err.message;
} else if (el.objectBoundsPanel.open) {
el.objectBoundsStatus.textContent = err.message;
} else if (el.outlinePanel.open) {
@@ -3275,6 +3361,7 @@ async function openOFD(file) {
el.importPanel.close();
el.batchPagesPanel.close();
el.objectStylePanel.close();
el.sourceTextPanel.close();
el.objectBoundsPanel.close();
el.outlinePanel.close();
updateControls();
@@ -7077,7 +7164,7 @@ function updateObjectControls(item, reset = false) {
el.resetCropButton.disabled = canEditObject(item, "resetCrop") ? disabled : el.cropImageButton.disabled || !cropping && (item.scoped ? !item.cropped : !item.imageBounds || ["x", "y", "width", "height"].every(key => Math.abs(item[key] - item.imageBounds[key]) < 1e-9));
el.objectDistribute.disabled = el.objectAlign.disabled || !item.items || item.items.length < 3;
el.editObjectButton.disabled = disabled || Boolean(item.items) || !canEditObject(item, "enter") && (item.type === "PathObject"
|| !canEditObject(item, item.type === "TextObject" ? "textContent" : item.type === "ImageObject" ? "replaceImage" : "update"));
|| !(item.type === "TextObject" && !item.scoped && canEditObject(item, "rewriteText")) && !canEditObject(item, item.type === "TextObject" ? "textContent" : item.type === "ImageObject" ? "replaceImage" : "update"));
el.editObjectButton.textContent = canEditObject(item, "enter") ? "进入" : item?.type === "ImageObject" ? "替换" : "修改";
el.editObjectButton.title = canEditObject(item, "enter") ? item?.type === "Annotation" ? "进入注解" : "进入组合" : item?.type === "ImageObject" ? "替换图片" : item?.type === "Annotation" ? "修改注解" : "修改对象";
el.editObjectButton.setAttribute("aria-label", el.editObjectButton.title);
+3
View File
@@ -1186,10 +1186,12 @@ export class CanvasEditor {
input.setAttribute("aria-label", "编辑文字");
input.setAttribute("role", "textbox");
input.setAttribute("aria-multiline", "true");
input.setAttribute("dir", "auto");
input.contentEditable = "true";
input.spellcheck = false;
for (const text of item.text.split("\n")) {
const paragraph = document.createElement("div");
paragraph.setAttribute("dir", "auto");
if (text) paragraph.textContent = text;
else paragraph.append(document.createElement("br"));
input.append(paragraph);
@@ -1206,6 +1208,7 @@ export class CanvasEditor {
minHeight: autoSize ? "0px" : `${frame.height * PX_PER_MM + 4}px`,
transform: `matrix(${a},${b},${c},${d},${(e + a * left) * PX_PER_MM},${(f + b * left) * PX_PER_MM})`,
whiteSpace: item.wrap ? "pre-wrap" : "pre",
unicodeBidi: "plaintext",
fontFamily: `"${face.family}"`, fontSize: `${item.size * PX_PER_MM}px`,
lineHeight: item.paragraphHeight || item.lineHeight ? `${(item.paragraphHeight || item.lineHeight) * PX_PER_MM}px` : "normal", color: item.color,
textAlign: item.align || "left",
+65 -4
View File
@@ -184,6 +184,7 @@ func RunWASM() {
registerCallback("ofdgoReadAnnotation", readAnnotation)
registerCallback("ofdgoInsertInk", insertInk)
registerCallback("ofdgoStyleObjects", styleObjects)
registerCallback("ofdgoPreviewPositionedText", previewPositionedText)
registerCallback("ofdgoMoveOutline", moveOutline)
registerCallback("ofdgoCaptureStyle", captureStyle)
registerCallback("ofdgoGroupObjects", groupObjects)
@@ -1702,6 +1703,7 @@ func editorCapabilities(capability ofdgo.ObjectCapabilities, kind string) map[st
result := map[string]any{"update": capability.Update, "paint": capability.Paint, "replaceFont": capability.ReplaceFont, "reflow": capability.Reflow, "layoutKnown": capability.LayoutKnown, "transform": capability.Transform, "arrange": capability.Arrange, "copy": capability.Copy, "delete": capability.Delete, "order": capability.Order, "reason": capability.Reason, "reasonCode": string(capability.ReasonCode),
"replaceImage": capability.ReplaceImage, "cropImage": capability.CropImage, "fitImage": capability.FitImage, "resetCrop": capability.ResetCrop, "ungroup": capability.Ungroup, "stretch": capability.Stretch, "enter": capability.Transform && (kind == "CompositeObject" || kind == "CompositeGraphicUnit")}
result["textContent"] = capability.TextContent
result["rewriteText"] = capability.RewriteText
if missing := capability.MissingGlyphs; missing != nil {
result["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters}
}
@@ -1711,6 +1713,16 @@ func editorCapabilities(capability ofdgo.ObjectCapabilities, kind string) map[st
// editorAppearance 提供对象自身透明度及有效绘制外观,不混入父对象透明度
// 入参: item 前端对象, object 有效样式, paint 是否可改色, scale 描边页面倍率
func editorAppearance(item map[string]any, object ofdgo.GraphicObject, paint bool, scale float64) {
links := 0
for _, action := range object.Actions() {
if action.Event == "CLICK" && (action.URI != nil || action.Goto != nil) {
links++
if data, err := json.Marshal(action); err == nil {
item["link"] = string(data)
}
}
}
item["linkCount"] = links
var alpha *int
var fill, stroke *ofdgo.FillColor
switch object.Type {
@@ -1945,7 +1957,7 @@ func changeCompositeObjects(args []js.Value) (any, error) {
if len(indexes) != 1 {
return fmt.Errorf("text layout requires one member")
}
layout := ofdgo.TextLayout{Wrap: args[6].Bool(), Align: args[7].String(), LineHeight: args[8].Float(), LetterSpacing: args[9].Float(), LeftIndent: args[10].Float(), RightIndent: args[11].Float(), FirstLineIndent: args[12].Float()}
layout := ofdgo.TextLayout{AutoBidi: true, Wrap: args[6].Bool(), Align: args[7].String(), LineHeight: args[8].Float(), LetterSpacing: args[9].Float(), LeftIndent: args[10].Float(), RightIndent: args[11].Float(), FirstLineIndent: args[12].Float()}
if !args[4].IsNull() {
return currentEditor.ResizeCompositeTextFrame(page, path, indexes[0], args[4].Float(), args[5].Float(), layout)
}
@@ -2224,6 +2236,40 @@ func previewText(args []js.Value) (any, error) {
return map[string]any{"runs": runs, "scale": scale, "weight": text.Weight, "italic": text.Italic}, nil
}
// previewPositionedText 预览保留定位的文字替换,不修改编辑器或撤销记录
// 入参: args 页面、对象标识和内容
// 返回: any SVG预览, error 错误信息
func previewPositionedText(args []js.Value) (any, error) {
object, err := currentEditor.Object(args[0].Int(), args[1].String())
if err != nil {
return nil, err
}
if object.Type != "TextObject" {
return nil, fmt.Errorf("object is not text")
}
if err := currentEditor.RewriteText(&object.TextObject, args[2].String()); err != nil {
return nil, err
}
measured, err := currentSession.Renderer.MeasureObject(object, ofdgo.MeasureOptions{})
if err != nil {
return nil, err
}
box := measured.Bounds
margin := object.TextObject.Size / 4
box.X, box.Y, box.W, box.H = box.X-margin, box.Y-margin, box.W+2*margin, box.H+2*margin
boundary, err := ofdgo.ParseBox(object.TextObject.Boundary)
if err != nil {
return nil, err
}
object.TextObject.Boundary = fmt.Sprintf("%g %g %g %g", boundary.X-box.X, boundary.Y-box.Y, boundary.W, boundary.H)
page := &ofdgo.PageContent{Area: ofdgo.PageArea{PhysicalBox: fmt.Sprintf("0 0 %g %g", box.W, box.H)}, Content: ofdgo.Content{Layer: []ofdgo.Layer{{Objects: []ofdgo.GraphicObject{object}}}}}
var output bytes.Buffer
if err := currentSession.Renderer.RenderToSVG(page, &output); err != nil {
return nil, err
}
return output.String(), nil
}
// updateText 修改文字,null内容保留定位且允许替换字体,null字体数据和颜色沿用对象属性
// 入参: args 页码、对象标识、内容、字体数据、字号和颜色
// 返回: any 文档信息, error 错误信息
@@ -2258,7 +2304,10 @@ func updateText(args []js.Value) (any, error) {
}
if !args[2].IsNull() {
if args[4].Float() == object.TextObject.Size {
style := ofdgo.TextStyle{Font: object.TextObject.Font}
var style ofdgo.TextStyle
if !args[3].IsNull() {
style.Font = object.TextObject.Font
}
if !args[5].IsNull() {
var fill ofdgo.FillColor
if err := setEditorColor(&fill, args[5].String()); err != nil {
@@ -2407,7 +2456,7 @@ func insertText(args []js.Value) (any, error) {
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 {
if err := currentEditor.LayoutText(&object.TextObject, args[1].String(), ofdgo.TextLayout{AutoBidi: true, 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 {
@@ -2438,7 +2487,7 @@ func layoutText(args []js.Value) (any, error) {
}
}
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 {
if err := currentEditor.LayoutText(&object.TextObject, value, ofdgo.TextLayout{AutoBidi: true, 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()
@@ -2938,6 +2987,13 @@ func changeOutline(args []js.Value) (any, error) {
// 返回: any 文档信息, error 错误信息
func styleObjects(args []js.Value) (any, error) {
style := objectStyle(args[2])
linkField := args[2].Get("link")
var link *ofdgo.AnnotationLink
if !linkField.IsUndefined() && !linkField.IsNull() {
if err := json.Unmarshal([]byte(linkField.String()), &link); err != nil {
return nil, err
}
}
var border *ofdgo.ImageBorderStyle
if field := args[2].Get("borderStyle"); !field.IsUndefined() {
if err := json.Unmarshal([]byte(field.String()), &border); err != nil {
@@ -2967,6 +3023,11 @@ func styleObjects(args []js.Value) (any, error) {
return changeObjects(func() error {
return currentEditor.Transaction(func(editor *ofdgo.Editor) error {
page, ids := args[0].Int(), stringsFromJS(args[1])
if !linkField.IsUndefined() {
if err := editor.SetObjectLink(page, ids, link); err != nil {
return err
}
}
if border != nil {
if err := resolveStylePaint((*ofdgo.FillColor)(border.Color)); err != nil {
return err
+17 -1
View File
@@ -149,6 +149,17 @@ func backendName(backend Backend) string {
func WithRenderBackends(backends RenderBackends) RendererOption {
return func(r *Renderer) {
fontsChanged := !sameBackend(r.backends.Fonts, backends.Fonts)
for _, pair := range [][2]Backend{
{r.backends.FontResources, backends.FontResources}, {r.backends.Fonts, backends.Fonts},
{r.backends.Geometry, backends.Geometry}, {r.backends.Compiler, backends.Compiler},
{r.backends.Raster, backends.Raster}, {r.backends.SVG, backends.SVG},
{r.backends.PDF, backends.PDF}, {r.backends.EPS, backends.EPS},
} {
if !sameBackend(pair[0], pair[1]) {
r.backendStates = make(map[any]any)
break
}
}
r.backends = backends
if fontsChanged {
r.resetFontBackendCache()
@@ -202,7 +213,12 @@ func (r *Renderer) Backends() RenderBackends {
// 入参: backend 光栅后端,nil禁用图像输出
// 返回: RendererOption 渲染选项
func WithRasterBackend(backend RasterBackend) RendererOption {
return func(r *Renderer) { r.backends.Raster = backend }
return func(r *Renderer) {
if !sameBackend(r.backends.Raster, backend) {
r.backendStates = make(map[any]any)
}
r.backends.Raster = backend
}
}
// CompilePage 编译与后端无关的光栅页面,可交给任意RasterBackend绘制
+2 -1
View File
@@ -28,7 +28,7 @@ var (
ErrEncryptionPolicyRequired = errors.New("explicit encryption policy required")
)
// Credentials 解密凭据,UserName为空时尝试匹配用户;证书采用DER编码
// Credentials 解密凭据,UserName用于筛选接收者,留空自动匹配,不参与口令派生;证书采用DER编码
// Password与Decrypter分别用于口令和证书方案,私钥可由外部密码设备持有
type Credentials struct {
UserName string
@@ -86,6 +86,7 @@ type EncryptionRecipient struct {
}
// EncryptionOptions 设置GM/T0099加密,口令与证书方案二选一
// UserName标记口令接收者,不参与口令派生,留空使用User
// 口令方案应使用足够长的随机口令;标准密钥派生不提供慢速口令哈希
type EncryptionOptions struct {
Password []byte
+1 -1
View File
@@ -79,7 +79,7 @@ func (e *Editor) SetFontFS(fsys ...fs.FS) {
func (e *Editor) SetPageCompiler(compiler PageCompiler) {
e.backends.Compiler = compiler
if e.fontRenderer != nil {
e.fontRenderer.backends.Compiler = compiler
WithRenderBackends(e.backends)(e.fontRenderer)
}
}
+1 -1
View File
@@ -526,7 +526,7 @@ func (e *Editor) updateObjectOrigins(page int, objects []GraphicObject, preserve
beforeOrigins[ids[i]], afterOrigins[ids[i]] = origin, next
}
}
if reflect.DeepEqual(before, after) {
if reflect.DeepEqual(before, after) && len(afterOrigins) == 0 {
return nil
}
apply := func(e *Editor, objects []GraphicObject, origins map[string]*editorObjectOrigin) {
+270 -100
View File
@@ -56,6 +56,7 @@ type editorPageImport struct {
defaultCS string
attachments map[string][]byte
copyPage bool
strictLinks bool
progress editorProgress
}
@@ -69,6 +70,152 @@ type PageImportOptions struct {
OnProgress func(stage string, completed, total int) error
}
// ObjectImportOptions 跨文档对象迁移选项,位移单位为毫米
// AllowDecrypted显式允许将加密来源导入未加密目标,进度回调返回错误时撤销整个操作
type ObjectImportOptions struct {
DX, DY float64
AllowDecrypted bool
OnProgress func(stage string, completed, total int) error
}
// ImportObjects 迁移选区及引用资源,不改变来源与目标页面结构,提交一次撤销记录
// 保留对象原文与图层继承,同页链接映射到目标页,无法迁移的跨页链接明确报错
// 返回后可关闭来源阅读器,后续修改来源不影响副本
// 入参: source 来源编辑器, sourcePage 来源页, ids 对象标识, page 目标页, options 迁移选项
// 返回: []string 新对象标识, error 错误信息
func (e *Editor) ImportObjects(source *Editor, sourcePage int, ids []string, page int, options ObjectImportOptions) ([]string, error) {
if !finite(options.DX) || !finite(options.DY) {
return nil, fmt.Errorf("import requires finite offsets")
}
if source.encryption != nil && e.encryption == nil && !options.AllowDecrypted {
return nil, ErrEncryptionPolicyRequired
}
objects, err := source.Objects(sourcePage, ids)
if err != nil {
return nil, err
}
target, err := e.page(page)
if err != nil || len(objects) == 0 {
return nil, err
}
if source == e {
return e.CopyObjects(page, objects, options.DX, options.DY)
}
reader, err := source.Reader()
if err != nil {
return nil, err
}
doc, err := reader.Doc()
if err != nil {
return nil, err
}
ref := doc.Pages.Page[sourcePage]
progress := editorProgress(options.OnProgress)
base, migration, err := e.prepareImport(reader, doc, map[string]bool{ref.ID: true}, false, progress)
if err != nil {
return nil, err
}
migration.ids[ref.ID], migration.strictLinks = target.ID, true
pageName := reader.ResPath(ref.BaseLoc)
content, err := reader.PageContentByIndex(sourcePage)
if err != nil {
return nil, err
}
for _, value := range content.PageRes {
name, err := migration.resolve(pageName, "", value)
if err != nil {
return nil, err
}
if err := migration.resourceIndex(name); err != nil {
return nil, err
}
}
layers := make([][]byte, len(objects))
for i, object := range objects {
if err := progress.report("objects", i, len(objects)); err != nil {
return nil, err
}
var data []byte
var attrs ofdAttrs
if origin := object.origin; origin != nil {
data, err = editorXMLObject(origin.data, origin.node, origin.object, object)
if err == nil {
data, err = editorXMLStandalone(data, origin.node)
}
for parent := origin.node.parent; parent != nil; parent = parent.parent {
if parent.name.Local == "Layer" {
attrs.add("DrawParam", parent.attr("DrawParam"))
break
}
}
} else {
data, err = editorObjectXML(object)
}
if err != nil {
return nil, err
}
data, err = editorXMLContainer("Layer", attrs, data)
if err != nil {
return nil, err
}
root, err := parseEditorXML(data)
if err != nil {
return nil, err
}
layers[i], err = migration.encode(editorImportEntry{name: pageName, data: data}, root)
if err != nil {
return nil, err
}
}
if err := migration.encodeResources(); err != nil {
return nil, err
}
for _, resource := range e.resources {
delete(migration.files, resource.name)
}
next, err := migration.merge(base, nil, nil, nil)
if err != nil {
return nil, err
}
var copied []string
err = e.Transaction(func(editor *Editor) error {
if err := progress.report("commit", 0, 0); err != nil {
return err
}
editor.bindImportResources()
editor.source, editor.maxID = next, migration.maximum
next.idsReady = true
imported := make([]GraphicObject, len(layers))
for i, data := range layers {
data, err := editorXMLContainer("Content", nil, data)
if err != nil {
return err
}
var content Content
if err := xml.Unmarshal(data, &content); err != nil {
return err
}
layer := content.Layer[0]
root, err := parseEditorXML(data)
if err != nil {
return err
}
object := layer.Objects[0]
imported[i], err = editor.copiedObjectStyle(object, layer.DrawParam)
if err != nil {
return err
}
imported[i].TextObject.layout = objects[i].TextObject.layout
imported[i].state = objects[i].state
imported[i].CompositeGraphicUnit.states = remapCompositeStates(objects[i].CompositeGraphicUnit.states, migration.ids)
imported[i].origin = &editorObjectOrigin{editor: editor, data: data, node: root.child("Layer").children[0], object: object}
}
copied, err = editor.CopyObjects(page, imported, options.DX, options.DY)
return err
})
return copied, err
}
// ImportPages 将来源主文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作
// 页面索引从0开始,at可等于当前页数;复制关联模板、注释、签章外观和实际引用的资源
// 不导入来源元数据和目录,指向未选页面的跳转被移除;签名数据仅保留原始凭据,不代表合并后文档有效
@@ -117,56 +264,10 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo
return nil, nil
}
progress := editorProgress(options.OnProgress)
maximum, err := e.sourceMaxID(progress)
base, m, err := e.prepareImport(source, doc, selected, copyPage, progress)
if err != nil {
return nil, err
}
base := e.source
if base == nil {
base, err = e.importBase()
if err != nil {
return nil, err
}
}
m := &editorPageImport{reader: source, doc: doc, directory: e.packageDirectory(), target: base.reader, maximum: maximum, copyPage: copyPage, progress: progress,
paths: make(map[string]string), evidence: make(map[string]string),
ids: make(map[string]string), pages: selected, files: make(map[string][]byte), resources: make(map[string]editorImportEntry), used: make(map[string][]byte), templates: make(map[string]TemplatePage), templateParts: make(map[string][]byte), attachments: make(map[string][]byte)}
for _, resource := range e.resources {
m.files[resource.name] = nil
}
m.documentXML, err = m.readFile(source.ResPath(source.OFD.DocBody[0].DocRoot))
if err != nil {
return nil, err
}
m.documentRoot, err = parseEditorXML(m.documentXML)
if err != nil {
return nil, err
}
if copyPage {
for _, page := range doc.Pages.Page {
if !selected[page.ID] {
m.ids[page.ID] = page.ID
}
}
}
for _, name := range append(slices.Clone(doc.CommonData.PublicRes), doc.CommonData.DocumentRes...) {
if err := m.resourceIndex(source.ResPath(name)); err != nil {
return nil, err
}
}
for _, template := range doc.CommonData.TemplatePage {
m.templates[template.ID] = template
}
if doc.CommonData.DefaultCS != 0 {
m.defaultCS, err = m.reference(strconv.Itoa(doc.CommonData.DefaultCS))
if err != nil {
return nil, err
}
} else if !e.sourceRGB() {
m.defaultCS = m.id("defaultRGB")
m.used["defaultRGB"] = []byte(`<ofd:ColorSpace ID="` + m.defaultCS + `" Type="RGB" BitsPerComponent="8"/>`)
m.resources["defaultRGB"] = editorImportEntry{group: "ColorSpaces"}
}
refs := make([]Page, len(indexes))
ids := make([]string, len(indexes))
for i, index := range indexes {
@@ -209,30 +310,9 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo
}
}
if !copyPage {
var resourceContent []byte
for _, group := range []string{"ColorSpaces", "DrawParams", "Fonts", "MultiMedias", "CompositeGraphicUnits"} {
var entries []byte
for _, id := range slices.Sorted(maps.Keys(m.used)) {
if m.resources[id].group == group {
entries = append(entries, m.used[id]...)
}
}
if len(entries) == 0 {
continue
}
encoded, err := editorXMLContainer(group, nil, entries)
if err != nil {
if err := m.encodeResources(); err != nil {
return nil, err
}
resourceContent = append(resourceContent, encoded...)
}
resources, err := editorXMLContainer("Res", nil, resourceContent)
if err != nil {
return nil, err
}
if len(resourceContent) != 0 {
m.resourceXML = resources
}
}
for _, resource := range e.resources {
delete(m.files, resource.name)
@@ -281,19 +361,7 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo
if err := progress.report("commit", 0, 0); err != nil {
return nil, err
}
if e.source == nil {
for i := range e.resources {
resource := &e.resources[i]
if resource.font != nil {
resource.font = cloneEditorData(resource.font)
resource.font.FontFile = "/" + resource.name
}
if resource.image != nil {
resource.image = cloneEditorData(resource.image)
resource.image.MediaFile = "/" + resource.name
}
}
}
e.bindImportResources()
e.source, e.maxID = next, m.maximum
next.idsReady = true
e.outlines = outlines
@@ -320,6 +388,111 @@ func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage boo
return ids, nil
}
// bindImportResources 将新建文档资源路径绑定到包根,保持迁移前后的解析一致
func (e *Editor) bindImportResources() {
if e.source != nil {
return
}
for i := range e.resources {
resource := &e.resources[i]
if resource.font != nil {
resource.font = cloneEditorData(resource.font)
resource.font.FontFile = "/" + resource.name
}
if resource.image != nil {
resource.image = cloneEditorData(resource.image)
resource.image.MediaFile = "/" + resource.name
}
}
}
// prepareImport 建立页面和对象迁移共用的资源图,不修改目标编辑器
// 入参: source 来源, doc 来源文档, selected 页面映射范围, copyPage 是否同文档, progress 进度
// 返回: *editorSource 目标基础, *editorPageImport 资源图, error 错误信息
func (e *Editor) prepareImport(source *Reader, doc *Document, selected map[string]bool, copyPage bool, progress editorProgress) (*editorSource, *editorPageImport, error) {
maximum, err := e.sourceMaxID(progress)
if err != nil {
return nil, nil, err
}
base := e.source
if base == nil {
base, err = e.importBase()
if err != nil {
return nil, nil, err
}
}
m := &editorPageImport{reader: source, doc: doc, directory: e.packageDirectory(), target: base.reader, maximum: maximum, copyPage: copyPage, progress: progress,
paths: make(map[string]string), evidence: make(map[string]string), ids: make(map[string]string), pages: selected,
files: make(map[string][]byte), resources: make(map[string]editorImportEntry), used: make(map[string][]byte),
templates: make(map[string]TemplatePage), templateParts: make(map[string][]byte), attachments: make(map[string][]byte)}
for _, resource := range e.resources {
m.files[resource.name] = nil
}
m.documentXML, err = m.readFile(source.ResPath(source.OFD.DocBody[0].DocRoot))
if err != nil {
return nil, nil, err
}
m.documentRoot, err = parseEditorXML(m.documentXML)
if err != nil {
return nil, nil, err
}
if copyPage {
for _, page := range doc.Pages.Page {
if !selected[page.ID] {
m.ids[page.ID] = page.ID
}
}
}
for _, name := range append(slices.Clone(doc.CommonData.PublicRes), doc.CommonData.DocumentRes...) {
if err := m.resourceIndex(source.ResPath(name)); err != nil {
return nil, nil, err
}
}
for _, template := range doc.CommonData.TemplatePage {
m.templates[template.ID] = template
}
if doc.CommonData.DefaultCS != 0 {
m.defaultCS, err = m.reference(strconv.Itoa(doc.CommonData.DefaultCS))
if err != nil {
return nil, nil, err
}
} else if !e.sourceRGB() {
m.defaultCS = m.id("defaultRGB")
m.used["defaultRGB"] = []byte(`<ofd:ColorSpace ID="` + m.defaultCS + `" Type="RGB" BitsPerComponent="8"/>`)
m.resources["defaultRGB"] = editorImportEntry{group: "ColorSpaces"}
}
return base, m, nil
}
// encodeResources 编码本次迁移实际引用的资源,不包含未使用的资源定义
// 返回: error 编码错误
func (m *editorPageImport) encodeResources() error {
var content []byte
ids := slices.Sorted(maps.Keys(m.used))
for _, group := range []string{"ColorSpaces", "DrawParams", "Fonts", "MultiMedias", "CompositeGraphicUnits"} {
var entries []byte
for _, id := range ids {
if m.resources[id].group == group {
entries = append(entries, m.used[id]...)
}
}
if len(entries) == 0 {
continue
}
encoded, err := editorXMLContainer(group, nil, entries)
if err != nil {
return err
}
content = append(content, encoded...)
}
if len(content) == 0 {
return nil
}
var err error
m.resourceXML, err = editorXMLContainer("Res", nil, content)
return err
}
// readFile 分块读取导入条目,允许资源解压过程中取消,不共享来源内存
// 入参: name 包内路径
// 返回: []byte 条目内容, error 错误信息
@@ -738,6 +911,9 @@ func (m *editorPageImport) encode(entry editorImportEntry, node *editorXML) ([]b
bookmarks[bookmark.Name] = bookmark.Dest
}
if dest := gotoDest(&action, bookmarks); !m.copyPage && (dest == nil || !m.pages[dest.PageID]) {
if m.strictLinks {
return nil, fmt.Errorf("object link targets a page outside the imported selection")
}
return nil, nil
}
}
@@ -1073,20 +1249,24 @@ func (m *editorPageImport) merge(base *editorSource, refs []Page, annotations, s
for _, id := range slices.Sorted(maps.Keys(m.templateParts)) {
content = append(content, m.templateParts[id]...)
}
data = editorPatchXML(data, []editorXMLPatch{{position, position, content}})
root, err = parseEditorXML(data)
if err != nil {
return nil, err
patches := []editorXMLPatch{{position, position, content}}
patch := editorXMLPatch{common.open, common.open, editorXMLText("MaxUnitID", strconv.Itoa(m.maximum))}
if maximum := common.child("MaxUnitID"); maximum != nil {
patch = editorXMLContent(data, maximum, []byte(strconv.Itoa(m.maximum)))
}
patches = append(patches, patch)
pages := root.child("Pages")
if pages == nil {
return nil, fmt.Errorf("document has no Pages")
}
content = bytes.Clone(m.pageParts)
if pages.open != pages.end {
content = append(bytes.Clone(data[pages.open:pages.close]), content...)
if len(m.pageParts) != 0 {
if pages.open == pages.end {
patches = append(patches, editorXMLContent(data, pages, m.pageParts))
} else {
patches = append(patches, editorXMLPatch{pages.close, pages.close, m.pageParts})
}
data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, pages, content)})
}
data = editorPatchXML(data, patches)
if len(annotations) != 0 {
loc, err := mergeImportIndex(base.reader, files, base.document.Annotations, path.Join(m.directory, "Annotations.xml"), "Annotations", annotations)
if err != nil {
@@ -1117,16 +1297,6 @@ func (m *editorPageImport) merge(base *editorSource, refs []Page, annotations, s
}
}
}
root, err = parseEditorXML(data)
if err != nil {
return nil, err
}
common = root.child("CommonData")
patch := editorXMLPatch{common.open, common.open, editorXMLText("MaxUnitID", strconv.Itoa(m.maximum))}
if maximum := common.child("MaxUnitID"); maximum != nil {
patch = editorXMLContent(data, maximum, []byte(strconv.Itoa(m.maximum)))
}
data = editorPatchXML(data, []editorXMLPatch{patch})
files[name] = data
if len(signatures) != 0 {
loc, err := mergeImportIndex(base.reader, files, base.document.Signatures, path.Join(m.directory, "Signs", "Signatures.xml"), "Signatures", signatures)
+9 -1
View File
@@ -71,10 +71,12 @@ type editorSourcePage struct {
// Paint表示可独立修改纯色填充和描边,不要求重新排版文字
// ReplaceImage表示可替换图片数据,CropImage表示可裁剪图片;内部裁剪与原裁剪取交集
// MissingGlyphs提供缺字导致操作受限时的结构化诊断
// RewriteText表示可保留原定位修改,具体替换仍需校验长度、字形映射和字体覆盖
// Ungroup表示可移除组合容器并保留内部内容,Stretch表示图片及纯图片组合可独立调整宽高
type ObjectCapabilities struct {
Update bool
TextContent bool
RewriteText bool
Paint bool
ReplaceFont bool
ReplaceImage bool
@@ -504,6 +506,7 @@ func (e *Editor) objectCapabilities(object GraphicObject, orderable map[*editorX
origin := e.objectOrigin(id)
if origin == nil {
all.TextContent = all.Reflow
all.RewriteText = object.Type == "TextObject"
all.Stretch = object.Type == "ImageObject"
all.ReplaceImage = object.Type == "ImageObject"
all.CropImage = all.ReplaceImage
@@ -551,7 +554,12 @@ func (e *Editor) objectCapabilities(object GraphicObject, orderable map[*editorX
all.Reason, all.ReasonCode = "image actions cannot be transformed together with the border", EditUnsupportedObject
}
if object.Type == "TextObject" {
all.TextContent = all.Update && object.TextObject.ReadDirection == 0 && object.TextObject.CharDirection == 0
all.TextContent = all.Update && object.TextObject.ReadDirection == 0 && object.TextObject.CharDirection == 0 && (all.LayoutKnown || len(object.TextObject.CGTransform) == 0)
all.RewriteText = all.TextContent
if !all.RewriteText {
text := object.TextObject
all.RewriteText = e.RewriteText(&text, text.Text()) == nil
}
all.Arrange = all.Update || e.editorTextMeasurable(object.TextObject)
if _, err := object.TextObject.TextFrame(); err != nil {
all.Reflow = false
+18 -3
View File
@@ -236,7 +236,22 @@ func (e *Editor) sourceParts(progress editorProgress) (map[string][]byte, error)
// 返回: []byte 页面XML, error 错误信息
func (e *Editor) sourcePageXML(index int, source *editorSourcePage) ([]byte, error) {
page := e.pages[index]
if reflect.DeepEqual(page, *source.original) {
unchanged := reflect.DeepEqual(page, *source.original)
if unchanged && len(e.origins) != 0 {
for _, layer := range page.Content.Layer {
for _, object := range layer.Objects {
id := editorObjectID(object)
if origin := e.origins[id]; origin != nil && origin.node != source.nodes[id] {
unchanged = false
break
}
}
if !unchanged {
break
}
}
}
if unchanged {
return source.data, nil
}
if source.root.open == source.root.end {
@@ -314,11 +329,11 @@ func (e *Editor) sourcePageXML(index int, source *editorSourcePage) ([]byte, err
if exists {
nextID := editorObjectID(next)
origin := source.nodes[nextID]
if origin != nil && reflect.DeepEqual(before[nextID], next) {
current := e.objectOrigin(nextID)
if origin != nil && current.node == origin && reflect.DeepEqual(before[nextID], next) {
encoded = data[origin.start:origin.end]
} else {
var err error
current := e.objectOrigin(nextID)
encoded, err = editorXMLObject(current.data, current.node, current.object, next)
if err != nil {
return nil, err
+131
View File
@@ -15,6 +15,7 @@
package ofdgo
import (
"encoding/xml"
"fmt"
"image/color"
"math"
@@ -45,6 +46,136 @@ type ImageBorderStyle struct {
Color *StrokeColor
}
// DrawParam 读取绘制参数,resolved为true时合并继承链,返回独立副本
// 入参: id 资源标识, resolved 是否解析继承
// 返回: *DrawParam 绘制参数, error 错误信息
func (e *Editor) DrawParam(id string, resolved bool) (*DrawParam, error) {
if resolved {
return e.editorDrawParam(id, make(map[string]bool))
}
for _, resource := range e.resources {
if resource.draw != nil && resource.draw.ID == id {
return cloneEditorData(resource.draw), nil
}
}
if e.source != nil {
if value := e.source.reader.drawParamCache[id]; value != nil {
return cloneEditorData(value), nil
}
}
return nil, &EditError{Code: EditUnsupportedStyle, Err: fmt.Errorf("draw parameter %q not found", id)}
}
// SetObjectDrawParam 修改对象的绘制参数引用,保留局部覆盖和原文扩展,空标识恢复图层继承
// 入参: page 页面索引, ids 对象标识, id 绘制参数标识
// 返回: error 错误信息
func (e *Editor) SetObjectDrawParam(page int, ids []string, id string) error {
if _, err := e.editorDrawParam(id, make(map[string]bool)); err != nil {
return err
}
objects, indexes, err := e.selectedObjects(page, ids)
if err != nil {
return err
}
origins := make(map[string]*editorObjectOrigin)
for i, object := range objects {
if object.Type != "TextObject" && object.Type != "PathObject" && object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit" {
return fmt.Errorf("object %q does not support draw parameters", ids[i])
}
origin := e.objectOrigin(ids[i])
if origin != nil && origin.node.attr("DrawParam") == id {
continue
}
var data []byte
if origin != nil {
before, resolveErr := e.resolveEditorStyle(origin.object, e.pages[page].Content.Layer[indexes[i].layer].DrawParam)
if resolveErr != nil {
return resolveErr
}
oldXML, encodeErr := editorObjectXML(before)
if encodeErr != nil {
return encodeErr
}
comparison := object
comparison.TextObject.DrawParam, comparison.PathObject.DrawParam = "", ""
newXML, encodeErr := editorObjectXML(comparison)
if encodeErr != nil {
return encodeErr
}
data, err = editorXMLMerge(origin.data, origin.node, oldXML, newXML)
if err == nil {
data, err = editorXMLStandalone(data, origin.node)
}
} else {
data, err = editorObjectXML(object)
}
if err != nil {
return err
}
root, err := parseEditorXML(data)
if err != nil {
return err
}
if id == "" {
before, encodeErr := editorXMLContainer(root.name.Local, ofdAttrs{{Name: xml.Name{Local: "DrawParam"}, Value: root.attr("DrawParam")}}, nil)
if encodeErr != nil {
return encodeErr
}
after, encodeErr := editorXMLContainer(root.name.Local, nil, nil)
if encodeErr != nil {
return encodeErr
}
data, err = editorXMLMerge(data, root, before, after)
} else {
data, err = editorXMLAttribute(data, root, "DrawParam", id)
}
if err != nil {
return err
}
next := GraphicObject{Type: object.Type, state: object.state}
var target any
switch object.Type {
case "TextObject":
target = &next.TextObject
case "PathObject":
target = &next.PathObject
default:
target = &next.CompositeGraphicUnit
}
if err := xml.Unmarshal(data, target); err != nil {
return err
}
next, err = e.resolveEditorStyle(next, e.pages[page].Content.Layer[indexes[i].layer].DrawParam)
if err != nil {
return err
}
if next.TextObject.Font == object.TextObject.Font && next.TextObject.Size == object.TextObject.Size {
next.TextObject.layout = object.TextObject.layout
}
switch next.Type {
case "TextObject":
next.TextObject.DrawParam = id
case "PathObject":
next.PathObject.DrawParam = id
}
next.CompositeGraphicUnit.states = object.CompositeGraphicUnit.states
root, err = parseEditorXML(data)
if err != nil {
return err
}
updated := &editorObjectOrigin{editor: e, data: data, node: root, object: next}
if origin != nil {
updated.page, root.parent = origin.page, origin.node.parent
} else {
content := &editorXML{name: xml.Name{Local: "Content"}}
root.parent = &editorXML{name: xml.Name{Local: "Layer"}, parent: content, children: []*editorXML{root}}
content.children = []*editorXML{root.parent}
}
objects[i], origins[ids[i]] = next, updated
}
return e.updateObjectOrigins(page, objects, true, origins)
}
// AddDrawParam 注册绘制参数,保留继承引用,未引用的资源不写入文档
// 入参: draw 绘制参数,ID由编辑器分配
// 返回: string 资源标识, error 错误信息
+7 -6
View File
@@ -66,8 +66,9 @@ func missingGlyphError(id string, characters []rune) error {
// Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify
// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负
// LeftIndent和RightIndent为左右缩进,FirstLineIndent为每段首行相对左缩进的偏移,单位为毫米
// 零值保持显式换行和左对齐
// AutoBidi自动识别双向文字并启用塑形,零值保持显式换行和左对齐
type TextLayout struct {
AutoBidi bool
Shape bool
Shaping TextShapeOptions
Wrap bool
@@ -256,7 +257,7 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error {
}
check = *obj
check.TextCode = codes
position := 0
position, mapping := 0, 0
for i, code := range codes {
before, after := textCodeRunes(obj.TextCode[i].Value), textCodeRunes(code.Value)
if len(before) == len(after) {
@@ -264,11 +265,11 @@ func (e *Editor) RewriteText(obj *TextObject, value string) error {
if before[j] == char {
continue
}
for k := range transforms {
transform := &transforms[k]
if position+j < transform.CodePosition || position+j >= transform.CodePosition+transform.CodeCount {
continue
for mapping < len(transforms) && position+j >= transforms[mapping].CodePosition+transforms[mapping].CodeCount {
mapping++
}
if mapping < len(transforms) && position+j >= transforms[mapping].CodePosition {
transform := &transforms[mapping]
if transform.CodeCount != 1 || transform.GlyphCount != 1 {
return &EditError{Code: EditLayoutRequired, Err: fmt.Errorf("changing a combined glyph requires explicit layout")}
}
+16
View File
@@ -19,6 +19,22 @@ import (
"strconv"
)
// Actions 返回对象动作的独立副本,不修改原对象
// 返回: []Action 动作列表
func (o GraphicObject) Actions() []Action {
switch o.Type {
case "TextObject":
return cloneEditorData(o.TextObject.Actions)
case "PathObject", "Path":
return cloneEditorData(o.PathObject.Actions)
case "ImageObject":
return cloneEditorData(o.ImageObject.Actions)
case "CompositeObject", "CompositeGraphicUnit":
return cloneEditorData(o.CompositeGraphicUnit.Actions)
}
return nil
}
// UnmarshalXML 解析路径并区分省略的虚线样式与显式实线
// 入参: d XML解码器, start 起始节点
// 返回: error 错误信息
+8
View File
@@ -53,6 +53,14 @@ type Renderer struct {
// RendererOption 渲染器配置选项
type RendererOption func(*Renderer)
// ClearCache 释放派生字体、图片、模板与后端缓存,不改变文档和渲染配置
func (r *Renderer) ClearCache() {
r.backendStates = make(map[any]any)
r.imageCache = make(map[string]image.Image)
r.templatePageCache = make(map[string]*PageContent)
r.resetFontCache()
}
// SetFontFS 替换外部字体文件系统并重置字体缓存
// 入参: fsys 字体文件系统
func (r *Renderer) SetFontFS(fsys ...fs.FS) {
+14
View File
@@ -20,6 +20,8 @@ import (
"slices"
"strings"
"unicode/utf8"
"golang.org/x/text/unicode/bidi"
)
// LayoutText 按自有字体度量重排横向文字,保留绘制属性,可显式启用字体塑形
@@ -76,6 +78,18 @@ func LayoutText(obj *TextObject, value string, options TextLayout, metrics FontM
if lineHeight == 0 {
lineHeight = math.Max(obj.Size, float64(int(ascender)+int(descender)+int(gap))*unit)
}
if options.AutoBidi && !options.Shaping.Bidi {
for _, char := range value {
properties, _ := bidi.LookupRune(char)
switch properties.Class() {
case bidi.R, bidi.AL, bidi.RLE, bidi.RLO, bidi.RLI, bidi.FSI:
options.Shaping.Bidi = true
}
if options.Shaping.Bidi {
break
}
}
}
if options.Shape || options.Shaping != (TextShapeOptions{}) {
return layoutShapedText(obj, value, options, metrics, width, lineHeight, hScale, float64(ascender)*unit)
}
+5 -7
View File
@@ -293,21 +293,19 @@ func validateTextGlyphs(obj TextObject, glyphCount uint16) error {
total += len(textCodeRunes(code.Value))
ends = append(ends, total)
}
previous := 0
previous, boundary := 0, 0
for _, transform := range obj.CGTransform {
start, count := transform.CodePosition, transform.CodeCount
if start < previous || count <= 0 || start >= total || count > total-start {
return fmt.Errorf("invalid glyph character range")
}
end := start + count
for _, boundary := range ends {
if start < boundary {
if end > boundary {
for ends[boundary] <= start {
boundary++
}
if end > ends[boundary] {
return fmt.Errorf("glyph mapping crosses text codes")
}
break
}
}
ids := strings.Fields(transform.Glyphs)
if len(ids) == 0 || len(ids) != transform.GlyphCount {
return fmt.Errorf("invalid glyph count")