From 4cc34cf7be05be786ed4b3cf54d42b3d3b34aefd Mon Sep 17 00:00:00 2001 From: xiaoqidun Date: Wed, 16 Sep 2026 22:01:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=BC=96=E8=BE=91=E8=83=BD=E5=8A=9B):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=B7=A8=E6=96=87=E6=A1=A3=E6=8F=92=E9=A1=B5?= =?UTF-8?q?=E3=80=81=E6=89=B9=E9=87=8F=E6=96=87=E5=AD=97=E6=A0=B7=E5=BC=8F?= =?UTF-8?q?=E4=B8=8E=E6=AE=B5=E8=90=BD=E7=BC=A9=E8=BF=9B=EF=BC=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/webui/index.html | 23 +- assets/webui/ofdgo.css | 23 +- assets/webui/ofdgo.js | 183 +++++++-- assets/webui/ofdgo_edit.js | 24 +- assets/webui/ofdgo_font.js | 2 +- internal/webui/wasm.go | 172 +++++++- ofdgo_editor.go | 33 +- ofdgo_editor_error.go | 70 ++++ ofdgo_editor_import.go | 818 +++++++++++++++++++++++++++++++++++++ ofdgo_editor_open.go | 28 +- ofdgo_editor_style.go | 6 +- ofdgo_editor_text.go | 77 +++- ofdgo_font_file.go | 30 ++ 13 files changed, 1418 insertions(+), 71 deletions(-) create mode 100644 ofdgo_editor_error.go create mode 100644 ofdgo_editor_import.go diff --git a/assets/webui/index.html b/assets/webui/index.html index 4f9927c..fa54778 100644 --- a/assets/webui/index.html +++ b/assets/webui/index.html @@ -123,7 +123,7 @@ - 段落 +
@@ -155,7 +155,7 @@ 变换
- + @@ -187,6 +187,25 @@
+ +
+
+
+ +
+
+
+
+ +
+
mm
+
+
mm
+
mm
+
+
+
+
diff --git a/assets/webui/ofdgo.css b/assets/webui/ofdgo.css index df21b11..c00fe60 100644 --- a/assets/webui/ofdgo.css +++ b/assets/webui/ofdgo.css @@ -334,7 +334,8 @@ a:focus-visible { border-right: 0; } -.editor-group > span { +.editor-group > span, +.editor-group-action { grid-column: 1 / -1; grid-row: 3; align-self: end; @@ -343,6 +344,26 @@ a:focus-visible { color: var(--text-muted); } +.editor-group-action { + justify-self: center; + padding: 0 8px; + height: 16px; + min-height: 0; + border: 0; + background: transparent; + line-height: 16px; + text-decoration: underline dotted; + text-underline-offset: 3px; +} + +.mixed-color::-webkit-color-swatch { + background: repeating-linear-gradient(135deg, #fff 0 4px, #aeb7bc 4px 8px); +} + +.mixed-color::-moz-color-swatch { + background: repeating-linear-gradient(135deg, #fff 0 4px, #aeb7bc 4px 8px); +} + .app-bar { display: flex; align-items: center; diff --git a/assets/webui/ofdgo.js b/assets/webui/ofdgo.js index 3dfbfc4..a691146 100644 --- a/assets/webui/ofdgo.js +++ b/assets/webui/ofdgo.js @@ -1,4 +1,4 @@ -import { CanvasEditor, canEditObject, missingGlyphMessage, objectEditReason } from "./ofdgo_edit.js"; +import { CanvasEditor, canEditObject, missingGlyphMessage, objectEditReason, selectedText } from "./ofdgo_edit.js"; import { FontManager, FontPicker } from "./ofdgo_font.js"; const MM_TO_PX = 96 / 25.4; @@ -38,6 +38,7 @@ const state = { editorInfo: null, savedRevision: null, insertObject: null, + importPageCount: 0, objectClipboard: null, textFonts: [], textFontID: null, @@ -114,6 +115,24 @@ const el = { textFontToggle: document.querySelector("#textFontToggle"), textFontList: document.querySelector("#textFontList"), textFontAdd: document.querySelector("#textFontAdd"), + paragraphButton: document.querySelector("#paragraphButton"), + paragraphPanel: document.querySelector("#paragraphPanel"), + paragraphForm: document.querySelector("#paragraphForm"), + paragraphCancel: document.querySelector("#paragraphCancel"), + paragraphStatus: document.querySelector("#paragraphStatus"), + leftIndent: document.querySelector("#leftIndent"), + rightIndent: document.querySelector("#rightIndent"), + firstLineIndent: document.querySelector("#firstLineIndent"), + importPanel: document.querySelector("#importPanel"), + importForm: document.querySelector("#importForm"), + importFile: document.querySelector("#importFile"), + importRange: document.querySelector("#importRange"), + importPagesRow: document.querySelector("#importPagesRow"), + importPages: document.querySelector("#importPages"), + importPosition: document.querySelector("#importPosition"), + importStatus: document.querySelector("#importStatus"), + importCancel: document.querySelector("#importCancel"), + importSubmit: document.querySelector("#importSubmit"), textSize: document.querySelector("#textSize"), textColor: document.querySelector("#textColor"), drawLineButton: document.querySelector("#drawLineButton"), @@ -270,7 +289,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { onTransform: (item, change) => changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item, change.x + item.x * (1 - change.scale), change.y + item.y * (1 - change.scale), change.scale), onReshape: (item, box) => changeDocument("ofdgoReshapeObject", item, box.x, box.y, box.width, box.height), - onTextWidth: (item, box) => changeDocument("ofdgoLayoutText", item, box.x - item.x, box.width, true, item.align || "", item.paragraphHeight || 0, item.letterSpacing || 0), + onTextWidth: (item, box) => changeDocument("ofdgoLayoutText", item, box.x - item.x, box.width, true, item.align || "", item.paragraphHeight || 0, item.letterSpacing || 0, ...textIndents(item)), onCrop: (item, box) => changeDocument("ofdgoCropImage", item, box.x, box.y, box.width, box.height), onCropChange: () => { updatePendingChanges(); @@ -294,7 +313,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, { box.x, box.y, box.width, box.height, style.fill, style.fillColor, style.stroke, style.strokeColor, style.lineWidth), onDrawText: beginCanvasText, onCommitText: (item, value, fontData) => item.draft - ? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight, item.letterSpacing) + ? changeDocument("ofdgoInsertText", null, item.index, value, fontData || item.fontData, item.x, item.y, item.size, item.color, item.width, item.wrap, item.align, item.paragraphHeight, item.letterSpacing, ...textIndents(item)) : changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, null), onTextChange: () => { const editing = canvasEditor.input; @@ -357,6 +376,28 @@ el.textColor.addEventListener("change", () => changeTextStyle(true)); editorClick(el.textWrap, () => changeParagraph(!currentTextStyle().wrap)); el.textAlign.addEventListener("change", () => changeParagraph()); el.textLineHeight.addEventListener("change", () => changeParagraph()); +editorClick(el.paragraphButton, () => { + const item = currentTextStyle(); + for (const key of ["leftIndent", "rightIndent", "firstLineIndent"]) el[key].value = item[key] || 0; + el.paragraphStatus.textContent = ""; + el.paragraphPanel.showModal(); +}); +el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close()); +el.paragraphForm.addEventListener("submit", async event => { + event.preventDefault(); + if (document.body.hasAttribute("aria-busy") || !el.paragraphForm.reportValidity()) return; + const item = currentTextStyle(); + const indents = Object.fromEntries(["leftIndent", "rightIndent", "firstLineIndent"].map(key => [key, Number(el[key].value)])); + if (item === state.textDefaults) { + Object.assign(item, indents); + } else { + if (!confirmTextReflow(item)) return; + const saved = await changeDocument("ofdgoLayoutText", item, null, null, Boolean(item.wrap), item.align || "left", + item.paragraphHeight || 0, item.letterSpacing || 0, ...textIndents(indents)); + if (!saved) return; + } + el.paragraphPanel.close(); +}); el.textSpacing.addEventListener("change", () => changeParagraph()); el.shapeWidth.addEventListener("focus", () => { el.shapeWidth.defaultValue = el.shapeWidth.value; }); for (const input of [el.textSize, el.shapeWidth, el.textLineHeight, el.textSpacing]) { @@ -455,10 +496,36 @@ for (const [button, tool] of [[el.drawLineButton, "line"], [el.drawRectangleButt for (const input of [el.shapeFill, el.shapeFillColor, el.shapeStroke, el.shapeStrokeColor, el.shapeWidth]) { input.addEventListener("change", changeShapeStyle); } -editorClick(el.addPageButton, () => { +el.addPageButton.addEventListener("change", async () => { + const action = el.addPageButton.value; + el.addPageButton.value = ""; + if (canvasEditor.input && !await canvasEditor.commitText()) return; + if (canvasEditor.crop && !await canvasEditor.commitCrop()) return; + if (action === "import") { openImportPanel(); return; } const page = currentPageInfo(); return changeDocument("ofdgoChangePage", null, "add", state.pageIndex, page.width, page.height); }); +el.importCancel.addEventListener("click", () => el.importPanel.close()); +el.importPanel.addEventListener("close", () => { + state.importPageCount = 0; + el.importForm.reset(); + if (state.ready) callWASM("ofdgoLoadImport", null).catch(err => showError(err, false)); +}); +el.importFile.addEventListener("change", loadImportFile); +el.importRange.addEventListener("change", () => { + const custom = el.importRange.value === "custom"; + el.importPagesRow.hidden = !custom; + el.importPages.disabled = !custom; + el.importPages.required = custom; + if (custom) el.importPages.focus(); +}); +el.importForm.addEventListener("submit", async event => { + event.preventDefault(); + if (document.body.hasAttribute("aria-busy") || !state.importPageCount || !el.importForm.reportValidity()) return; + const position = { before:state.pageIndex, after:state.pageIndex+1, first:0, last:state.doc.pageCount }[el.importPosition.value]; + const saved = await changeDocument("ofdgoImportPages", null, el.importRange.value === "custom" ? el.importPages.value : "", position); + if (saved) el.importPanel.close(); +}); editorClick(el.copyPageButton, () => changeDocument("ofdgoChangePage", null, "copy", state.pageIndex)); editorClick(el.deletePageButton, () => changeDocument("ofdgoChangePage", null, "delete", state.pageIndex)); editorClick(el.movePagePrevButton, () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex - 1)); @@ -712,7 +779,45 @@ function handleKeyDown(event) { } function formDialogOpen() { - return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open; + return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open; +} + +function openImportPanel() { + if (!state.editing || document.body.hasAttribute("aria-busy")) return; + state.importPageCount = 0; + el.importForm.reset(); + el.importStatus.textContent = ""; + el.importSubmit.disabled = true; + el.importPagesRow.hidden = true; + el.importPages.disabled = true; + el.importPages.required = false; + el.importPanel.showModal(); +} + +async function loadImportFile() { + const file = el.importFile.files[0]; + const openSeq = state.openSeq; + state.importPageCount = 0; + el.importSubmit.disabled = true; + el.importStatus.textContent = ""; + setBusy(true); + try { + const data = file ? new Uint8Array(await file.arrayBuffer()) : null; + if (openSeq !== state.openSeq) return; + const info = await callWASM("ofdgoLoadImport", data); + if (openSeq !== state.openSeq || !el.importPanel.open) { + await callWASM("ofdgoLoadImport", null); + return; + } + state.importPageCount = info?.pageCount || 0; + el.importPages.title = state.importPageCount ? `页码 1-${state.importPageCount}` : "页码"; + el.importStatus.textContent = info?.signed ? "保留签章外观,原签名不再有效" : ""; + el.importSubmit.disabled = !state.importPageCount; + } catch (err) { + if (openSeq === state.openSeq && el.importPanel.open) el.importStatus.textContent = err.message; + } finally { + if (openSeq === state.openSeq) setBusy(false); + } } function openPagePanel() { @@ -943,7 +1048,11 @@ function openInsertPanel(item = null) { } function currentTextStyle() { - return canvasEditor.selected?.type === "TextObject" ? canvasEditor.selected : state.textDefaults; + return selectedText(canvasEditor.selected) || state.textDefaults; +} + +function textIndents(item) { + return [item.leftIndent || 0, item.rightIndent || 0, item.firstLineIndent || 0]; } async function toggleTextTool() { @@ -958,10 +1067,11 @@ async function toggleTextTool() { updateTextFonts(canvasEditor.selected, true); } if (openSeq !== state.openSeq) return; - const style = canEditObject(canvasEditor.selected, "layoutKnown") ? currentTextStyle() : state.textDefaults; + const style = !canvasEditor.selected?.items && canEditObject(canvasEditor.selected, "layoutKnown") ? currentTextStyle() : state.textDefaults; const font = state.textFonts[Number(fontPicker.value)]; state.textDefaults = { type: "TextObject", size: style.size, color: style.color, wrap: style.wrap, align: style.align || "left", paragraphHeight: style.paragraphHeight || 0, letterSpacing: style.letterSpacing || 0, + leftIndent: style.leftIndent || 0, rightIndent: style.rightIndent || 0, firstLineIndent: style.firstLineIndent || 0, fontChoice: font && !font.disabled ? font : state.textDefaults.fontChoice }; state.selectObjects = true; setPan(false); @@ -1045,22 +1155,25 @@ async function readTextFont(font) { } function updateTextFonts(item, refresh = false) { + item = selectedText(item); const id = item?.type === "TextObject" && !item.draft ? item.font : null; - if (refresh || state.textFontID !== id) { - state.textFontID = id; + const key = item?.items ? `selection:${item.items.map(member => member.id).join(",")}:${id || ""}` : id; + if (refresh || state.textFontID !== key) { + state.textFontID = key; state.textFonts = editorFonts(id ? item : null); const selected = canvasEditor.input?.fontChoice || (id ? state.textFonts[0] : state.textDefaults.fontChoice); if (selected && (canvasEditor.input || selected.embedded) && !state.textFonts.some(font => (font.id || font.postscriptName) === (selected.id || selected.postscriptName))) { state.textFonts.unshift(selected); } - fontPicker.setFonts(state.textFonts, selected || state.textFonts[0]); - if (!id) state.textDefaults.fontChoice = state.textFonts[Number(fontPicker.value)]; + fontPicker.setFonts(state.textFonts, item?.items && !id ? null : selected || state.textFonts[0]); + el.textFont.placeholder = item?.items && !id ? "混合" : ""; + if (!id && !item?.items) state.textDefaults.fontChoice = state.textFonts[Number(fontPicker.value)]; } } async function changeTextFont() { - const item = canvasEditor.selected; + const item = selectedText(canvasEditor.selected); const font = state.textFonts[Number(fontPicker.value)]; if (!state.ready || document.body.hasAttribute("aria-busy") || !font || font.disabled) return; const editing = canvasEditor.input; @@ -1069,6 +1182,7 @@ async function changeTextFont() { setBusy(true); try { const data = await readTextFont(font); + await callWASM("ofdgoCheckTextFont", data, editing.input.value); const face = new FontFace("ofdgo-edit-font", data); await face.load(); if (openSeq !== state.openSeq || canvasEditor.input !== editing) return; @@ -1084,8 +1198,8 @@ async function changeTextFont() { if (openSeq === state.openSeq) setBusy(false); } } else if (item?.type === "TextObject") { - if (!font.embedded && canEditObject(item, "replaceFont")) { - await changeDocument("ofdgoUpdateText", item, canEditObject(item, "layoutKnown") ? item.text : null, readTextFont(font), item.size, null); + if (canEditObject(item, "replaceFont") && (!font.embedded || item.font !== font.id.slice(9))) { + await changeDocument("ofdgoStyleText", { ...item, id: (item.items || [item]).map(member => member.id) }, readTextFont(font), 0, null); updateTextFonts(canvasEditor.selected, true); } } else { @@ -1174,7 +1288,7 @@ async function changeParagraph(wrap = currentTextStyle().wrap) { updateObjectControls(item, true); return; } - await changeDocument("ofdgoLayoutText", item, null, null, Boolean(wrap), el.textAlign.value, height, spacing); + await changeDocument("ofdgoLayoutText", item, null, null, Boolean(wrap), el.textAlign.value, height, spacing, ...textIndents(item)); updateObjectControls(canvasEditor.selected, true); } @@ -1190,6 +1304,15 @@ async function changeTextStyle(color) { } const size = color || Number(el.textSize.value) === Number(el.textSize.defaultValue) ? item.size : Number(el.textSize.value) * 25.4 / 72; const fill = color && el.textColor.value !== item.color ? el.textColor.value : null; + if (item.items) { + if (color ? !canEditObject(item, "update") : !canEditObject(item, "layoutKnown") || !canEditObject(item, "reflow")) return; + if (!color && !el.textSize.value) return; + if (size !== item.size || fill !== null) { + await changeDocument("ofdgoStyleText", { ...item, id: item.items.map(member => member.id) }, null, color ? 0 : size, fill); + } + updateObjectControls(canvasEditor.selected, true); + return; + } if (item === state.textDefaults) { item.size = size; if (fill !== null) item.color = fill; @@ -1314,6 +1437,10 @@ async function changeDocument(name, item, ...args) { if (openSeq === state.openSeq) { if (el.pagePanel.open) { el.pageStatus.textContent = err.message; + } else if (el.paragraphPanel.open) { + el.paragraphStatus.textContent = err.message; + } else if (el.importPanel.open) { + el.importStatus.textContent = err.message; } else { showError(err, false); } @@ -1825,6 +1952,8 @@ async function openOFD(file) { el.createPanel.close(); el.insertPanel.close(); el.pagePanel.close(); + el.paragraphPanel.close(); + el.importPanel.close(); updateControls(); await openDocument({ pageIndex: 0, resetScroll: true, openSeq }); } catch (err) { @@ -3049,10 +3178,10 @@ async function pasteEditorContent(event) { el.textFontAdd.focus(); return; } - const style = currentTextStyle(); + const style = canvasEditor.selected?.items ? state.textDefaults : currentTextStyle(); const data = readTextFont(font); await changeDocument("ofdgoInsertText", null, index, value, data, x, y, style.size, style.color, - page.width - x * 2, style.wrap, style.align || "left", style.paragraphHeight || 0, style.letterSpacing || 0); + page.width - x * 2, style.wrap, style.align || "left", style.paragraphHeight || 0, style.letterSpacing || 0, ...textIndents(style)); } function copySelection(event) { @@ -4512,13 +4641,15 @@ function updateObjectControls(item, reset = false) { el.editObjectButton.disabled = disabled || Boolean(item.items) || item.type === "PathObject" || !canEditObject(item, item.type === "TextObject" ? "reflow" : "update"); el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting; - const text = item?.type === "TextObject" ? item : state.textDefaults; - const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items); + const selection = selectedText(item); + const text = selection || state.textDefaults; + const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items && !selection); fontPicker.setDisabled(textDisabled || text !== state.textDefaults && !item.draft && !canEditObject(item, "replaceFont")); - el.textSize.disabled = textDisabled || Boolean(item?.draft) || text !== state.textDefaults && !canEditObject(item, "reflow"); + el.textSize.disabled = textDisabled || Boolean(item?.draft) || text !== state.textDefaults && (!canEditObject(item, "reflow") || Boolean(item?.items) && !canEditObject(item, "layoutKnown")); el.textColor.disabled = textDisabled || Boolean(item?.draft) || text !== state.textDefaults && !canEditObject(item, "update"); el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting; - el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled; + el.textAlign.disabled = el.textWrap.disabled = el.textLineHeight.disabled = el.textSpacing.disabled = el.textSize.disabled || Boolean(item?.items); + el.paragraphButton.disabled = el.textAlign.disabled; const layoutKnown = text === state.textDefaults || Boolean(item?.draft) || canEditObject(item, "layoutKnown"); el.textAlign.value = layoutKnown ? text.align || "left" : ""; el.textLineHeight.placeholder = layoutKnown ? "自动" : "原文"; @@ -4532,10 +4663,13 @@ function updateObjectControls(item, reset = false) { } updateTextFonts(item); if (reset || el.textSize.disabled || document.activeElement !== el.textSize) { - el.textSize.value = document.activeElement === el.textSize ? textPoints(text.size) : Math.round(textPoints(text.size)); + el.textSize.value = text.size === undefined ? "" : document.activeElement === el.textSize ? textPoints(text.size) : Math.round(textPoints(text.size)); el.textSize.defaultValue = el.textSize.value; } - el.textSize.title = `字号 ${textPoints(text.size)} pt`; + el.textSize.placeholder = text.size === undefined ? "混合" : ""; + el.textSize.title = text.size === undefined ? "字号:混合" : `字号 ${textPoints(text.size)} pt`; + el.textColor.classList.toggle("mixed-color", Boolean(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"; } @@ -4589,6 +4723,7 @@ async function callWASM(name, ...args) { args[0] = args[0].slice(); transfer.push(args[0].buffer); } + if (name === "ofdgoLoadImport" && args[0]) transfer.push(args[0].buffer); wasmWorker.postMessage({ id, name, args }, transfer); } catch (err) { wasmRequests.delete(id); @@ -4610,6 +4745,8 @@ function setBusy(busy, text = "", percent = 0, status = "") { el.createForm.inert = busy; el.insertForm.inert = busy; el.pageForm.inert = busy; + el.paragraphForm.inert = busy; + el.importForm.inert = busy; el.editorTools.inert = busy; el.fontList.inert = busy; if (!busy) { diff --git a/assets/webui/ofdgo_edit.js b/assets/webui/ofdgo_edit.js index f0c5548..ca37b70 100644 --- a/assets/webui/ofdgo_edit.js +++ b/assets/webui/ofdgo_edit.js @@ -131,22 +131,29 @@ export function missingGlyphMessage(diagnostic) { } export function objectEditReason(item) { + 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 = "对象特性暂不支持"; + let message = labels[object.capabilities.reasonCode] || "对象特性暂不支持"; if (object.capabilities.missingGlyphs) message = missingGlyphMessage(object.capabilities.missingGlyphs); - else if (reason.includes("embedded font")) message = "缺少内嵌字体"; - else if (reason.includes("color") || reason.includes("RGB")) message = "颜色样式暂不支持"; - else if (reason.includes("draw parameter")) message = "绘制参数暂不支持"; - else if (reason.includes("layer")) message = "图层特性暂不支持"; - else if (reason.includes("container")) message = "容器特性暂不支持"; - else if (reason.includes("invalid")) message = "对象数据异常"; return `${object.capabilities.transform ? "部分操作受限" : "暂不可编辑"}:${message}`; }); return [...new Set(reasons.filter(Boolean))].join(";"); } +export function selectedText(item) { + if (!item?.items) return item?.type === "TextObject" ? item : null; + if (!item.items.every(member => member.type === "TextObject")) return null; + const text = { ...item, type: "TextObject" }; + for (const key of ["font", "fontName", "size", "color"]) { + const value = item.items[0][key]; + text[key] = item.items.every(member => member[key] === value) ? value : undefined; + } + return text; +} + export class CanvasEditor { constructor(viewer, options) { this.viewer = viewer; @@ -803,6 +810,9 @@ export class CanvasEditor { lineHeight: item.paragraphHeight || item.lineHeight ? `${(item.paragraphHeight || item.lineHeight) * PX_PER_MM}px` : "normal", color: item.color, textAlign: item.align || "left", letterSpacing: `${(item.letterSpacing || 0) * PX_PER_MM}px`, + paddingLeft: `${2 + Math.max(0, item.leftIndent || 0) * PX_PER_MM}px`, + paddingRight: `${2 + Math.max(0, item.rightIndent || 0) * PX_PER_MM}px`, + textIndent: `${(item.firstLineIndent || 0) * PX_PER_MM}px`, }); if (item.textFrame) { const { width, height, matrix: [a, b, c, d, e, f] } = item.textFrame; diff --git a/assets/webui/ofdgo_font.js b/assets/webui/ofdgo_font.js index 246e4fc..6fe8e5a 100644 --- a/assets/webui/ofdgo_font.js +++ b/assets/webui/ofdgo_font.js @@ -32,7 +32,7 @@ export class FontPicker { this.names.set(name, (this.names.get(name) || 0) + 1); } const key = font => font?.id || font?.postscriptName; - this.value = fonts.length ? String(Math.max(0, fonts.findIndex(font => key(font) === key(selected)))) : ""; + this.value = !fonts.length ? "" : selected === null ? "-1" : String(Math.max(0, fonts.findIndex(font => key(font) === key(selected)))); this.restore(); } diff --git a/internal/webui/wasm.go b/internal/webui/wasm.go index f6fbdd0..ff8ce86 100644 --- a/internal/webui/wasm.go +++ b/internal/webui/wasm.go @@ -28,6 +28,7 @@ import ( "io" "math" "strconv" + "strings" "syscall/js" "github.com/xiaoqidun/ofdgo" @@ -76,12 +77,16 @@ func awaitExport(fn js.Value, args ...any) error { // currentSession 当前WebUI文档会话 var currentSession *Session +// pendingImport 待插页文档,仅保留文件数据与索引,不创建渲染会话 +var pendingImport *ofdgo.Reader + // apiResult 浏览器接口返回结果 type apiResult struct { OK bool `json:"ok"` Error string `json:"error,omitempty"` Data any `json:"data,omitempty"` MissingGlyphs *ofdgo.MissingGlyphError `json:"missingGlyphs,omitempty"` + ReasonCode ofdgo.EditReason `json:"reasonCode,omitempty"` } // RunWASM 注册浏览器WASM接口并阻塞运行 @@ -104,8 +109,12 @@ func RunWASM() { registerCallback("ofdgoCreateDocument", createDocument) registerCallback("ofdgoEditDocument", editDocument) registerCallback("ofdgoChangePage", changePage) + registerCallback("ofdgoLoadImport", loadImport) + registerCallback("ofdgoImportPages", importPages) registerCallback("ofdgoInsertText", insertText) registerCallback("ofdgoUpdateText", updateText) + registerCallback("ofdgoStyleText", styleText) + registerCallback("ofdgoCheckTextFont", checkTextFont) registerCallback("ofdgoInsertImage", insertImage) registerCallback("ofdgoInsertShape", insertShape) registerCallback("ofdgoUpdatePathStyle", updatePathStyle) @@ -170,7 +179,13 @@ func callbackResult(fn func([]js.Value) (any, error), args []js.Value) any { data, err := safeCall(fn, args) if err != nil { result := apiResult{Error: err.Error()} - errors.As(err, &result.MissingGlyphs) + var failure *ofdgo.EditError + if errors.As(err, &failure) { + result.ReasonCode = failure.Code + } + if errors.As(err, &result.MissingGlyphs) { + result.ReasonCode = ofdgo.EditMissingGlyphs + } return encodeResult(result) } if result, ok := data.(js.Value); ok { @@ -207,6 +222,7 @@ func openDocument(args []js.Value) (any, error) { return nil, err } renderAnnotations := args[2].Bool() + clearImport() currentEditor = nil copiedObjects = nil if currentSession != nil { @@ -701,7 +717,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { return nil, err } item := map[string]any{"id": id, "type": object.Type, "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "position": position.Index, "count": position.Count, "container": position.Container, - "capabilities": map[string]any{"update": capability.Update, "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}} + "capabilities": map[string]any{"update": capability.Update, "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)}} if missing := capability.MissingGlyphs; missing != nil { item["capabilities"].(map[string]any)["missingGlyphs"] = map[string]any{"fontID": missing.FontID, "characters": missing.Characters} } @@ -729,6 +745,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) { item["text"], item["font"], item["fontName"], item["size"] = value, object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size item["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight item["letterSpacing"] = layout.LetterSpacing + item["leftIndent"], item["rightIndent"], item["firstLineIndent"] = layout.LeftIndent, layout.RightIndent, layout.FirstLineIndent if capability.Reflow { frame, err := object.TextObject.TextFrame() if err != nil { @@ -992,10 +1009,79 @@ func changePage(args []js.Value) (any, error) { return editorPageInfo{info, index}, nil } +// clearImport 释放待插页文档 +func clearImport() { + if pendingImport != nil { + _ = pendingImport.Close() + pendingImport = nil + } +} + +// loadImport 加载待插页文件的索引,null参数清除待处理文件 +// 入参: args OFD字节数组或null +// 返回: any 页数及签名提示, error 错误信息 +func loadImport(args []js.Value) (any, error) { + clearImport() + if args[0].IsNull() { + return nil, nil + } + data, err := bytesFromJS(args[0]) + if err != nil { + return nil, err + } + reader, err := ofdgo.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return nil, err + } + doc, err := reader.Doc() + if err != nil { + reader.Close() + return nil, err + } + if len(doc.Pages.Page) == 0 { + reader.Close() + return nil, fmt.Errorf("document has no pages") + } + pendingImport = reader + return map[string]any{"pageCount": len(doc.Pages.Page), "signed": doc.Signatures != ""}, nil +} + +// importPages 插入指定来源页面并保持目标文档元数据 +// 入参: args 页码表达式,空值为全部,以及目标零基插入位置 +// 返回: any 文档信息及首个插入页, error 错误信息 +func importPages(args []js.Value) (any, error) { + if currentEditor == nil || pendingImport == nil { + return nil, fmt.Errorf("no document is ready for page import") + } + doc, err := pendingImport.Doc() + if err != nil { + return nil, err + } + var indexes []int + if value := strings.TrimSpace(args[0].String()); value != "" { + indexes, err = ofdgo.ParsePageRange(value, len(doc.Pages.Page)) + if err != nil { + return nil, err + } + } else { + for i := range doc.Pages.Page { + indexes = append(indexes, i) + } + } + at := args[1].Int() + if _, err := currentEditor.ImportPages(pendingImport, indexes, at); err != nil { + return nil, err + } + clearImport() + info, err := previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) + return editorPageInfo{info, at}, err +} + // createDocument 新建单页文档 // 入参: args 标题、纸张宽高和注解设置 // 返回: any 文档信息, error 错误信息 func createDocument(args []js.Value) (any, error) { + clearImport() editor := ofdgo.NewEditor() editor.Info.Title = args[0].String() if _, err := editor.AddPage(args[1].Float(), args[2].Float()); err != nil { @@ -1070,6 +1156,13 @@ func updateText(args []js.Value) (any, error) { if err != nil { return nil, err } + value := object.TextObject.Text() + if !args[2].IsNull() { + value = args[2].String() + } + if err := checkFontGlyphs(data, value); err != nil { + return nil, err + } object.TextObject.Font, err = currentEditor.AddFont(FontFile{Data: data}, 0) if err != nil { return nil, err @@ -1097,6 +1190,73 @@ func updateText(args []js.Value) (any, error) { return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations) } +// checkFontGlyphs 只检查候选字体,不注册资源或改变文档 +// 入参: data 字体数据, value 待替换文字 +// 返回: error 缺字或字体格式错误 +func checkFontGlyphs(data []byte, value string) error { + missing, err := (FontFile{Data: data}).MissingGlyphs(0, value) + if err != nil { + return err + } + if missing != "" { + return &ofdgo.MissingGlyphError{Characters: missing} + } + return nil +} + +// checkTextFont 为画布输入预检候选字体 +// 入参: args 字体数据和输入文字 +// 返回: any 空结果, error 缺字或字体格式错误 +func checkTextFont(args []js.Value) (any, error) { + data, err := bytesFromJS(args[0]) + if err != nil { + return nil, err + } + return nil, checkFontGlyphs(data, args[1].String()) +} + +// styleText 批量修改文字样式,字体预检通过后交由库原子提交 +// 入参: args 页码、对象标识、字体数据、字号和颜色,null字体与颜色保持原值 +// 返回: any 文档信息, error 错误信息 +func styleText(args []js.Value) (any, error) { + return changeObjects(func() error { + page, ids := args[0].Int(), stringsFromJS(args[1]) + style := ofdgo.TextStyle{Size: args[3].Float()} + if !args[2].IsNull() { + data, err := bytesFromJS(args[2]) + if err != nil { + return err + } + var value strings.Builder + for _, id := range ids { + object, err := currentEditor.Object(page, id) + if err != nil { + return err + } + if object.Type != "TextObject" { + return fmt.Errorf("object %q is not text", id) + } + value.WriteString(object.TextObject.Text()) + } + if err := checkFontGlyphs(data, value.String()); err != nil { + return err + } + style.Font, err = currentEditor.AddFont(FontFile{Data: data}, 0) + if err != nil { + return err + } + } + if !args[4].IsNull() { + var color ofdgo.FillColor + if err := setEditorColor(&color, args[4].String()); err != nil { + return err + } + style.Color = color.Value + } + return currentEditor.StyleText(page, ids, style) + }) +} + // setTextColor 将浏览器RGB色值写入文字对象,保留颜色透明度 // 入参: object 文字对象, value 十六进制色值 // 返回: error 错误信息 @@ -1223,7 +1383,7 @@ func updatePathStyle(args []js.Value) (any, error) { } // insertText 使用选定字体创建文字对象 -// 入参: args 页码、内容、字体数据、横纵坐标、字号、颜色、框宽、折行、对齐、行距和字距 +// 入参: args 页码、内容、字体数据、横纵坐标、字号、颜色、框宽、折行、对齐、行距、字距及左右和首行缩进 // 返回: any 文档信息, error 错误信息 func insertText(args []js.Value) (any, error) { if currentEditor == nil { @@ -1256,7 +1416,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()}); err != nil { + if err := currentEditor.LayoutText(&object.TextObject, args[1].String(), ofdgo.TextLayout{Wrap: args[8].Bool(), Align: args[9].String(), LineHeight: args[10].Float(), LetterSpacing: args[11].Float(), LeftIndent: args[12].Float(), RightIndent: args[13].Float(), FirstLineIndent: args[14].Float()}); err != nil { return nil, err } if err := setTextColor(&object.TextObject, args[6].String()); err != nil { @@ -1269,7 +1429,7 @@ func insertText(args []js.Value) (any, error) { } // layoutText 调整文字框和段落排版,保留软换行前的原文 -// 入参: args 页码、对象标识、本地左侧偏移、宽度、折行、对齐、行距和字距 +// 入参: args 页码、对象标识、本地左侧偏移、宽度、折行、对齐、行距、字距及左右和首行缩进 // 返回: any 文档信息, error 错误信息 func layoutText(args []js.Value) (any, error) { if currentEditor == nil { @@ -1290,7 +1450,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()}); err != nil { + if err := currentEditor.LayoutText(&object.TextObject, value, ofdgo.TextLayout{Wrap: args[4].Bool(), Align: args[5].String(), LineHeight: args[6].Float(), LetterSpacing: args[7].Float(), LeftIndent: args[8].Float(), RightIndent: args[9].Float(), FirstLineIndent: args[10].Float()}); err != nil { return nil, err } revision := currentEditor.Revision() diff --git a/ofdgo_editor.go b/ofdgo_editor.go index 8060cf3..f4712a8 100644 --- a/ofdgo_editor.go +++ b/ofdgo_editor.go @@ -606,7 +606,7 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject, } for _, color := range []*FillColor{fill, stroke} { if err := creationColor(color); err != nil { - return GraphicObject{}, err + return GraphicObject{}, &EditError{Code: EditUnsupportedColor, Err: err} } } if join != "" && join != "Miter" && join != "Round" && join != "Bevel" { @@ -725,6 +725,9 @@ func transformEditorObject(object GraphicObject, dx, dy, scale float64) (Graphic layout := *obj.layout layout.options.LineHeight *= scale layout.options.LetterSpacing *= scale + layout.options.LeftIndent *= scale + layout.options.RightIndent *= scale + layout.options.FirstLineIndent *= scale obj.layout = &layout } if obj.LineWidth == 0 && obj.Stroke != nil && *obj.Stroke { @@ -821,7 +824,7 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e } sfnt, err := e.editorFont(obj.Font) if err != nil { - return fmt.Errorf("font resource %q not found", obj.Font) + return err } if !finite(obj.Size) || obj.Size <= 0 || !finite(obj.HScale) || obj.HScale < 0 { return fmt.Errorf("invalid text dimensions") @@ -837,16 +840,22 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e if !finite(options.LetterSpacing) { return fmt.Errorf("letter spacing must be finite") } + if !finite(options.LeftIndent) || !finite(options.RightIndent) || !finite(options.FirstLineIndent) { + return fmt.Errorf("paragraph indents must be finite") + } if !slices.Contains([]string{"", "left", "center", "right", "justify"}, options.Align) { return fmt.Errorf("invalid text alignment %q", options.Align) } var width float64 - if options.Wrap || options.Align != "" && options.Align != "left" { + if options.Wrap || options.Align != "" && options.Align != "left" || options.LeftIndent != 0 || options.RightIndent != 0 || options.FirstLineIndent != 0 { box, err := obj.TextFrame() if err != nil { return err } - width = box.W + width = box.W - options.LeftIndent - options.RightIndent + if !finite(width) || width <= 0 || !finite(width-options.FirstLineIndent) || width-options.FirstLineIndent <= 0 { + return fmt.Errorf("paragraph indents leave no text width") + } } hScale := obj.HScale if hScale == 0 { @@ -879,9 +888,14 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e return fmt.Errorf("text advance exceeds finite range") } } - lines := breakTextLines(runes, advances, width, options.Wrap, options.LetterSpacing) + lines := breakTextLines(runes, advances, width, options.Wrap, options.LetterSpacing, options.FirstLineIndent) for i, line := range lines { - x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width, options.Align, i+1 < len(lines), options.LetterSpacing) + indent := 0.0 + if i == 0 { + indent = options.FirstLineIndent + } + x, deltas := alignTextLine(runes[line[0]:line[1]], advances[line[0]:line[1]], width-indent, options.Align, i+1 < len(lines), options.LetterSpacing) + x += options.LeftIndent + indent y := float64(ascender)*unit + float64(len(codes))*lineHeight if !finite(x) || !finite(y) { return fmt.Errorf("text position exceeds finite range") @@ -905,14 +919,17 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e // 返回: error 错误信息 func (e *Editor) prepareText(obj *TextObject) error { sfnt, err := e.editorFont(obj.Font) - if err != nil || !finite(obj.Size) || obj.Size <= 0 { + if err != nil { + return err + } + if !finite(obj.Size) || obj.Size <= 0 { return fmt.Errorf("text requires an embedded font and a positive finite size") } if len(obj.TextCode) == 0 { return fmt.Errorf("text codes are empty") } if obj.VScale != 0 || obj.Decoration != "" || len(obj.CGTransform) != 0 { - return fmt.Errorf("text extensions and glyph transforms are not supported for creation") + return &EditError{Code: EditUnsupportedObject, Err: fmt.Errorf("text extensions and glyph transforms are not supported for creation")} } if !finite(obj.HScale) || obj.HScale < 0 || !finite(obj.LineWidth) || obj.LineWidth < 0 || !finite(obj.MiterLimit) || obj.MiterLimit < 0 { return fmt.Errorf("invalid text dimensions") diff --git a/ofdgo_editor_error.go b/ofdgo_editor_error.go new file mode 100644 index 0000000..f3fe5aa --- /dev/null +++ b/ofdgo_editor_error.go @@ -0,0 +1,70 @@ +// 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 "errors" + +// EditReason 编辑操作受限的原因,不依赖错误描述的语言或文案 +type EditReason string + +const ( + // EditUnsupportedObject 对象包含尚不支持编辑的特性 + EditUnsupportedObject EditReason = "unsupportedObject" + // EditUnsupportedContainer 所在容器包含尚不支持编辑的特性 + EditUnsupportedContainer EditReason = "unsupportedContainer" + // EditUnsupportedColor 颜色表达方式暂不支持编辑 + EditUnsupportedColor EditReason = "unsupportedColor" + // EditUnsupportedStyle 绘制样式暂不支持编辑 + EditUnsupportedStyle EditReason = "unsupportedStyle" + // EditFontUnavailable 对象所需字体不可用 + EditFontUnavailable EditReason = "fontUnavailable" + // EditMissingGlyphs 字体缺少文字所需字形 + EditMissingGlyphs EditReason = "missingGlyphs" + // EditInvalidObject 对象数据未通过校验 + EditInvalidObject EditReason = "invalidObject" +) + +// EditError 编辑受限原因及原始错误,可通过errors.As取得Code和更具体的诊断 +type EditError struct { + Code EditReason + Err error +} + +// Error 保留原始错误描述 +// 返回: string 错误描述 +func (e *EditError) Error() string { + return e.Err.Error() +} + +// Unwrap 保留原始错误链 +// 返回: error 原始错误 +func (e *EditError) Unwrap() error { + return e.Err +} + +// editReason 提取编辑错误分类,未分类的校验错误作为对象数据异常 +// 入参: err 校验错误 +// 返回: EditReason 原因 +func editReason(err error) EditReason { + var missing *MissingGlyphError + if errors.As(err, &missing) { + return EditMissingGlyphs + } + var failure *EditError + if errors.As(err, &failure) { + return failure.Code + } + return EditInvalidObject +} diff --git a/ofdgo_editor_import.go b/ofdgo_editor_import.go new file mode 100644 index 0000000..f288851 --- /dev/null +++ b/ofdgo_editor_import.go @@ -0,0 +1,818 @@ +// 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" + "path" + "slices" + "strconv" + "strings" +) + +// editorImportEntry 资源或关联索引节点及其来源 +type editorImportEntry struct { + name, base, group string + data []byte + node *editorXML +} + +// editorPageImport 一次插页操作的独立资源图,提交前不修改目标文档 +type editorPageImport struct { + reader *Reader + doc *Document + prefix string + maximum int + ids map[string]string + pages map[string]bool + files map[string][]byte + resources map[string]editorImportEntry + used map[string][]byte + templates map[string]TemplatePage + templateParts map[string][]byte + defaultCS string + attachments map[string][]byte +} + +// ImportPages 将来源主文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作 +// 页面索引从0开始,at可等于当前页数;复制关联模板、注释、签章外观和实际引用的资源 +// 不导入来源元数据和目录,指向未选页面的跳转被移除;签名数据仅保留原始凭据,不代表合并后文档有效 +// 返回后可关闭来源Reader,目标原Reader的生命周期要求不变 +// 入参: source 来源阅读器, indexes 来源页面索引,不可重复, at 目标插入位置 +// 返回: []string 新页面标识, error 错误信息 +func (e *Editor) ImportPages(source *Reader, indexes []int, at int) ([]string, error) { + if at < 0 || at > len(e.pages) { + return nil, fmt.Errorf("page index %d out of range", at) + } + doc, err := source.Doc() + if err != nil { + return nil, err + } + selected := make(map[string]bool) + for _, index := range indexes { + if index < 0 || index >= len(doc.Pages.Page) { + return nil, fmt.Errorf("source page index %d out of range", index) + } + id := doc.Pages.Page[index].ID + if selected[id] { + return nil, fmt.Errorf("duplicate source page %q", id) + } + selected[id] = true + } + if len(indexes) == 0 { + return nil, nil + } + if err := e.prepareSourceIDs(); err != nil { + return nil, err + } + base := e.source + if base == nil { + base, err = e.importBase() + if err != nil { + return nil, err + } + } + prefix := path.Join(base.directory, "Import_"+strconv.Itoa(e.maxID+1)) + for editorDirectoryExists(base.reader, prefix) { + prefix += "_" + } + m := &editorPageImport{reader: source, doc: doc, prefix: prefix, maximum: e.maxID, + 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 _, 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(``) + m.resources["defaultRGB"] = editorImportEntry{group: "ColorSpaces"} + } + refs := make([]Page, len(indexes)) + ids := make([]string, len(indexes)) + for i, index := range indexes { + page := doc.Pages.Page[index] + ids[i] = m.id(page.ID) + name, err := m.copyXML(source.ResPath(page.BaseLoc)) + if err != nil { + return nil, err + } + refs[i] = Page{ID: ids[i], BaseLoc: name} + } + annotations, err := m.annotations() + if err != nil { + return nil, err + } + signatures, err := m.signatures() + if err != nil { + return nil, err + } + 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 { + return nil, err + } + resourceContent = append(resourceContent, encoded...) + } + resources, err := editorXMLContainer("Res", nil, resourceContent) + if err != nil { + return nil, err + } + m.files[path.Join(prefix, "Resources.xml")] = resources + next, err := m.merge(base, refs, annotations, signatures) + if err != nil { + return nil, err + } + added := make([]PageContent, len(refs)) + for i, ref := range refs { + added[i] = PageContent{ID: ref.ID} + } + 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.source, e.maxID = next, m.maximum + e.pages = slices.Insert(e.pages, at, added...) + if change := e.recordChange(); change != nil { + change.undo = func(e *Editor) { + for i := range added { + added[i] = copyEditorPage(e.pages[at+i]) + } + e.pages = slices.Delete(e.pages, at, at+len(added)) + e.source = base + } + change.redo = func(e *Editor) { + pages := make([]PageContent, len(added)) + for i := range added { + pages[i] = copyEditorPage(added[i]) + } + e.pages = slices.Insert(e.pages, at, pages...) + e.source = next + } + } + return ids, nil +} + +// importBase 为新建文档提供空的保真包基底,不重复保存已创建的页面和资源 +// 返回: *editorSource 包基底, error 错误信息 +func (e *Editor) importBase() (*editorSource, error) { + seed := NewEditor() + seed.Info = e.Info + seed.pages = []PageContent{{Area: PageArea{PhysicalBox: "0 0 210 297"}}} + files := make(map[string][]byte) + if err := seed.writeParts(func(name string, data []byte, _ bool) error { + if name == "OFD.xml" || name == "Doc_0/Document.xml" { + files[name] = data + } + return nil + }); err != nil { + return nil, err + } + data := files["Doc_0/Document.xml"] + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + files["Doc_0/Document.xml"] = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, root.child("Pages"), nil)}) + reader := &Reader{files: files} + if err := reader.initRoot(); err != nil { + return nil, err + } + editor, err := reader.Editor() + if err != nil { + return nil, err + } + editor.source.idsReady = true + return editor.source, nil +} + +// id 为来源标识分配稳定且不与目标冲突的新标识 +// 入参: value 来源标识 +// 返回: string 新标识 +func (m *editorPageImport) id(value string) string { + if id := m.ids[value]; id != "" { + return id + } + m.maximum++ + id := strconv.Itoa(m.maximum) + m.ids[value] = id + return id +} + +// resourceIndex 登记资源定义,按引用延迟复制资源内容 +// 入参: name 资源索引路径 +// 返回: error 错误信息 +func (m *editorPageImport) resourceIndex(name string) error { + data, err := m.reader.readFile(name) + if err != nil { + return err + } + root, err := parseEditorXML(data) + if err != nil { + return err + } + if root.name.Local != "Res" { + return fmt.Errorf("invalid resource index %q", name) + } + for _, group := range root.children { + for _, node := range group.children { + m.resources[node.attr("ID")] = editorImportEntry{name: name, base: root.attr("BaseLoc"), group: group.name.Local, data: data, node: node} + } + } + return nil +} + +// reference 复制被引用的资源及其传递依赖 +// 入参: value 来源资源标识 +// 返回: string 新标识, error 错误信息 +func (m *editorPageImport) reference(value string) (string, error) { + if entry, ok := m.resources[value]; ok { + if _, seen := m.used[value]; !seen { + m.used[value] = nil + data, err := m.encode(entry, entry.node) + if err != nil { + return "", err + } + m.used[value] = data + } + } + if template, ok := m.templates[value]; ok { + if _, seen := m.templateParts[value]; !seen { + m.templateParts[value] = nil + name, err := m.copyXML(m.reader.ResPath(template.BaseLoc)) + if err != nil { + return "", err + } + data, err := editorXMLContainer("TemplatePage", ofdAttrs{{Name: xml.Name{Local: "ID"}, Value: m.id(value)}, {Name: xml.Name{Local: "Name"}, Value: template.Name}, {Name: xml.Name{Local: "BaseLoc"}, Value: name}, {Name: xml.Name{Local: "ZOrder"}, Value: template.ZOrder}}, nil) + if err != nil { + return "", err + } + m.templateParts[value] = bytes.TrimPrefix(data, []byte(xml.Header)) + } + } + return m.id(value), nil +} + +// resolve 按所在XML解析包内路径,兼容阅读器接受的文档相对路径 +// 入参: name 所在XML, base 资源目录, value 路径值 +// 返回: string 包内路径, error 错误信息 +func (m *editorPageImport) resolve(name, base, value string) (string, error) { + for _, candidate := range []string{resolveResourcePath(name, base, value), m.reader.ResPath(value)} { + if m.reader.files[candidate] != nil { + return candidate, nil + } + if file, ok := m.reader.packageFile(candidate); ok { + return cleanPackagePath(file.Name), nil + } + } + return "", fmt.Errorf("import reference %q in %s not found", value, name) +} + +// copyData 复制二进制资源或签名引用原文,返回绝对包路径 +// 入参: name 来源路径, evidence 是否原始签名凭据 +// 返回: string 新路径, error 错误信息 +func (m *editorPageImport) copyData(name string, evidence bool) (string, error) { + directory := m.prefix + if evidence { + directory = path.Join(directory, "Signed") + } + target := path.Join(directory, name) + if _, exists := m.files[target]; !exists { + data, err := m.reader.readFile(name) + if err != nil { + return "", err + } + m.files[target] = bytes.Clone(data) + } + return "/" + target, nil +} + +// copyXML 复制页面或标准关联XML并重映射引用 +// 入参: name 来源路径 +// 返回: string 新路径, error 错误信息 +func (m *editorPageImport) copyXML(name string) (string, error) { + target := path.Join(m.prefix, name) + if _, exists := m.files[target]; exists { + return "/" + target, nil + } + data, err := m.reader.readFile(name) + if err != nil { + return "", err + } + root, err := parseEditorXML(data) + if err != nil { + return "", err + } + m.files[target] = nil + if root.name.Local == "Page" { + for _, child := range root.children { + if child.name.Local == "PageRes" { + loc, err := m.resolve(name, "", editorImportText(data, child)) + if err != nil { + return "", err + } + if err := m.resourceIndex(loc); err != nil { + return "", err + } + } + } + } + encoded, err := m.encode(editorImportEntry{name: name, data: data}, root) + if err != nil { + return "", err + } + m.files[target] = append([]byte(xml.Header), encoded...) + return "/" + target, nil +} + +// editorImportText 获取节点直接文本,解码XML实体 +// 入参: data XML, node 节点 +// 返回: string 文本 +func editorImportText(data []byte, node *editorXML) string { + var value struct { + Text string `xml:",chardata"` + } + _ = xml.Unmarshal(data[node.start:node.end], &value) + return value.Text +} + +// encode 编码标准OFD节点,保留文字数据和几何值,仅改写资源、标识及路径 +// 入参: entry 来源上下文, node 当前节点 +// 返回: []byte 独立XML片段, error 错误信息 +func (m *editorPageImport) encode(entry editorImportEntry, node *editorXML) ([]byte, error) { + if node.name.Space != "" && node.name.Space != ofdNamespace && node.name.Space != "http://www.ofdspec.org" { + return nil, fmt.Errorf("cannot remap extension namespace %q", node.name.Space) + } + name := node.name.Local + if name == "PageRes" { + return nil, nil + } + if name == "StampAnnot" && !m.pages[node.attr("PageRef")] { + return nil, nil + } + if name == "Action" { + if gotoNode := node.child("Goto"); gotoNode != nil { + var action Goto + if err := xml.Unmarshal(entry.data[gotoNode.start:gotoNode.end], &action); err != nil { + return nil, err + } + bookmarks := make(map[string]Dest) + for _, bookmark := range m.doc.Bookmarks.Bookmark { + bookmarks[bookmark.Name] = bookmark.Dest + } + if dest := gotoDest(&action, bookmarks); dest == nil || !m.pages[dest.PageID] { + return nil, nil + } + } + } + var attrs ofdAttrs + for _, attr := range node.attrs { + if attr.Name.Space == "xmlns" || attr.Name.Local == "xmlns" { + continue + } + if attr.Name.Space == "http://www.w3.org/2001/XMLSchema-instance" && (attr.Name.Local == "schemaLocation" || attr.Name.Local == "noNamespaceSchemaLocation") { + continue + } + if attr.Name.Space != "" { + return nil, fmt.Errorf("cannot remap extension attribute %q", attr.Name.Local) + } + key, value := attr.Name.Local, attr.Value + var err error + switch key { + case "ID": + if name == "Signature" || name == "StampAnnot" { + value = name + ":" + value + } + value = m.id(value) + case "Font", "ResourceID", "Substitution", "ImageMask", "Relative", "DrawParam", "ColorSpace", "Thumbnail", "TemplateID", "PageID", "PageRef", "RefId": + value, err = m.reference(value) + case "BaseLoc", "FileRef": + loc, resolveErr := m.resolve(entry.name, "", value) + if resolveErr != nil { + return nil, resolveErr + } + if key == "FileRef" { + value, err = m.copyData(loc, true) + } else { + value, err = m.copyXML(loc) + } + case "AttachID": + value, err = m.attachment(value) + } + if err != nil { + return nil, err + } + attrs = append(attrs, xml.Attr{Name: xml.Name{Local: key}, Value: value}) + } + if m.defaultCS != "" && (name == "FillColor" || name == "StrokeColor" || name == "Color") && node.attr("ColorSpace") == "" { + attrs = append(attrs, xml.Attr{Name: xml.Name{Local: "ColorSpace"}, Value: m.defaultCS}) + } + var content []byte + if name == "Page" { + area := m.doc.CommonData.PageArea + if local := node.child("Area"); local != nil { + var own PageArea + if err := xml.Unmarshal(entry.data[local.start:local.end], &own); err != nil { + return nil, err + } + if own.PhysicalBox != "" { + area.PhysicalBox = own.PhysicalBox + } + if own.ApplicationBox != "" { + area.ApplicationBox = own.ApplicationBox + } + if own.ContentBox != "" { + area.ContentBox = own.ContentBox + } + if own.BleedBox != "" { + area.BleedBox = own.BleedBox + } + } + var fields []byte + for _, field := range [][2]string{{"PhysicalBox", area.PhysicalBox}, {"ApplicationBox", area.ApplicationBox}, {"ContentBox", area.ContentBox}, {"BleedBox", area.BleedBox}} { + if field[1] != "" { + fields = append(fields, editorXMLText(field[0], field[1])...) + } + } + encoded, err := editorXMLContainer("Area", nil, fields) + if err != nil { + return nil, err + } + content = bytes.TrimPrefix(encoded, []byte(xml.Header)) + } + for _, child := range node.children { + if name == "Page" && child.name.Local == "Area" { + continue + } + if name == "Goto" && child.name.Local == "Bookmark" { + for _, bookmark := range m.doc.Bookmarks.Bookmark { + if bookmark.Name != child.attr("Name") { + continue + } + dest := bookmark.Dest + encoded, err := xml.Marshal(struct { + XMLName xml.Name `xml:"Dest"` + Dest + }{Dest: dest}) + if err != nil { + return nil, err + } + root, err := parseEditorXML(encoded) + if err != nil { + return nil, err + } + encoded, err = m.encode(editorImportEntry{data: encoded}, root) + if err != nil { + return nil, err + } + content = append(content, encoded...) + } + continue + } + encoded, err := m.encode(entry, child) + if err != nil { + return nil, err + } + content = append(content, encoded...) + } + if len(node.children) == 0 { + value := editorImportText(entry.data, node) + var err error + switch name { + case "FontFile", "MediaFile", "Profile", "SignedValue", "BaseLoc": + loc, resolveErr := m.resolve(entry.name, entry.base, strings.TrimSpace(value)) + if resolveErr != nil { + return nil, resolveErr + } + value, err = m.copyData(loc, false) + case "FileLoc": + loc, resolveErr := m.resolve(entry.name, "", strings.TrimSpace(value)) + if resolveErr != nil { + return nil, resolveErr + } + if node.parent.name.Local == "Attachment" { + value, err = m.copyData(loc, false) + } else { + value, err = m.copyXML(loc) + } + case "Font", "Substitution": + value, err = m.reference(strings.TrimSpace(value)) + } + if err != nil { + return nil, err + } + var escaped bytes.Buffer + if err := xml.EscapeText(&escaped, []byte(value)); err != nil { + return nil, err + } + content = append(content, escaped.Bytes()...) + } + encoded, err := editorXMLContainer(name, attrs, content) + return bytes.TrimPrefix(encoded, []byte(xml.Header)), err +} + +// annotations 复制所选页面的注释索引 +// 返回: []byte 索引条目, error 错误信息 +func (m *editorPageImport) annotations() ([]byte, error) { + if m.doc.Annotations == "" { + return nil, nil + } + name := m.reader.ResPath(m.doc.Annotations) + data, err := m.reader.readFile(name) + if err != nil { + return nil, err + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + var entries []byte + for _, node := range root.children { + if !m.pages[node.attr("PageID")] { + continue + } + encoded, err := m.encode(editorImportEntry{name: name, data: data}, node) + if err != nil { + return nil, err + } + entries = append(entries, encoded...) + } + return entries, nil +} + +// attachment 复制页面动作引用的附件,不导入无关附件 +// 入参: id 来源附件标识 +// 返回: string 新标识, error 错误信息 +func (m *editorPageImport) attachment(id string) (string, error) { + if _, exists := m.attachments[id]; exists { + return m.id(id), nil + } + name := m.reader.ResPath(m.reader.OFD.DocBody[0].DocRoot) + if m.doc.Attachments.Path != "" { + name = m.reader.ResPath(m.doc.Attachments.Path) + } + data, err := m.reader.readFile(name) + if err != nil { + return "", err + } + root, err := parseEditorXML(data) + if err != nil { + return "", err + } + if m.doc.Attachments.Path == "" { + root = root.child("Attachments") + } + if root != nil { + for _, node := range root.children { + if node.name.Local == "Attachment" && node.attr("ID") == id { + m.attachments[id], err = m.encode(editorImportEntry{name: name, data: data}, node) + return m.id(id), err + } + } + } + return "", fmt.Errorf("attachment %q not found", id) +} + +// signatures 复制所选页面的签章及原始验证凭据,过滤其他页面的签章位置 +// 返回: []byte 签名索引条目, error 错误信息 +func (m *editorPageImport) signatures() ([]byte, error) { + if m.doc.Signatures == "" { + return nil, nil + } + name := m.reader.ResPath(m.doc.Signatures) + data, err := m.reader.readFile(name) + if err != nil { + return nil, err + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + var entries []byte + for _, node := range root.children { + if node.name.Local != "Signature" { + continue + } + loc, err := m.resolve(name, "", node.attr("BaseLoc")) + if err != nil { + return nil, err + } + value, err := m.reader.readFile(loc) + if err != nil { + return nil, err + } + var signature SignatureFile + if err := xml.Unmarshal(value, &signature); err != nil { + return nil, err + } + matched := false + for _, stamp := range signature.SignedInfo.StampAnnot { + matched = matched || m.pages[stamp.PageRef] + } + if !matched { + continue + } + encoded, err := m.encode(editorImportEntry{name: name, data: data}, node) + if err != nil { + return nil, err + } + entries = append(entries, encoded...) + } + return entries, nil +} + +// merge 合并包索引及资源图,原有页面和元数据保持原文 +// 入参: base 目标基底, refs 插入页面, annotations 注释索引, signatures 签名索引 +// 返回: *editorSource 新基底, error 错误信息 +func (m *editorPageImport) merge(base *editorSource, refs []Page, annotations, signatures []byte) (*editorSource, error) { + files := maps.Clone(base.reader.files) + if files == nil { + files = make(map[string][]byte) + } + maps.Copy(files, m.files) + name := base.reader.ResPath(base.reader.OFD.DocBody[0].DocRoot) + data, err := base.reader.readFile(name) + if err != nil { + return nil, err + } + root, err := parseEditorXML(data) + if err != nil { + return nil, err + } + common := root.child("CommonData") + if common == nil { + return nil, fmt.Errorf("document has no CommonData") + } + position := common.close + for _, node := range common.children { + if node.name.Local == "TemplatePage" || node.name.Local == "DefaultCS" { + position = node.start + break + } + } + content := editorXMLText("DocumentRes", "/"+path.Join(m.prefix, "Resources.xml")) + for _, id := range slices.Sorted(maps.Keys(m.templateParts)) { + content = append(content, m.templateParts[id]...) + } + data = editorPatchXML(data, []editorXMLPatch{{position, position, content}}) + if len(annotations) != 0 { + loc, err := mergeImportIndex(base.reader, files, base.document.Annotations, path.Join(m.prefix, "Annotations.xml"), "Annotations", annotations) + if err != nil { + return nil, err + } + root, _ = parseEditorXML(data) + data = editorXMLSetText(data, root, [][2]string{{"Annotations", loc}}) + } + if len(m.attachments) != 0 { + var entries []byte + for _, id := range slices.Sorted(maps.Keys(m.attachments)) { + entries = append(entries, m.attachments[id]...) + } + root, _ = parseEditorXML(data) + node := root.child("Attachments") + if node != nil && len(node.children) != 0 { + content := append(bytes.Clone(data[node.open:node.close]), entries...) + data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, node, content)}) + } else { + loc, err := mergeImportIndex(base.reader, files, base.document.Attachments.Path, path.Join(m.prefix, "Attachments.xml"), "Attachments", entries) + if err != nil { + return nil, err + } + data = editorXMLSetText(data, root, [][2]string{{"Attachments", loc}}) + } + } + files[name] = data + if len(signatures) != 0 { + loc, err := mergeImportIndex(base.reader, files, base.document.Signatures, path.Join(m.prefix, "Signatures.xml"), "Signatures", signatures) + if err != nil { + return nil, err + } + if base.document.Signatures == "" { + ofd, err := base.reader.readFile("OFD.xml") + if err != nil { + return nil, err + } + root, err := parseEditorXML(ofd) + if err != nil { + return nil, err + } + files["OFD.xml"] = editorXMLSetText(ofd, root.child("DocBody"), [][2]string{{"Signatures", loc}}) + } + } + reader := &Reader{Zip: base.reader.Zip, files: files} + if err := reader.initRoot(); err != nil { + return nil, err + } + doc, err := reader.Doc() + if err != nil { + return nil, err + } + next := *base + next.reader, next.document = reader, doc + next.pages, next.origins = maps.Clone(base.pages), maps.Clone(base.origins) + for id, page := range next.pages { + if page.original == nil { + next.pages[id] = &editorSourcePage{ref: page.ref} + } + } + for _, ref := range refs { + next.pages[ref.ID] = &editorSourcePage{ref: ref} + } + next.idsReady = true + return &next, nil +} + +// mergeImportIndex 向原索引追加条目,不移动原条目的相对路径基准 +// 入参: reader 目标输入包, files 新条目, loc 原索引, fallback 新索引路径, rootName 根节点名称, entries 新条目 +// 返回: string 索引绝对路径, error 错误信息 +func mergeImportIndex(reader *Reader, files map[string][]byte, loc, fallback, rootName string, entries []byte) (string, error) { + name := fallback + var data []byte + var err error + if loc == "" { + data, err = editorXMLContainer(rootName, nil, entries) + } else { + name = reader.ResPath(loc) + data, err = reader.readFile(name) + if err != nil { + return "", err + } + root, parseErr := parseEditorXML(data) + if parseErr != nil { + return "", parseErr + } + if root.open == root.end { + data = editorPatchXML(data, []editorXMLPatch{editorXMLContent(data, root, entries)}) + } else { + data = editorPatchXML(data, []editorXMLPatch{{root.close, root.close, entries}}) + } + } + if err != nil { + return "", err + } + if rootName == "Signatures" { + root, err := parseEditorXML(data) + if err != nil { + return "", err + } + maximum := 0 + for _, node := range root.children { + value := node.attr("ID") + if node.name.Local == "MaxSignId" { + value = editorImportText(data, node) + } + id, _ := strconv.Atoi(value) + maximum = max(maximum, id) + } + value := editorXMLText("MaxSignId", strconv.Itoa(maximum)) + patch := editorXMLPatch{root.open, root.open, value} + if node := root.child("MaxSignId"); node != nil { + patch = editorXMLPatch{node.start, node.end, value} + } + data = editorPatchXML(data, []editorXMLPatch{patch}) + } + files[name] = data + return "/" + name, nil +} diff --git a/ofdgo_editor_open.go b/ofdgo_editor_open.go index 8bc268d..db2826e 100644 --- a/ofdgo_editor_open.go +++ b/ofdgo_editor_open.go @@ -45,7 +45,7 @@ type editorObjectOrigin struct { page *editorSourcePage node *editorXML object GraphicObject - reason string + reason error } // editorSourcePage 保存页面原文、解析快照及图层和页块内的对象位置 @@ -72,16 +72,18 @@ type ObjectCapabilities struct { Delete bool Order bool Reason string + ReasonCode EditReason MissingGlyphs *MissingGlyphError } // editError 将操作受限原因转为错误,保留可供调用方识别的缺字诊断 // 返回: error 受限原因 func (c ObjectCapabilities) editError() error { + var err error = errors.New(c.Reason) if c.MissingGlyphs != nil { - return c.MissingGlyphs + err = c.MissingGlyphs } - return errors.New(c.Reason) + return &EditError{Code: c.ReasonCode, Err: err} } // PageCapabilities 页面可执行的操作,Insert表示可以添加RGB对象;新增空白页面始终可用 @@ -309,7 +311,7 @@ func (e *Editor) loadSourcePage(index int) error { if editorXMLSupported(node) { resolved, err := e.resolveEditorStyle(object, layer.DrawParam) if err != nil { - origin.reason = err.Error() + origin.reason = err } else { layer.Objects[j] = resolved } @@ -343,10 +345,10 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er return all, nil } if !editorXMLSupported(node) { - return ObjectCapabilities{Reason: "object uses unsupported editing features"}, nil + return ObjectCapabilities{Reason: "object uses unsupported editing features", ReasonCode: EditUnsupportedObject}, nil } - if origin := e.objectOrigin(id); origin.reason != "" { - return ObjectCapabilities{Reason: origin.reason}, nil + if origin := e.objectOrigin(id); origin.reason != nil { + return ObjectCapabilities{Reason: origin.reason.Error(), ReasonCode: editReason(origin.reason)}, nil } for parent := node.parent; parent.name.Local != "Content"; parent = parent.parent { allowed := "ID" @@ -354,21 +356,23 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er allowed += " Type DrawParam" } if !editorXMLAttributes(parent, allowed) { - return ObjectCapabilities{Reason: "object container uses unsupported editing features"}, nil + return ObjectCapabilities{Reason: "object container uses unsupported editing features", ReasonCode: EditUnsupportedContainer}, nil } } if err := validateEditorGeometry(object); err != nil { - return ObjectCapabilities{Reason: err.Error()}, nil + return ObjectCapabilities{Reason: err.Error(), ReasonCode: EditInvalidObject}, nil } all.Order = editorContainerOrderable(node.parent) if _, err := e.prepareObject(id, object); err != nil { all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, err.Error() + all.ReasonCode = editReason(err) errors.As(err, &all.MissingGlyphs) } if !e.sourceRGB() { all.ReplaceFont = false all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, "document uses a non-RGB default color space" all.MissingGlyphs = nil + all.ReasonCode = EditUnsupportedColor } if object.Type == "TextObject" { all.Arrange = all.Update @@ -426,16 +430,16 @@ func (e *Editor) editorFont(id string) (*font.SFNT, error) { if e.source != nil { data, err := e.source.reader.FontData(id) if err != nil { - return nil, err + return nil, &EditError{Code: EditFontUnavailable, Err: err} } sfnt, err := font.ParseSFNT(data, 0) if err != nil { - return nil, err + return nil, &EditError{Code: EditFontUnavailable, Err: err} } e.fonts[id] = sfnt return sfnt, nil } - return nil, fmt.Errorf("embedded font %q not found", id) + return nil, &EditError{Code: EditFontUnavailable, Err: fmt.Errorf("embedded font %q not found", id)} } // editorImage 按需读取原图片尺寸,不解码完整像素 diff --git a/ofdgo_editor_style.go b/ofdgo_editor_style.go index 15c18c5..254bd3a 100644 --- a/ofdgo_editor_style.go +++ b/ofdgo_editor_style.go @@ -25,7 +25,7 @@ func (e *Editor) editorDrawParam(id string, visited map[string]bool) (*DrawParam } dp := e.source.reader.drawParamCache[id] if dp == nil || visited[id] { - return nil, fmt.Errorf("invalid draw parameter reference %q", id) + return nil, &EditError{Code: EditUnsupportedStyle, Err: fmt.Errorf("invalid draw parameter reference %q", id)} } visited[id] = true base, err := e.editorDrawParam(dp.Relative, visited) @@ -72,7 +72,7 @@ func (e *Editor) resolveEditorStyle(object GraphicObject, layer string) (Graphic } else { obj := &object.TextObject if style.Cap != "" && style.Cap != "Butt" || style.DashPattern != "" || style.DashOffset != nil && *style.DashOffset != 0 { - return GraphicObject{}, fmt.Errorf("text draw parameters require unsupported stroke styles") + return GraphicObject{}, &EditError{Code: EditUnsupportedStyle, Err: fmt.Errorf("text draw parameters require unsupported stroke styles")} } style = mergeDrawParam(*style, &DrawParam{LineWidth: obj.LineWidth, Join: obj.Join, MiterLimit: obj.MiterLimit, FillColor: obj.FillColor, StrokeColor: obj.StrokeColor}) @@ -84,7 +84,7 @@ func (e *Editor) resolveEditorStyle(object GraphicObject, layer string) (Graphic } for _, color := range []*FillColor{style.FillColor, (*FillColor)(style.StrokeColor)} { if err := creationColor(color); err != nil { - return GraphicObject{}, fmt.Errorf("unsupported draw parameter color: %w", err) + return GraphicObject{}, &EditError{Code: EditUnsupportedColor, Err: fmt.Errorf("unsupported draw parameter color: %w", err)} } } return cloneEditorObject(object) diff --git a/ofdgo_editor_text.go b/ofdgo_editor_text.go index 9803526..5f549d3 100644 --- a/ofdgo_editor_text.go +++ b/ofdgo_editor_text.go @@ -58,12 +58,69 @@ func missingGlyphError(id string, characters []rune) error { // TextLayout 本地横向段落选项,Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify // LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负 +// LeftIndent和RightIndent为左右缩进,FirstLineIndent为每段首行相对左缩进的偏移,单位为毫米 // 零值保持显式换行和左对齐 type TextLayout struct { - Wrap bool - Align string - LineHeight float64 - LetterSpacing float64 + Wrap bool + Align string + LineHeight float64 + LetterSpacing float64 + LeftIndent float64 + RightIndent float64 + FirstLineIndent float64 +} + +// TextStyle 文字样式增量,Font和Color为空、Size为0时保留原值 +// Size单位为毫米,Color为OFD的RGB分量字符串,保留原颜色透明度 +type TextStyle struct { + Font string + Size float64 + Color string +} + +// StyleText 原子更新同页文字样式,全部校验通过后提交一次撤销记录 +// 已知段落重新排版;原布局未知时替换字体和颜色保留坐标,不允许自动调整字号 +// 入参: page 页面索引, ids 文字对象标识, style 样式增量 +// 返回: error 错误信息 +func (e *Editor) StyleText(page int, ids []string, style TextStyle) error { + objects, _, err := e.selectedObjects(page, ids) + if err != nil { + return err + } + updates := make([]GraphicObject, len(objects)) + for i, object := range objects { + id := editorObjectID(object) + if object.Type != "TextObject" { + return fmt.Errorf("object %q is not text", id) + } + updates[i] = cloneEditorData(object) + text := &updates[i].TextObject + value, layout := text.TextLayout() + known := e.objectOrigin(id) == nil || text.layout != nil + fontChanged := style.Font != "" && style.Font != text.Font + sizeChanged := style.Size != 0 && style.Size != text.Size + if sizeChanged && !known { + return &EditError{Code: EditUnsupportedObject, Err: fmt.Errorf("object %q requires explicit paragraph layout before changing size", id)} + } + if fontChanged { + text.Font = style.Font + } + if sizeChanged { + text.Size = style.Size + } + if known && (fontChanged || sizeChanged) { + if err := e.LayoutText(text, value, layout); err != nil { + return err + } + } + if style.Color != "" { + if text.FillColor == nil { + text.FillColor = &FillColor{} + } + text.FillColor.Value = style.Color + } + } + return e.UpdateObjects(page, updates) } // textLayout 保存编辑中的原文与选项,不写入OFD,也不参与渲染 @@ -83,9 +140,9 @@ func (obj TextObject) TextLayout() (string, TextLayout) { } // breakTextLines 优先使用Unicode断行机会,过长词仅在字素边界折行,不丢弃空白 -// 入参: runes 段落字符, advances 含字距的字符步进, width 本地排版宽度, wrap 是否自动折行, spacing 附加字距 +// 入参: runes 段落字符, advances 含字距的字符步进, width 本地排版宽度, wrap 是否自动折行, spacing 附加字距, indent 首行缩进 // 返回: [][2]int 各行的字符起止索引,左闭右开 -func breakTextLines(runes []rune, advances []float64, width float64, wrap bool, spacing float64) [][2]int { +func breakTextLines(runes []rune, advances []float64, width float64, wrap bool, spacing, indent float64) [][2]int { if !wrap || len(runes) == 0 { return [][2]int{{0, len(runes)}} } @@ -107,6 +164,10 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool, } var lines [][2]int for start, cursor := 0, 0; start < len(runes); { + available := width + if start == 0 { + available -= indent + } end, legal, next := start, start, cursor for next < len(ends) { candidate := ends[next] @@ -118,7 +179,7 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool, if visible > start { length -= spacing } - if length > width && end > start { + if length > available && end > start { break } end = candidate @@ -126,7 +187,7 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool, if breaks[end] { legal = end } - if length > width { + if length > available { break } } diff --git a/ofdgo_font_file.go b/ofdgo_font_file.go index a6339a8..4dd69e1 100644 --- a/ofdgo_font_file.go +++ b/ofdgo_font_file.go @@ -19,6 +19,7 @@ import ( "encoding/binary" "fmt" "strings" + "unicode/utf8" "github.com/tdewolff/font" ) @@ -96,6 +97,35 @@ func (f FontFile) Face(index int) ([]byte, error) { return bytes.Clone(data), nil } +// MissingGlyphs 检查指定字体的字符覆盖,不注册资源或修改文档,忽略段落换行 +// 入参: index 零起始字体索引, text 待检查文字 +// 返回: string 按首次出现顺序去重的缺失字符, error 字体或文字格式错误 +func (f FontFile) MissingGlyphs(index int, text string) (string, error) { + if !utf8.ValidString(text) { + return "", fmt.Errorf("text must be UTF-8") + } + data, err := f.Face(index) + if err != nil { + return "", err + } + sfnt, err := font.ParseSFNT(data, 0) + if err != nil { + return "", err + } + seen := make(map[rune]bool) + var missing strings.Builder + for _, char := range text { + if char == '\r' || char == '\n' || seen[char] { + continue + } + seen[char] = true + if sfnt.GlyphIndex(char) == 0 { + missing.WriteRune(char) + } + } + return missing.String(), nil +} + // fontFileCount 校验字体或集合头并读取字体数量 // 入参: data OpenType数据 // 返回: int 字体数量, error 格式错误