feat(编辑能力): 支持复合对象内部编辑,优化提示文案与操作稳定性
build.yaml / build (push) Successful in 36s

This commit is contained in:
xiaoqidun committed 2026-09-17 15:13:30 +08:00
1 parent 094f67e333
commit 896b00148a
14 files changed
+946 -43

No files matched your search

+1
View File
@@ -67,6 +67,7 @@
<button id="multiSelectButton" class="button" type="button" aria-pressed="false" title="多选对象">多选</button>
<button id="editObjectButton" class="button" type="button" aria-label="修改对象" disabled>修改</button>
<button id="copyObjectButton" class="button" type="button" aria-label="复制对象" disabled>复制</button>
<button id="compositeBackButton" class="button" type="button" title="返回上层" hidden>返回</button>
<button id="deleteObjectButton" class="button" type="button" disabled>删除</button>
<select id="objectOrder" class="order-select" aria-label="对象层级" disabled>
<option value="" hidden>层级</option>
+1
View File
@@ -1287,6 +1287,7 @@ button.outline-link:hover {
}
.empty-state[hidden],
.editor-group > [hidden],
.panel-row > [hidden],
.meta-list > [hidden],
.navigation-tabs[hidden],
+98 -22
View File
@@ -25,6 +25,7 @@ let wasmRecoveryTimer = 0;
let textMeasure = null;
const state = {
composite: null,
ready: false,
wasmExited: false,
wasmSeq: 0,
@@ -107,6 +108,7 @@ const el = {
selectObjectButton: document.querySelector("#selectObjectButton"),
deleteObjectButton: document.querySelector("#deleteObjectButton"),
editObjectButton: document.querySelector("#editObjectButton"),
compositeBackButton: document.querySelector("#compositeBackButton"),
copyObjectButton: document.querySelector("#copyObjectButton"),
objectOrder: document.querySelector("#objectOrder"),
objectAlign: document.querySelector("#objectAlign"),
@@ -382,6 +384,11 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
: changeDocument("ofdgoDeleteObjects", item);
},
onEdit: editCanvasObject,
onExitScope: () => {
if (!state.composite) return false;
exitCompositeScope();
return true;
},
drawStyle: shapeStyle,
onTool: updateDrawingControls,
onDraw: (index, shape, box, style) => changeDocument("ofdgoInsertShape", null, index, shape,
@@ -439,6 +446,7 @@ editorClick(el.multiSelectButton, () => {
canvasEditor.multiple = !canvasEditor.multiple;
el.multiSelectButton.setAttribute("aria-pressed", String(canvasEditor.multiple));
});
editorClick(el.compositeBackButton, exitCompositeScope);
editorClick(el.editObjectButton, () => {
if (canvasEditor.selected) {
editCanvasObject(canvasEditor.selected);
@@ -981,7 +989,7 @@ function openObjectBounds() {
state.boundsOriginal = { x: box.x, y: box.y, width: Math.abs(box.width), height: Math.abs(box.height) };
for (const [input, key] of [[el.objectX,"x"], [el.objectY,"y"], [el.objectWidth,"width"], [el.objectHeight,"height"]]) setObjectDimension(input, state.boundsOriginal[key]);
el.objectAspect.checked = true;
el.objectAspect.disabled = !item.shape && (item.items || [item]).some(member => member.type !== "ImageObject" || member.imageBorder) || lineShape(item.shape) && (!box.width || !box.height);
el.objectAspect.disabled = Boolean(state.composite) || !item.shape && (item.items || [item]).some(member => member.type !== "ImageObject" || member.imageBorder) || lineShape(item.shape) && (!box.width || !box.height);
el.objectWidth.disabled = lineShape(item.shape) && !box.width;
el.objectHeight.disabled = lineShape(item.shape) && !box.height;
el.objectBoundsStatus.textContent = "";
@@ -1189,6 +1197,7 @@ function setEditorInfo(doc) {
}
function pageCan(capability, index = state.pageIndex) {
if (state.composite && capability === "insert") return false;
const bit = { insert: 1, copy: 2, delete: 4, move: 8, resize: 16 }[capability];
return Boolean(state.editorInfo?.pageCapabilities?.[index] & bit);
}
@@ -1198,6 +1207,7 @@ async function toggleEditor() {
if (state.editorInfo) {
if (!await canvasEditor.commitText() || !await canvasEditor.commitCrop()) return;
state.editing = !state.editing;
resetCompositeScope();
canvasEditor.clear();
canvasEditor.setTool("");
if (state.editing) {
@@ -1220,6 +1230,7 @@ async function toggleEditor() {
state.savedRevision = doc.revision;
setEditorInfo(doc);
state.editing = true;
state.composite = null;
state.selectObjects = true;
state.ofdBytes = null;
state.objectClipboard = null;
@@ -1270,6 +1281,7 @@ async function createDocument(event) {
return;
}
state.editing = true;
state.composite = null;
state.objectClipboard = null;
state.styleClipboard = null;
state.fontRenderPending = false;
@@ -1298,6 +1310,7 @@ async function createDocument(event) {
async function editCanvasObject(item) {
if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input || item.items) return;
if (canEditObject(item, "enter")) return enterCompositeScope(item.index, item.id);
if (item.type === "TextObject") {
if (!confirmTextReflow(item)) return;
} else if (!canEditObject(item, "update")) return;
@@ -1328,6 +1341,51 @@ async function editCanvasObject(item) {
}
}
function mountEditorObjects(index, page, surface) {
const scope = state.composite?.index === index ? state.composite : null;
canvasEditor.mount(index, scope ? { ...page, objects: scope.objects } : page, surface);
}
function resetCompositeScope() {
const scope = state.composite;
if (!scope) return;
state.composite = null;
canvasEditor.clear();
const page = state.pageCache.get(scope.index), surface = pageShell(scope.index)?.querySelector(".page-surface");
if (page && surface) mountEditorObjects(scope.index, page, surface);
}
async function enterCompositeScope(index, key) {
const openSeq = state.openSeq;
setBusy(true);
try {
const objects = await callWASM("ofdgoCompositeObjects", index, key);
if (openSeq !== state.openSeq) return;
canvasEditor.clear();
canvasEditor.setTool("");
state.composite = { index, key, objects };
const page = state.pageCache.get(index), surface = pageShell(index)?.querySelector(".page-surface");
if (page && surface) mountEditorObjects(index, page, surface);
updateControls();
setStatus(pageStatus(index, state.doc.pageCount));
el.viewerPanel.focus({ preventScroll: true });
} catch (err) {
if (openSeq === state.openSeq) showError(err, false);
} finally {
if (openSeq === state.openSeq) setBusy(false);
}
}
async function exitCompositeScope() {
const scope = state.composite;
if (!scope) return;
const split = scope.key.lastIndexOf("/");
if (split >= 0) return enterCompositeScope(scope.index, scope.key.slice(0, split));
resetCompositeScope();
updateControls();
el.viewerPanel.focus({ preventScroll: true });
}
async function startImageCrop(item) {
const openSeq = state.openSeq;
const urls = [];
@@ -1738,7 +1796,7 @@ function updateDrawingControls() {
button.disabled = disabled || !pageCan("insert");
button.setAttribute("aria-pressed", String(tool === name));
}
el.eraseButton.disabled = el.eraseMode.disabled = disabled;
el.eraseButton.disabled = el.eraseMode.disabled = disabled || Boolean(state.composite);
el.eraseButton.setAttribute("aria-pressed", String(tool.startsWith("erase-")));
el.selectObjectButton.setAttribute("aria-pressed", String(canvasEditor.enabled && !tool));
const styleDisabled = disabled || (canvasEditor.selected ? !path || !canEditObject(path, "paint") : !pageCan("insert"));
@@ -1764,7 +1822,15 @@ async function changeDocument(name, item, ...args) {
if (openSeq !== state.openSeq) {
return;
}
const doc = await callWASM(name, ...(item ? [item.index, item.id] : []), ...args);
const scope = state.composite;
const operation = { ofdgoTransformObject: "transform", ofdgoTransformObjects: "transform", ofdgoRotateObjects: "rotate", ofdgoFlipObjects: "flip", ofdgoResizeObjects: "resize" }[name];
const members = item?.items || (item ? [item] : []);
const scoped = scope && members.length && members.every(member => member.scoped);
if (!item) resetCompositeScope();
if (scoped && !operation) throw new Error("内部对象暂不支持此操作");
const doc = scoped
? await callWASM("ofdgoChangeCompositeObjects", item.index, scope.key, Array.isArray(item.id) ? item.id : members.map(member => member.id), operation, ...args)
: await callWASM(name, ...(item ? [item.index, item.id] : []), ...args);
if (openSeq !== state.openSeq) {
return;
}
@@ -2355,6 +2421,7 @@ async function openOFD(file) {
state.ofdBytes = bytes;
state.fileName = file.name || "ofdgo.ofd";
state.editing = false;
state.composite = null;
state.pageSelection.clear();
state.pageSelectionAnchor = null;
state.pageMultiSelect = false;
@@ -2414,7 +2481,7 @@ async function openSelectedFonts(event) {
if (changed) {
await applyFontChange();
}
setStatus(saved ? "字体保存完成" : "字体仅限本次");
setStatus(saved ? "字体已保存" : "字体未保存,仅本次可用");
} catch (err) {
showError(err, false);
} finally {
@@ -2435,7 +2502,7 @@ async function loadLocalFonts() {
await nextFrame();
const available = await fontManager.queryLocal();
if (!state.doc) {
setStatus(available.length ? `字体授权完成 ${available.length} 个` : "暂无系统字体");
setStatus(available.length ? `已授权${available.length}种字体` : "暂无系统字体");
return;
}
if (await loadDocumentLocalFonts(available)) {
@@ -2459,7 +2526,7 @@ async function requestLocalFontsBeforeOpen() {
setBusy(true, "正在请求授权", 12, "正在请求授权");
try {
const available = await fontManager.queryLocal();
setStatus(available.length ? `字体授权完成 ${available.length} 个` : "暂无系统字体");
setStatus(available.length ? `已授权${available.length}种字体` : "暂无系统字体");
} catch (err) {
if (err && err.name === "NotAllowedError") {
setStatus("字体尚未授权");
@@ -2503,7 +2570,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
}
if (docFonts.every((font) => font.embedded)) {
fontManager.localFonts = [];
setStatus("字体均为内嵌");
setStatus("字体均已内嵌");
updateFontSummary();
renderFontList();
return false;
@@ -2524,7 +2591,7 @@ async function loadDocumentLocalFonts(available, openSeq = state.openSeq) {
fonts.push(fontManager.record(fontManager.localName(item), data, "browser"));
}
fontManager.localFonts = fonts;
setStatus(fonts.length ? `字体加载完成 ${fonts.length} 个` : emptyStatus);
setStatus(fonts.length ? `已加载${fonts.length}种字体` : emptyStatus);
updateFontSummary();
renderFontList();
return fonts.length > 0;
@@ -2972,7 +3039,7 @@ async function downloadAttachment(attachment) {
if (result.blob) {
downloadBytes(result.blob, result.mime, attachment.fileName);
}
setStatus(`附件下载完成 ${formatBytes(result.size)}`);
setStatus(`附件已下载(${formatBytes(result.size)})`);
} catch (err) {
if (openSeq === state.openSeq) {
if (err.name === "AbortError") {
@@ -3086,7 +3153,7 @@ async function exportFile(whole, indices = null, value = el.exportFormat.value)
state.savedRevision = state.editorInfo.revision;
setDirty(false);
}
setStatus(`${result.label} ${saving ? "保存" : "导出"}完成 ${formatBytes(result.size)}`);
setStatus(`${result.label}已${saving ? "保存" : "导出"}(${formatBytes(result.size)})`);
return true;
} catch (err) {
if (openSeq === state.openSeq) {
@@ -3321,6 +3388,11 @@ async function processPageRenderQueue() {
continue;
}
const page = await callWASM("ofdgoRenderPage", task.index);
const scope = state.composite;
if (scope?.index === task.index) {
const objects = await callWASM("ofdgoCompositeObjects", task.index, scope.key);
if (scope === state.composite && task.openSeq === state.openSeq) scope.objects = objects;
}
await loadSVGFonts(page.fonts, task.openSeq);
if (task.openSeq === state.openSeq) {
for (const image of page.images) {
@@ -3436,7 +3508,7 @@ function mountPageSVG(index, page, openSeq = state.openSeq) {
surface.replaceChildren(svg);
surface.append(createTextLayer(page.text));
if (state.editorInfo) {
canvasEditor.mount(index, page, surface);
mountEditorObjects(index, page, surface);
}
for (const link of page.links) {
const anchor = document.createElement("a");
@@ -3670,7 +3742,7 @@ async function pasteEditorContent(event) {
if (token) {
const clipboard = state.objectClipboard;
if (!clipboard || token !== clipboard.token) {
setStatus("对象剪贴板失效");
setStatus("复制内容已失效,请重新复制");
return;
}
const openSeq = state.openSeq;
@@ -3700,7 +3772,7 @@ async function pasteEditorContent(event) {
return;
}
if (!/^image\/(png|jpeg)$/.test(files[0].type)) {
setStatus("图片仅支持 PNG、JPG");
setStatus("仅支持PNG、JPG图片");
return;
}
await changeDocument("ofdgoInsertImage", null, index, files[0].arrayBuffer().then(bytes => new Uint8Array(bytes)), x, y, width);
@@ -3853,6 +3925,7 @@ function pageShellAtPoint(x, y) {
}
function setCurrentPage(index) {
if (state.composite && state.composite.index !== index) resetCompositeScope();
state.pageIndex = index;
if (state.fitMode === "width") {
state.scale = fitWidthScale(currentPageInfo());
@@ -4958,7 +5031,7 @@ async function focusPageRegion(region, label) {
return;
}
highlightPageRegion(region);
setStatus(`${label}定位完成 第 ${region.page} 页`);
setStatus(`已定位${label}(第${region.page}页)`);
}
function highlightPageRegion(region) {
@@ -5453,14 +5526,17 @@ function updateEditorTools() {
}
function updateObjectControls(item, reset = false) {
el.compositeBackButton.hidden = !state.composite;
el.compositeBackButton.disabled = !state.ready || state.exporting;
el.deleteObjectButton.hidden = Boolean(state.composite);
const disabled = !item || Boolean(item.draft) || !state.ready || state.exporting;
el.deleteObjectButton.disabled = disabled || !canEditObject(item, "delete");
el.copyObjectButton.disabled = disabled || !canEditObject(item, "copy");
el.objectStyleButton.disabled = disabled || !canEditObject(item, "transform");
el.objectStyleButton.disabled = disabled || Boolean(state.composite) || !canEditObject(item, "transform");
const cropping = Boolean(canvasEditor.crop);
el.objectBoundsButton.disabled = disabled || cropping || !canEditObject(item, "transform");
el.objectAlign.disabled = disabled || cropping || !canEditObject(item, "arrange");
el.objectRotate.disabled = el.objectFlip.disabled = el.objectAlign.disabled;
el.objectRotate.disabled = el.objectFlip.disabled = disabled || cropping || !canEditObject(item, state.composite ? "transform" : "arrange");
el.cropImageButton.disabled = disabled || item.type !== "ImageObject" || !canEditObject(item, "update");
el.imageFit.disabled = el.cropImageButton.disabled || cropping;
el.cropImageButton.textContent = cropping ? "完成" : "裁剪";
@@ -5470,12 +5546,12 @@ function updateObjectControls(item, reset = false) {
el.resetCropButton.setAttribute("aria-label", cropping ? "取消裁剪" : "还原图片");
el.resetCropButton.disabled = el.cropImageButton.disabled || !cropping && (!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) || item.type === "PathObject"
|| !canEditObject(item, item.type === "TextObject" ? "reflow" : "update");
el.editObjectButton.disabled = disabled || Boolean(item.items) || !canEditObject(item, "enter") && (item.type === "PathObject"
|| !canEditObject(item, item.type === "TextObject" ? "reflow" : "update"));
el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting;
const selection = selectedText(item);
const text = selection ? currentTextStyle() : state.textDefaults;
const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items && !selection);
const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(state.composite) || Boolean(item?.items && !selection);
fontPicker.setDisabled(textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "replaceFont"));
el.textSize.disabled = textDisabled || text !== state.textDefaults && !item.draft && (!canEditObject(item, "reflow") || Boolean(item?.items) && !canEditObject(item, "layoutKnown"));
el.textColor.disabled = textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "paint");
@@ -5498,9 +5574,9 @@ function updateObjectControls(item, reset = false) {
el.textSize.value = text.size === undefined ? "" : displayPoints(text.size);
}
el.textSize.placeholder = text.size === undefined ? "混合" : "";
el.textSize.title = text.size === undefined ? "字号:混合" : `字号 ${displayPoints(text.size)} pt`;
el.textSize.title = text.size === undefined ? "字号不同" : `字号${displayPoints(text.size)}pt`;
el.textColor.classList.toggle("mixed-color", Boolean(selection?.items && !text.color));
el.textColor.title = selection?.items && !text.color ? "文字颜色:混合" : "文字颜色";
el.textColor.title = selection?.items && !text.color ? "文字颜色不同" : "文字颜色";
if (reset || el.textColor.disabled || document.activeElement !== el.textColor) {
el.textColor.value = text.color || "#000000";
}
@@ -5512,7 +5588,7 @@ function updateObjectControls(item, reset = false) {
for (const [input, key, label] of [[el.shapeFillColor, "fillColor", "填充颜色"], [el.shapeStrokeColor, "strokeColor", "描边颜色"]]) {
const mixed = Boolean(path?.items && path[key] === undefined);
input.classList.toggle("mixed-color", mixed);
input.title = mixed ? `${label}:混合` : label;
input.title = mixed ? `${label}不同` : label;
if (path && (reset || document.activeElement !== input)) input.value = path[key] || "#000000";
}
el.shapeWidth.placeholder = path?.items && path.lineWidth === undefined ? "混合" : "";
+18 -13
View File
@@ -174,25 +174,24 @@ export function canEditObject(item, capability) {
return Boolean(item) && (item.items || [item]).every(object => Boolean(object.capabilities?.[capability]));
}
export function missingGlyphMessage(diagnostic) {
export function missingGlyphMessage(diagnostic, action = "请更换字体") {
if (!diagnostic) return "";
const characters = Array.from(diagnostic.characters);
const sample = characters.slice(0, 8).map(char => /[\p{C}\p{Z}\p{M}]/u.test(char)
? `U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}` : char).join(" ");
return `字体缺字:${sample}${characters.length > 8 ? ` 等 ${characters.length} 字` : ""},改选字体`;
? `U+${char.codePointAt(0).toString(16).toUpperCase().padStart(4, "0")}` : char).join("、");
return `字体缺字(${sample}${characters.length > 8 ? `等${characters.length}字` : ""}),${action}`;
}
export function objectEditReason(item) {
const labels = { fontUnavailable: "字体不可用", unsupportedColor: "颜色样式暂不支持", unsupportedStyle: "绘制参数暂不支持",
unsupportedContainer: "容器特性暂不支持", invalidObject: "对象数据异常" };
const labels = { fontUnavailable: "字体不可用", unsupportedColor: "暂不支持此颜色", unsupportedStyle: "暂不支持此样式",
unsupportedContainer: "暂不支持此对象结构", invalidObject: "对象数据异常" };
const reasons = (item?.items || (item ? [item] : [])).map(object => {
const reason = object.capabilities?.reason || "";
if (!reason) return "";
let message = labels[object.capabilities.reasonCode] || "对象特性暂不支持";
if (object.capabilities.missingGlyphs) message = missingGlyphMessage(object.capabilities.missingGlyphs);
const available = object.capabilities.transform ? object.capabilities.paint ? "可移动、改色" : "可移动"
: object.capabilities.copy && object.capabilities.delete ? "可复制、删除" : "暂不可编辑";
return `${available}:${message}`;
const message = labels[object.capabilities.reasonCode] || "暂不支持此对象特性";
const available = object.capabilities.transform ? object.capabilities.paint ? "仍可移动、改色" : "仍可移动"
: object.capabilities.copy && object.capabilities.delete ? "仍可复制、删除" : "暂不可编辑";
return object.capabilities.missingGlyphs ? missingGlyphMessage(object.capabilities.missingGlyphs, available) : `${message},${available}`;
});
return [...new Set(reasons.filter(Boolean))].join(";");
}
@@ -257,6 +256,7 @@ export class CanvasEditor {
}
mount(index, page, surface) {
surface.querySelector(".edit-layer")?.remove();
const crop = this.crop?.item.index === index ? this.crop : null;
if (crop) this.closeCrop(true);
const pending = this.pendingSelection?.index === index;
@@ -266,7 +266,7 @@ export class CanvasEditor {
this.pages.set(surface, { index, width: page.width, height: page.height });
const layer = document.createElement("div");
layer.className = "edit-layer";
const artwork = new Map([...surface.querySelectorAll("[data-ofd-object]")].map((node, order) => [node.getAttribute("data-ofd-object"), { node, order }]));
const artwork = new Map([...surface.querySelectorAll("[data-ofd-object], [data-ofd-child]")].map((node, order) => [node.getAttribute("data-ofd-object") || node.getAttribute("data-ofd-child"), { node, order }]));
const objects = [...page.objects].sort((a, b) => (artwork.get(a.id)?.order ?? 0) - (artwork.get(b.id)?.order ?? 0));
for (const object of objects) {
const node = document.createElement("div");
@@ -281,14 +281,14 @@ export class CanvasEditor {
node.setAttribute("aria-label", { ImageObject: "图片对象", TextObject: "文字对象", PathObject: "图形对象", CompositeObject: "复合对象", CompositeGraphicUnit: "复合对象" }[object.type]);
const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface };
item.artwork = artwork.get(object.id)?.node;
if (object.type === "PathObject" && (object.shape || object.outline) || (object.type === "CompositeObject" || object.type === "CompositeGraphicUnit") && object.contours?.length) {
if (object.type === "PathObject" && (object.shape || object.outline) || (object.scoped || object.type === "CompositeObject" || object.type === "CompositeGraphicUnit") && object.contours?.length) {
node.classList.add("edit-contour");
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.classList.add("edit-outline");
svg.setAttribute("aria-hidden", "true");
svg.setAttribute("preserveAspectRatio", "none");
let contour;
if (object.type === "PathObject") {
if (object.type === "PathObject" && !object.scoped) {
contour = document.createElementNS("http://www.w3.org/2000/svg", object.shape === "line" ? "line" : object.shape === "ellipse" ? "ellipse" : object.shape === "rectangle" ? "rect" : "path");
if (!object.shape) contour.setAttribute("d", object.outline);
svg.append(contour);
@@ -952,6 +952,11 @@ export class CanvasEditor {
this.setTool("");
return;
}
if (event.key === "Escape" && !this.input && this.enabled && !this.options.busy() && !this.nudge && this.options.onExitScope?.()) {
event.preventDefault();
event.stopPropagation();
return;
}
if (this.input || !this.enabled || !this.selected || this.options.busy() || this.nudgeCommit || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) {
return;
}
+93
View File
@@ -138,6 +138,8 @@ func RunWASM() {
registerCallback("ofdgoReshapeObject", reshapeObject)
registerCallback("ofdgoLayoutText", layoutText)
registerCallback("ofdgoTransformObjects", transformObjects)
registerCallback("ofdgoCompositeObjects", compositeObjects)
registerCallback("ofdgoChangeCompositeObjects", changeCompositeObjects)
registerCallback("ofdgoAlignObjects", alignObjects)
registerCallback("ofdgoDeleteObjects", deleteObjects)
registerCallback("ofdgoEraseObjects", eraseObjects)
@@ -266,6 +268,12 @@ func configureDocument(args []js.Value) (any, error) {
if err := currentSession.SetFonts(fonts); err != nil {
return nil, err
}
if currentEditor != nil {
currentEditor.SetFontFS()
if currentSession.fontFS != nil {
currentEditor.SetFontFS(currentSession.fontFS)
}
}
}
if currentSession.Renderer.RenderAnnotations != args[1].Bool() {
currentSession.Renderer.RenderAnnotations = args[1].Bool()
@@ -757,6 +765,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
if missing := capability.MissingGlyphs; missing != nil {
item["capabilities"].(map[string]any)["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters}
}
item["capabilities"].(map[string]any)["enter"] = capability.Transform && (object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit")
var alpha *int
switch object.Type {
case "TextObject":
@@ -851,6 +860,88 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
return objects, nil
}
// compositePath 解析画布复合对象路径
// 入参: value 顶层标识与逐层序号
// 返回: ofdgo.ObjectPath 库层路径, error 错误信息
func compositePath(value string) (ofdgo.ObjectPath, error) {
parts := strings.Split(value, "/")
path := ofdgo.ObjectPath{ID: parts[0]}
for _, part := range parts[1:] {
index, err := strconv.Atoi(part)
if err != nil || index < 0 {
return path, fmt.Errorf("invalid composite path")
}
path.Children = append(path.Children, index)
}
return path, nil
}
// compositeObjects 获取内部编辑范围,不重新渲染页面或传输图片
// 入参: args 页面索引与复合路径
// 返回: any 画布对象列表, error 错误信息
func compositeObjects(args []js.Value) (any, error) {
if currentEditor == nil {
return nil, fmt.Errorf("no document is being edited")
}
key := args[1].String()
path, err := compositePath(key)
if err != nil {
return nil, err
}
members, err := currentEditor.CompositeObjects(args[0].Int(), path)
if err != nil {
return nil, err
}
objects := make([]any, 0, len(members))
for i, member := range members {
box := member.Bounds
if box.W <= 0 || box.H <= 0 {
continue
}
objects = append(objects, map[string]any{"id": fmt.Sprintf("%s/%d", key, i), "type": member.Object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": i, "position": i, "count": len(members), "container": key, "scoped": true, "contours": member.Contours,
"capabilities": map[string]any{"transform": member.Transform, "enter": member.Object.Type == "CompositeObject" || member.Object.Type == "CompositeGraphicUnit"}})
}
return objects, nil
}
// changeCompositeObjects 复用库层内部选区变换与单次撤销
// 入参: args 页面索引、父路径、成员路径列表、操作及参数
// 返回: any 文档信息, error 错误信息
func changeCompositeObjects(args []js.Value) (any, error) {
return changeObjects(func() error {
key := args[1].String()
path, err := compositePath(key)
if err != nil {
return err
}
var indexes []int
for _, id := range stringsFromJS(args[2]) {
part, found := strings.CutPrefix(id, key+"/")
if !found {
return fmt.Errorf("selection is outside composite")
}
i, err := strconv.Atoi(part)
if err != nil {
return err
}
indexes = append(indexes, i)
}
page := args[0].Int()
switch args[3].String() {
case "transform":
return currentEditor.TransformCompositeObjects(page, path, indexes, args[4].Float(), args[5].Float(), args[6].Float())
case "rotate":
return currentEditor.RotateCompositeObjects(page, path, indexes, args[4].Int())
case "flip":
return currentEditor.FlipCompositeObjects(page, path, indexes, args[4].String())
case "resize":
return currentEditor.ResizeCompositeObjects(page, path, indexes, ofdgo.Box{X: args[4].Float(), Y: args[5].Float(), W: args[6].Float(), H: args[7].Float()})
default:
return fmt.Errorf("unsupported composite operation")
}
})
}
// editorFont 读取对象实际使用的内嵌字体,供画布输入使用
// 入参: args 字体资源标识
// 返回: any 字体数据, error 错误信息
@@ -1384,10 +1475,12 @@ func previewEditor(editor *ofdgo.Editor, annotations bool) (editorInfo, error) {
if err != nil {
return editorInfo{}, err
}
editor.SetFontFS()
if currentSession != nil {
if currentSession.fontFS != nil {
session.fontFS = currentSession.fontFS
session.Renderer.SetFontFS(session.fontFS)
editor.SetFontFS(session.fontFS)
}
_ = currentSession.Close()
}
+9
View File
@@ -22,6 +22,7 @@ import (
"encoding/xml"
"fmt"
"image"
"io/fs"
"math"
"reflect"
"slices"
@@ -45,6 +46,7 @@ type Editor struct {
pages []PageContent
resources []editorResource
fonts map[string]*font.SFNT
fontFS []fs.FS
images map[string]image.Point
resourceID map[editorResourceKey]string
maxID int
@@ -57,6 +59,12 @@ type Editor struct {
outlines []byte
}
// SetFontFS 设置几何度量使用的外部字体来源,与预览渲染器保持一致,不嵌入或替换文档字体
// 入参: fsys 字体文件系统,空参数恢复默认来源
func (e *Editor) SetFontFS(fsys ...fs.FS) {
e.fontFS = slices.Clone(fsys)
}
// editorResource 文档内嵌资源
type editorResource struct {
name string
@@ -65,6 +73,7 @@ type editorResource struct {
image *MultiMedia
space *ColorSpace
composite string
references []string
subset *editorFontSubset
}
+15 -1
View File
@@ -350,6 +350,13 @@ func (e *Editor) UpdateObjects(page int, objects []GraphicObject) error {
// 入参: page 页面索引, objects 新对象, preserveContent 是否保留原内容
// 返回: error 错误信息
func (e *Editor) updateObjects(page int, objects []GraphicObject, preserveContent bool) error {
return e.updateObjectOrigins(page, objects, preserveContent, nil)
}
// updateObjectOrigins 原子提交对象及原文来源,撤销时一并恢复
// 入参: page 页面索引, objects 新对象, preserveContent 是否保留原内容, origins 新原文来源
// 返回: error 错误信息
func (e *Editor) updateObjectOrigins(page int, objects []GraphicObject, preserveContent bool, origins map[string]*editorObjectOrigin) error {
ids := make([]string, len(objects))
for i, object := range objects {
ids[i] = editorObjectID(object)
@@ -416,6 +423,13 @@ func (e *Editor) updateObjects(page int, objects []GraphicObject, preserveConten
}
}
}
if next := origins[ids[i]]; next != nil {
if beforeOrigins == nil {
beforeOrigins = make(map[string]*editorObjectOrigin)
afterOrigins = make(map[string]*editorObjectOrigin)
}
beforeOrigins[ids[i]], afterOrigins[ids[i]] = origin, next
}
}
if reflect.DeepEqual(before, after) {
return nil
@@ -612,7 +626,7 @@ func (e *Editor) objectBounds(page int, objects []GraphicObject) ([]Box, error)
if _, err := reader.PageContentByIndex(page); err != nil {
return nil, err
}
renderer := NewRenderer(reader)
renderer := NewRenderer(reader, WithFontFS(e.fontFS...))
boxes := make([]Box, len(objects))
for i, object := range objects {
layer, _, findErr := e.findObject(page, editorObjectID(object))
+645
View File
@@ -0,0 +1,645 @@
// 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"
"maps"
"math"
"slices"
"github.com/tdewolff/canvas"
)
// ObjectPath 定位页面顶层对象及复合对象内的成员,Children为从0开始的逐层绘制序号
// 路径不依赖资源标识,几何变换、资源隔离及撤销重做不改变路径;成员增删或排序后需重新枚举
type ObjectPath struct {
ID string `json:"id"`
Children []int `json:"children,omitempty"`
}
// CompositeMember 复合对象的直接成员快照,Bounds与Contours使用页面毫米坐标
type CompositeMember struct {
Object GraphicObject
Bounds Box
Contours []ObjectContour
Transform bool
}
// editorCompositeNode 保留原始XML及实例上下文,不修改共享资源
type editorCompositeNode struct {
data []byte
node *editorXML
object GraphicObject
parent Matrix
boundaryInCTM bool
defaults *DrawParam
clip *canvas.Path
alpha *int
visible bool
ref *editorCompositeNode
children []*editorCompositeNode
expanded bool
changed bool
span *editorXML
}
// CompositeObjects 枚举复合对象的直接可绘制成员,资源引用层不占用路径层级
// 入参: page 页面索引, path 复合对象路径
// 返回: []CompositeMember 按绘制顺序排列的成员, error 错误信息
func (e *Editor) CompositeObjects(page int, path ObjectPath) ([]CompositeMember, error) {
reader, renderer, _, members, err := e.compositeScope(page, path)
if err != nil {
return nil, err
}
defer reader.Close()
return measureCompositeMembers(renderer, members), nil
}
// TransformCompositeObjects 在页面坐标中等比缩放并平移内部选区,只隔离被修改实例的资源
// 入参: page 页面索引, path 父复合对象路径, indexes 成员序号, dx、dy 位移, scale 正缩放比例
// 返回: error 错误信息
func (e *Editor) TransformCompositeObjects(page int, path ObjectPath, indexes []int, dx, dy, scale float64) error {
if !finite(dx) || !finite(dy) || !finite(scale) || scale <= 0 {
return fmt.Errorf("invalid composite transform")
}
return e.changeCompositeObjects(page, path, indexes, func(Box) Matrix { return Matrix{a: scale, d: scale, e: dx, f: dy} })
}
// RotateCompositeObjects 绕内部选区的可见中心旋转,提交一次撤销记录
// 入参: page 页面索引, path 父复合对象路径, indexes 成员序号, degrees 顺时针角度,为90度的整数倍
// 返回: error 错误信息
func (e *Editor) RotateCompositeObjects(page int, path ObjectPath, indexes []int, degrees int) error {
if degrees%90 != 0 {
return fmt.Errorf("rotation must be a multiple of 90 degrees")
}
m := []Matrix{IdentityMatrix, {b: 1, c: -1}, {a: -1, d: -1}, {b: -1, c: 1}}[(degrees%360+360)%360/90]
return e.changeCompositeObjects(page, path, indexes, func(box Box) Matrix { return compositeOrientation(box, m) })
}
// FlipCompositeObjects 绕内部选区的可见中心镜像
// 入参: page 页面索引, path 父复合对象路径, indexes 成员序号, axis 为horizontal或vertical
// 返回: error 错误信息
func (e *Editor) FlipCompositeObjects(page int, path ObjectPath, indexes []int, axis string) error {
m := IdentityMatrix
switch axis {
case "horizontal":
m.a = -1
case "vertical":
m.d = -1
default:
return fmt.Errorf("invalid flip axis %q", axis)
}
return e.changeCompositeObjects(page, path, indexes, func(box Box) Matrix { return compositeOrientation(box, m) })
}
// ResizeCompositeObjects 等比缩放内部选区到目标范围,不重排文字或重采样图片
// 入参: page 页面索引, path 父复合对象路径, indexes 成员序号, box 页面毫米目标范围
// 返回: error 错误信息
func (e *Editor) ResizeCompositeObjects(page int, path ObjectPath, indexes []int, box Box) error {
if _, err := creationBox(editorBoxString(box)); err != nil {
return err
}
members, err := e.CompositeObjects(page, path)
if err != nil {
return err
}
var before Box
for _, i := range indexes {
if i < 0 || i >= len(members) {
return fmt.Errorf("invalid composite selection")
}
before = unionTextBox(before, members[i].Bounds)
}
if before.W <= 0 || before.H <= 0 {
return fmt.Errorf("selection has no visible bounds")
}
sx, sy := box.W/before.W, box.H/before.H
if math.Abs(sx-sy) > 1e-9*math.Max(sx, sy) {
return fmt.Errorf("composite members require proportional resizing")
}
return e.TransformCompositeObjects(page, path, indexes, box.X-before.X*sx, box.Y-before.Y*sx, sx)
}
// compositeOrientation 将旋转或镜像中心移至选区中心
// 入参: box 选区, matrix 方向矩阵
// 返回: Matrix 页面变换
func compositeOrientation(box Box, matrix Matrix) Matrix {
x, y := box.X+box.W/2, box.Y+box.H/2
return TranslationMatrix(x, y).Multiply(matrix).Multiply(TranslationMatrix(-x, -y))
}
// compositeScope 加载所需的复合路径及直接成员,不展开无关子树
// 入参: page 页面索引, path 复合对象路径
// 返回: *Reader 预览读取器, *Renderer 度量器, *editorCompositeNode 顶层节点, []*editorCompositeNode 成员, error 错误信息
func (e *Editor) compositeScope(page int, path ObjectPath) (*Reader, *Renderer, *editorCompositeNode, []*editorCompositeNode, error) {
layer, index, err := e.findObject(page, path.ID)
if err != nil {
return nil, nil, nil, nil, err
}
object := layer.Objects[index]
origin := e.objectOrigin(path.ID)
if origin == nil {
return nil, nil, nil, nil, fmt.Errorf("composite requires original content")
}
data, err := editorXMLObject(origin.data, origin.node, origin.object, object)
if err == nil {
data, err = editorXMLStandalone(data, origin.node)
}
if err != nil {
return nil, nil, nil, nil, err
}
root, err := newEditorCompositeNode(data)
if err != nil {
return nil, nil, nil, nil, err
}
reader, err := e.Reader()
if err != nil {
return nil, nil, nil, nil, err
}
if _, err = reader.PageContentByIndex(page); err != nil {
reader.Close()
return nil, nil, nil, nil, err
}
renderer := NewRenderer(reader, WithFontFS(e.fontFS...))
root.parent, root.visible, root.defaults = IdentityMatrix, true, renderer.drawParamDefaults(layer.DrawParam, nil)
scope := root
for depth := 0; ; depth++ {
var members []*editorCompositeNode
members, err = scope.members(reader, renderer, make(map[string]bool))
if err != nil {
break
}
if depth == len(path.Children) {
return reader, renderer, root, members, nil
}
i := path.Children[depth]
if i < 0 || i >= len(members) {
err = fmt.Errorf("composite member index %d out of range", i)
break
}
scope = members[i]
}
reader.Close()
return nil, nil, nil, nil, err
}
// newEditorCompositeNode 解析独立图形片段,保留全部原始属性
// 入参: data XML片段
// 返回: *editorCompositeNode 节点, error 错误信息
func newEditorCompositeNode(data []byte) (*editorCompositeNode, error) {
node, err := parseEditorXML(data)
if err != nil {
return nil, err
}
var object GraphicObject
object.Type = node.name.Local
switch object.Type {
case "TextObject":
err = xml.Unmarshal(data, &object.TextObject)
case "PathObject":
err = xml.Unmarshal(data, &object.PathObject)
case "ImageObject":
err = xml.Unmarshal(data, &object.ImageObject)
case "CompositeObject", "CompositeGraphicUnit":
err = xml.Unmarshal(data, &object.CompositeGraphicUnit)
default:
err = fmt.Errorf("unsupported composite member %q", object.Type)
}
return &editorCompositeNode{data: data, node: node, object: object}, err
}
// members 展开当前复合实例的资源引用与直接绘制成员
// 入参: reader 资源读取器, renderer 渲染上下文, visiting 当前资源链
// 返回: []*editorCompositeNode 成员, error 错误信息
func (n *editorCompositeNode) members(reader *Reader, renderer *Renderer, visiting map[string]bool) ([]*editorCompositeNode, error) {
if n.object.Type != "CompositeObject" && n.object.Type != "CompositeGraphicUnit" {
return nil, fmt.Errorf("object is not composite")
}
if !n.expanded {
c := n.object.CompositeGraphicUnit
box, _ := ParseBox(c.Boundary)
boundary := n.parent.Multiply(TranslationMatrix(box.X, box.Y))
matrix := boundary.Multiply(NewMatrix(c.CTM))
clips, clipMatrix := c.Clips, matrix
if clips != nil && clips.TransFlag != nil && !*clips.TransFlag {
copy := *clips
copy.TransFlag = nil
clips, clipMatrix = &copy, boundary
}
clip := intersectClipPath(n.clip, renderer.buildClipPath(clips, 0, 0, 0, clipMatrix))
defaults := renderer.drawParamDefaults(c.DrawParam, n.defaults)
alpha := mergeAlpha(c.Alpha, n.alpha)
visible := n.visible && (c.Visible == nil || *c.Visible)
if c.ResourceID != "" {
data, err := reader.readFile(reader.resourceFiles[c.ResourceID])
if err != nil {
return nil, err
}
root, err := parseEditorXML(data)
if err != nil {
return nil, err
}
var resource *editorXML
if units := root.child("CompositeGraphicUnits"); units != nil {
for _, child := range units.children {
if child.name.Local == "CompositeGraphicUnit" && child.attr("ID") == c.ResourceID {
resource = child
break
}
}
}
if resource == nil {
return nil, fmt.Errorf("composite resource %q not found", c.ResourceID)
}
fragment, err := editorXMLStandalone(data[resource.start:resource.end], resource)
if err != nil {
return nil, err
}
n.ref, err = newEditorCompositeNode(fragment)
if err != nil {
return nil, err
}
n.ref.parent, n.ref.boundaryInCTM, n.ref.defaults, n.ref.clip, n.ref.alpha, n.ref.visible = matrix, true, defaults, clip, alpha, visible
}
var collect func(*editorXML) error
collect = func(container *editorXML) error {
for _, child := range container.children {
switch child.name.Local {
case "Content", "PageBlock":
if err := collect(child); err != nil {
return err
}
case "TextObject", "PathObject", "ImageObject", "CompositeObject", "CompositeGraphicUnit":
data, err := editorXMLStandalone(n.data[child.start:child.end], child)
if err != nil {
return err
}
item, err := newEditorCompositeNode(data)
if err != nil {
return err
}
item.parent, item.boundaryInCTM, item.defaults, item.clip, item.alpha, item.visible, item.span = matrix, n.boundaryInCTM, defaults, clip, alpha, visible, child
n.children = append(n.children, item)
}
}
return nil
}
if err := collect(n.node); err != nil {
return nil, err
}
n.expanded = true
}
var result []*editorCompositeNode
if n.ref != nil {
id := n.object.CompositeGraphicUnit.ResourceID
if visiting[id] {
return nil, fmt.Errorf("cyclic composite resource %q", id)
}
visiting[id] = true
members, err := n.ref.members(reader, renderer, visiting)
delete(visiting, id)
if err != nil {
return nil, err
}
result = append(result, members...)
}
return append(result, n.children...), nil
}
// measureCompositeMembers 按实际父变换、裁剪和继承样式度量成员
// 入参: renderer 渲染器, nodes 成员
// 返回: []CompositeMember 独立快照及操作能力
func measureCompositeMembers(renderer *Renderer, nodes []*editorCompositeNode) []CompositeMember {
result := make([]CompositeMember, len(nodes))
for i, node := range nodes {
bounds := &boundsRenderer{collect: true}
object := mergeGraphicObjectAlpha(node.object, node.alpha)
if node.visible {
renderer.renderObject(canvas.NewContext(bounds), &object, 0, node.defaults, &node.parent, node.boundaryInCTM, node.clip)
}
_, invertible := node.parent.Invert()
borderActions := node.object.Type == "ImageObject" && node.object.ImageObject.Border != nil && len(node.object.ImageObject.Actions) != 0
result[i] = CompositeMember{Object: cloneEditorData(node.object), Bounds: bounds.box, Contours: bounds.contours, Transform: invertible && editorXMLTransformable(node.node) && validateEditorGeometry(node.object) == nil && !borderActions}
}
return result
}
// changeCompositeObjects 原子变换内部选区,沿改动路径复制共享资源
// 入参: page 页面索引, path 父路径, indexes 成员序号, transform 页面变换生成器
// 返回: error 错误信息
func (e *Editor) changeCompositeObjects(page int, path ObjectPath, indexes []int, transform func(Box) Matrix) (err error) {
reader, renderer, root, nodes, err := e.compositeScope(page, path)
if err != nil {
return err
}
defer reader.Close()
members := measureCompositeMembers(renderer, nodes)
selected := make(map[int]bool)
var box Box
for _, i := range indexes {
if i < 0 || i >= len(nodes) || selected[i] {
return fmt.Errorf("invalid composite selection")
}
if !members[i].Transform {
return fmt.Errorf("composite member %d cannot be transformed", i)
}
selected[i] = true
box = unionTextBox(box, members[i].Bounds)
}
if len(selected) == 0 {
return nil
}
matrix := transform(box)
if matrix == IdentityMatrix {
return nil
}
count, maximum := len(e.resources), e.maxID
ready := e.source.idsReady
defer func() {
if err != nil {
clear(e.resources[count:])
e.resources, e.maxID = e.resources[:count], maximum
e.source.idsReady = ready
}
}()
if err = e.prepareSourceIDs(); err != nil {
return err
}
for i := range selected {
n := nodes[i]
object := cloneEditorData(n.object)
inverse, _ := n.parent.Invert()
basic := object.Type != "CompositeObject" && object.Type != "CompositeGraphicUnit"
if basic && !n.boundaryInCTM {
object = compositeBoundary(object, n.parent, true)
}
local := inverse.Multiply(matrix).Multiply(n.parent)
if object.Type == "ImageObject" && object.ImageObject.Border != nil {
origin := &editorObjectOrigin{data: n.data, node: n.node, object: n.object}
object, err = e.transformBorderedImage(object, local, origin, renderer)
if err != nil {
return err
}
} else {
object = transformEditorMatrix(object, local)
}
if basic && object.Type == n.object.Type && !n.boundaryInCTM {
object = compositeBoundary(object, n.parent, false)
}
if err = validateEditorGeometry(object); err != nil {
return err
}
n.data, err = editorXMLObject(n.data, n.node, n.object, object)
if err != nil {
return err
}
if !basic && !n.boundaryInCTM {
n.data, err = transformCompositeOffsets(n.data, matrix)
if err != nil {
return err
}
}
updated, err := newEditorCompositeNode(n.data)
if err != nil {
return err
}
n.node, n.object, n.changed = updated.node, updated.object, true
}
data, _, err := e.writeCompositeNode(root)
if err != nil {
return err
}
next, err := newEditorCompositeNode(data)
if err != nil {
return err
}
origin := e.objectOrigin(path.ID)
next.node.parent = origin.node.parent
updated := &editorObjectOrigin{page: origin.page, data: data, node: next.node, object: next.object}
return e.updateObjectOrigins(page, []GraphicObject{next.object}, true, map[string]*editorObjectOrigin{path.ID: updated})
}
// transformCompositeOffsets 变换旧式内联复合成员的页面偏移,资源引用保持原坐标约定
// 入参: data 修改后的复合片段, matrix 页面变换
// 返回: []byte 同步内部边界及非随动裁剪后的片段, error 错误信息
func transformCompositeOffsets(data []byte, matrix Matrix) ([]byte, error) {
root, err := parseEditorXML(data)
if err != nil {
return nil, err
}
var patches []editorXMLPatch
var visit func(*editorXML) error
visit = func(node *editorXML) error {
for _, child := range node.children {
switch child.name.Local {
case "Content", "PageBlock", "CompositeObject", "CompositeGraphicUnit":
if err := visit(child); err != nil {
return err
}
case "TextObject", "PathObject", "ImageObject":
fragment, err := editorXMLStandalone(data[child.start:child.end], child)
if err != nil {
return err
}
n, err := newEditorCompositeNode(fragment)
if err != nil {
return err
}
object := cloneEditorData(n.object)
object = compositeBoundary(object, Matrix{a: matrix.a, b: matrix.b, c: matrix.c, d: matrix.d}, false)
if clips := editorObjectClips(&object); *clips != nil && (*clips).TransFlag != nil && !*(*clips).TransFlag {
*clips = transformObjectClips(*clips, TranslationMatrix(matrix.e, matrix.f))
}
fragment, err = editorXMLObject(n.data, n.node, n.object, object)
if err != nil {
return err
}
patches = append(patches, editorXMLPatch{child.start, child.end, fragment})
}
}
return nil
}
if err = visit(root); err != nil {
return nil, err
}
return editorPatchXML(data, patches), nil
}
// compositeBoundary 在边界参与和不参与父变换的坐标约定之间转换
// 入参: object 基本对象, parent 父矩阵, inward 是否转换到父坐标
// 返回: GraphicObject 转换后的对象
func compositeBoundary(object GraphicObject, parent Matrix, inward bool) GraphicObject {
matrix := parent
matrix.e, matrix.f = 0, 0
clipMatrix := parent
if inward {
matrix, _ = matrix.Invert()
clipMatrix, _ = parent.Invert()
}
boundary, _ := editorGeometry(object)
box, _ := ParseBox(boundary)
box.X, box.Y = matrix.a*box.X+matrix.c*box.Y, matrix.b*box.X+matrix.d*box.Y
switch object.Type {
case "TextObject":
object.TextObject.Boundary = editorBoxString(box)
case "PathObject":
object.PathObject.Boundary = editorBoxString(box)
case "ImageObject":
object.ImageObject.Boundary = editorBoxString(box)
}
if clips := editorObjectClips(&object); *clips != nil && (*clips).TransFlag != nil && !*(*clips).TransFlag {
*clips = transformObjectClips(*clips, clipMatrix)
}
return object
}
// writeCompositeNode 保留原文逐级合并内部修改,仅复制发生变化的资源分支
// 入参: node 实例节点
// 返回: []byte 新片段, bool 是否变化, error 错误信息
func (e *Editor) writeCompositeNode(node *editorCompositeNode) ([]byte, bool, error) {
var patches []editorXMLPatch
for _, child := range node.children {
data, changed, err := e.writeCompositeNode(child)
if err != nil {
return nil, false, err
}
if changed {
patches = append(patches, editorXMLPatch{child.span.start, child.span.end, data})
}
}
data := editorPatchXML(node.data, patches)
changed := node.changed || len(patches) != 0
if node.ref != nil {
fragment, dirty, err := e.writeCompositeNode(node.ref)
if err != nil {
return nil, false, err
}
if dirty {
id, err := e.addCompositeResource(fragment)
if err != nil {
return nil, false, err
}
root, err := parseEditorXML(data)
if err != nil {
return nil, false, err
}
after := node.object
after.CompositeGraphicUnit.ResourceID = id
data, err = editorXMLObject(data, root, node.object, after)
if err != nil {
return nil, false, err
}
changed = true
}
}
return data, changed, nil
}
// addCompositeResource 注册独立复合资源并重映射内部标识,保留未知XML
// 入参: data 复合资源片段
// 返回: string 新资源标识, error 错误信息
func (e *Editor) addCompositeResource(data []byte) (string, error) {
root, err := parseEditorXML(data)
if err != nil {
return "", err
}
if !editorXMLCopyable(root) {
return "", fmt.Errorf("composite contains unsupported identifiers")
}
ids := make(map[string]string)
var collect func(*editorXML) error
collect = func(node *editorXML) error {
if id := editorResourceID(node.attr("ID")); id != "" {
if ids[id] != "" {
return fmt.Errorf("duplicate object ID %q", id)
}
ids[id] = e.nextID()
}
for _, child := range node.children {
if err := collect(child); err != nil {
return err
}
}
return nil
}
if err := collect(root); err != nil {
return "", err
}
id := ids[editorResourceID(root.attr("ID"))]
data, err = editorXMLRemapIDs(data, root, ids)
if err != nil {
return "", err
}
data, err = editorXMLContainer("CompositeGraphicUnits", nil, bytes.TrimPrefix(data, []byte(xml.Header)))
if err != nil {
return "", err
}
data, err = editorXMLContainer("Res", ofdAttrs{{Name: xml.Name{Local: "BaseLoc"}, Value: "."}}, bytes.TrimPrefix(data, []byte(xml.Header)))
if err != nil {
return "", err
}
resource, err := e.compositeResource(id, data)
if err != nil {
return "", err
}
e.resources = append(e.resources, resource)
return id, nil
}
// compositeResource 记录生成复合资源的完整引用,用于保存时按依赖保留资源
// 入参: id 资源标识, data 资源XML
// 返回: editorResource 资源, error 错误信息
func (e *Editor) compositeResource(id string, data []byte) (editorResource, error) {
root, err := parseEditorXML(data)
if err != nil {
return editorResource{}, err
}
refs := make(map[string]bool)
var collect func(*editorXML)
collect = func(node *editorXML) {
if node.name.Space == "" || node.name.Space == ofdNamespace || node.name.Space == "http://www.ofdspec.org" {
for _, attr := range node.attrs {
if attr.Name.Space == "" && editorObjectReference(attr.Name.Local) {
refs[attr.Value] = true
}
}
}
for _, child := range node.children {
collect(child)
}
}
collect(root)
return editorResource{name: e.resourceDirectory() + "/Composite_" + id + ".xml", data: data, composite: id, references: slices.Sorted(maps.Keys(refs))}, nil
}
// collectCompositeReferences 收集内联复合对象引用的新资源
// 入参: composite 复合对象, used 引用集合
func collectCompositeReferences(composite CompositeGraphicUnit, used map[string]bool) {
used[composite.ResourceID] = true
for _, object := range composite.Objects {
switch object.Type {
case "TextObject":
used[object.TextObject.Font] = true
case "ImageObject":
used[object.ImageObject.ResourceID] = true
used[object.ImageObject.ImageMask] = true
case "CompositeObject", "CompositeGraphicUnit":
collectCompositeReferences(object.CompositeGraphicUnit, used)
}
}
}
+2 -1
View File
@@ -283,7 +283,8 @@ func (e *Editor) sourcePageXML(index int, source *editorSourcePage) ([]byte, err
encoded = data[origin.start:origin.end]
} else {
var err error
encoded, err = editorXMLObject(data, origin, before[nextID], next)
current := e.objectOrigin(nextID)
encoded, err = editorXMLObject(current.data, current.node, current.object, next)
if err != nil {
return nil, err
}
+13 -2
View File
@@ -179,6 +179,13 @@ func (e *Editor) transformMatrix(object GraphicObject, matrix Matrix) (GraphicOb
if origin == nil {
return GraphicObject{}, fmt.Errorf("bordered image requires an original object")
}
return e.transformBorderedImage(object, matrix, origin, NewRenderer(e.source.reader, WithFontFS(e.fontFS...)))
}
// transformBorderedImage 将原文图片封装为复合资源,保留边框和裁剪
// 入参: object 图片对象, matrix 变换, origin 原文, renderer 资源度量器
// 返回: GraphicObject 变换结果, error 错误信息
func (e *Editor) transformBorderedImage(object GraphicObject, matrix Matrix, origin *editorObjectOrigin, renderer *Renderer) (GraphicObject, error) {
if err := e.prepareSourceIDs(); err != nil {
return GraphicObject{}, err
}
@@ -186,7 +193,7 @@ func (e *Editor) transformMatrix(object GraphicObject, matrix Matrix) (GraphicOb
if err != nil {
return GraphicObject{}, err
}
visible, err := NewRenderer(e.source.reader).ObjectBounds(object, "")
visible, err := renderer.ObjectBounds(object, "")
if err != nil {
return GraphicObject{}, err
}
@@ -220,7 +227,11 @@ func (e *Editor) transformMatrix(object GraphicObject, matrix Matrix) (GraphicOb
if err != nil {
return GraphicObject{}, err
}
e.resources = append(e.resources, editorResource{name: e.resourceDirectory() + "/Composite_" + id + ".xml", data: data, composite: id})
resource, err := e.compositeResource(id, data)
if err != nil {
return GraphicObject{}, err
}
e.resources = append(e.resources, resource)
object = GraphicObject{Type: "CompositeObject", CompositeGraphicUnit: CompositeGraphicUnit{ID: object.ImageObject.ID, ResourceID: id, Boundary: editorBoxString(extent)}}
return transformEditorMatrix(object, matrix), nil
}
+2
View File
@@ -241,10 +241,12 @@ func (r *Reader) loadRes(resPath string) {
for i := range res.DrawParams.DrawParam {
dp := &res.DrawParams.DrawParam[i]
r.drawParamCache[dp.ID] = dp
r.resourceFiles[dp.ID] = fullPath
}
for i := range res.CompositeGraphicUnits.CompositeGraphicUnit {
cgu := &res.CompositeGraphicUnits.CompositeGraphicUnit[i]
r.compositeGraphicUnitCache[cgu.ID] = cgu
r.resourceFiles[cgu.ID] = fullPath
}
r.resourcesRead[fullPath] = true
}
+12
View File
@@ -115,6 +115,18 @@ func (r *Renderer) renderLayer(ctx *canvas.Context, layer Layer, pageH float64,
// 入参: ctx 画布上下文, cgu 复合图元对象, pageH 页面高度, defaults 默认绘制参数, parentCTM 父级CTM, boundaryInCTM 边界是否参与CTM变换, parentClip 父级裁剪路径
func (r *Renderer) renderCompositeGraphicUnit(ctx *canvas.Context, cgu CompositeGraphicUnit, pageH float64, defaults *DrawParam, parentCTM *Matrix, boundaryInCTM bool, parentClip *canvas.Path) {
if cgu.Visible != nil && !*cgu.Visible {
if groups, ok := ctx.Renderer.(interface{ skipObjects(int) }); ok {
count, ref, seen := len(cgu.Objects), cgu.ResourceID, make(map[string]bool)
for ref != "" && !seen[ref] {
seen[ref] = true
unit := r.CompositeGraphicUnits[ref]
if unit == nil {
break
}
count, ref = count+len(unit.Objects), unit.ResourceID
}
groups.skipObjects(count)
}
return
}
ctx.Push()
+27 -2
View File
@@ -63,6 +63,14 @@ type svgResourceRenderer struct {
styles strings.Builder
err error
objects map[*GraphicObject]string
objectStack []svgObjectGroup
}
// svgObjectGroup 记录编辑分组的稳定路径,不把底纹内部绘制当作成员
type svgObjectGroup struct {
path string
next int
composite bool
}
// RenderToSVGWithFonts 渲染为SVG并返回页面引用的字体资源,不在SVG中嵌入字体
@@ -135,23 +143,40 @@ func (r *Renderer) renderSVGResources(page *PageContent, writer io.Writer, image
return SVGResources{Fonts: s.fonts, Images: s.images}, buffer.Flush()
}
// beginObject 开始标记当前页面直接对象,模板、注释和内部复合对象不单独标记
// beginObject 标记直接对象及复合成员,模板与注释不单独标记
// 入参: object 图形对象
// 返回: bool 是否写入分组
func (s *svgResourceRenderer) beginObject(object *GraphicObject) bool {
id := s.objects[object]
attribute := "data-ofd-object"
if id == "" {
if len(s.objectStack) == 0 || !s.objectStack[len(s.objectStack)-1].composite {
return false
}
fmt.Fprintf(s.writer, `<g data-ofd-object="%s">`, html.EscapeString(id))
parent := &s.objectStack[len(s.objectStack)-1]
id = fmt.Sprintf("%s/%d", parent.path, parent.next)
parent.next++
attribute = "data-ofd-child"
}
s.objectStack = append(s.objectStack, svgObjectGroup{path: id, composite: object.Type == "CompositeObject" || object.Type == "CompositeGraphicUnit"})
fmt.Fprintf(s.writer, `<g %s="%s">`, attribute, html.EscapeString(id))
return true
}
// endObject 结束对象分组
func (s *svgResourceRenderer) endObject() {
s.objectStack = s.objectStack[:len(s.objectStack)-1]
fmt.Fprint(s.writer, `</g>`)
}
// skipObjects 为不可见资源保留内部成员序号
// 入参: count 直接成员数量
func (s *svgResourceRenderer) skipObjects(count int) {
if len(s.objectStack) > 0 {
s.objectStack[len(s.objectStack)-1].next += count
}
}
// RenderImage 绘制图片,分离资源时保持原始编码、尺寸和变换
// 入参: img 图片对象, m 变换矩阵
func (s *svgResourceRenderer) RenderImage(img image.Image, m canvas.Matrix) {
+10 -2
View File
@@ -320,7 +320,7 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color
for _, layer := range page.Content.Layer {
for _, object := range layer.Objects {
before, exists := original[editorObjectID(object)]
if origin := e.objectOrigin(editorObjectID(object)); origin != nil && (!exists || before.Type != object.Type) {
if origin := e.objectOrigin(editorObjectID(object)); origin != nil && (!exists || before.Type != object.Type || !reflect.DeepEqual(before.CompositeGraphicUnit, object.CompositeGraphicUnit)) {
for _, name := range origin.page.original.PageRes {
files[e.source.reader.ResPath(resolveResourcePath(origin.page.ref.BaseLoc, "", name))] = true
}
@@ -335,7 +335,7 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color
}
switch object.Type {
case "CompositeObject", "CompositeGraphicUnit":
used[object.CompositeGraphicUnit.ResourceID] = true
collectCompositeReferences(object.CompositeGraphicUnit, used)
case "TextObject":
used[object.TextObject.Font] = true
if e.source != nil && (!exists || before.Type != object.Type || before.TextObject.Font != object.TextObject.Font) {
@@ -356,6 +356,14 @@ func (e *Editor) usedResources() (fonts, images []editorResource, spaces []Color
}
}
}
for i := len(e.resources) - 1; i >= 0; i-- {
resource := e.resources[i]
if resource.composite != "" && used[resource.composite] {
for _, id := range resource.references {
used[id] = true
}
}
}
for _, resource := range e.resources {
if resource.composite != "" && used[resource.composite] {
files[resource.name] = true