mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 17:27:08 +08:00
This commit is contained in:
1 parent
f924f008a3
commit
299ff8d002
13 files changed
+1418
-71
No files matched your search
+21
-2
@@ -123,7 +123,7 @@
|
|||||||
<label class="editor-field"><span>行距</span><span class="editor-number"><input id="textLineHeight" type="number" min="0" step="any" placeholder="自动" aria-label="行距" title="行距,0为自动" disabled><span>pt</span></span></label>
|
<label class="editor-field"><span>行距</span><span class="editor-number"><input id="textLineHeight" type="number" min="0" step="any" placeholder="自动" aria-label="行距" title="行距,0为自动" disabled><span>pt</span></span></label>
|
||||||
<button id="textWrap" class="button" type="button" aria-label="自动折行" aria-pressed="false" disabled>折行</button>
|
<button id="textWrap" class="button" type="button" aria-label="自动折行" aria-pressed="false" disabled>折行</button>
|
||||||
<label class="editor-field"><span>字距</span><span class="editor-number"><input id="textSpacing" type="number" step="any" placeholder="0" aria-label="字距" title="字距" disabled><span>pt</span></span></label>
|
<label class="editor-field"><span>字距</span><span class="editor-number"><input id="textSpacing" type="number" step="any" placeholder="0" aria-label="字距" title="字距" disabled><span>pt</span></span></label>
|
||||||
<span>段落</span>
|
<button id="paragraphButton" class="editor-group-action" type="button" title="段落缩进" aria-label="段落缩进" aria-haspopup="dialog" aria-controls="paragraphPanel" disabled>段落</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-group">
|
<div class="editor-group">
|
||||||
<div class="shape-paint"><label><input id="shapeFill" type="checkbox">填充</label><input id="shapeFillColor" class="color-input" type="color" value="#dcefeb" aria-label="填充颜色" title="填充颜色"></div>
|
<div class="shape-paint"><label><input id="shapeFill" type="checkbox">填充</label><input id="shapeFillColor" class="color-input" type="color" value="#dcefeb" aria-label="填充颜色" title="填充颜色"></div>
|
||||||
@@ -155,7 +155,7 @@
|
|||||||
<span>变换</span>
|
<span>变换</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-group">
|
<div class="editor-group">
|
||||||
<button id="addPageButton" class="button" type="button" aria-label="新增页面">新增</button>
|
<select id="addPageButton" aria-label="新增页面" title="新增页面"><option value="" hidden>新增</option><option value="blank">空白</option><option value="import">插页</option></select>
|
||||||
<button id="copyPageButton" class="button" type="button" aria-label="复制页面">复制</button>
|
<button id="copyPageButton" class="button" type="button" aria-label="复制页面">复制</button>
|
||||||
<button id="deletePageButton" class="button" type="button" aria-label="删除页面">删除</button>
|
<button id="deletePageButton" class="button" type="button" aria-label="删除页面">删除</button>
|
||||||
<button id="movePagePrevButton" class="button" type="button" aria-label="页面前移">前移</button>
|
<button id="movePagePrevButton" class="button" type="button" aria-label="页面前移">前移</button>
|
||||||
@@ -187,6 +187,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
<dialog id="importPanel" class="form-dialog" aria-label="插入页面">
|
||||||
|
<form id="importForm">
|
||||||
|
<div class="form-row"><label for="importFile">文档</label><input id="importFile" type="file" accept=".ofd" required autofocus></div>
|
||||||
|
<div class="form-row"><label for="importRange">范围</label><select id="importRange"><option value="all">全部</option><option value="custom">指定</option></select></div>
|
||||||
|
<div id="importPagesRow" class="form-row" hidden><label for="importPages">页码</label><input id="importPages" type="text" autocomplete="off" spellcheck="false" disabled></div>
|
||||||
|
<div class="form-row"><label for="importPosition">位置</label><select id="importPosition"><option value="before">页前</option><option value="after" selected>页后</option><option value="first">首部</option><option value="last">末尾</option></select></div>
|
||||||
|
<div class="form-actions"><div id="importStatus" class="form-status" role="status"></div><button id="importCancel" class="button" type="button">取消</button><button id="importSubmit" class="button" type="submit" disabled>插入</button></div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
<dialog id="paragraphPanel" class="form-dialog" aria-label="段落缩进">
|
||||||
|
<form id="paragraphForm">
|
||||||
|
<div class="form-row"><label for="firstLineIndent">首行</label><div class="form-unit"><input id="firstLineIndent" type="number" step="any" required autofocus><span>mm</span></div></div>
|
||||||
|
<div class="form-pair">
|
||||||
|
<div class="form-row"><label for="leftIndent">左侧</label><div class="form-unit"><input id="leftIndent" type="number" step="any" required><span>mm</span></div></div>
|
||||||
|
<div class="form-row"><label for="rightIndent">右侧</label><div class="form-unit"><input id="rightIndent" type="number" step="any" required><span>mm</span></div></div>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions"><div id="paragraphStatus" class="form-status" role="status"></div><button id="paragraphCancel" class="button" type="button">取消</button><button class="button" type="submit">确定</button></div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
<dialog id="pagePanel" class="form-dialog" aria-label="页面设置">
|
<dialog id="pagePanel" class="form-dialog" aria-label="页面设置">
|
||||||
<form id="pageForm">
|
<form id="pageForm">
|
||||||
<div class="form-row">
|
<div class="form-row">
|
||||||
|
|||||||
+22
-1
@@ -334,7 +334,8 @@ a:focus-visible {
|
|||||||
border-right: 0;
|
border-right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-group > span {
|
.editor-group > span,
|
||||||
|
.editor-group-action {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
grid-row: 3;
|
grid-row: 3;
|
||||||
align-self: end;
|
align-self: end;
|
||||||
@@ -343,6 +344,26 @@ a:focus-visible {
|
|||||||
color: var(--text-muted);
|
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 {
|
.app-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
+160
-23
@@ -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";
|
import { FontManager, FontPicker } from "./ofdgo_font.js";
|
||||||
|
|
||||||
const MM_TO_PX = 96 / 25.4;
|
const MM_TO_PX = 96 / 25.4;
|
||||||
@@ -38,6 +38,7 @@ const state = {
|
|||||||
editorInfo: null,
|
editorInfo: null,
|
||||||
savedRevision: null,
|
savedRevision: null,
|
||||||
insertObject: null,
|
insertObject: null,
|
||||||
|
importPageCount: 0,
|
||||||
objectClipboard: null,
|
objectClipboard: null,
|
||||||
textFonts: [],
|
textFonts: [],
|
||||||
textFontID: null,
|
textFontID: null,
|
||||||
@@ -114,6 +115,24 @@ const el = {
|
|||||||
textFontToggle: document.querySelector("#textFontToggle"),
|
textFontToggle: document.querySelector("#textFontToggle"),
|
||||||
textFontList: document.querySelector("#textFontList"),
|
textFontList: document.querySelector("#textFontList"),
|
||||||
textFontAdd: document.querySelector("#textFontAdd"),
|
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"),
|
textSize: document.querySelector("#textSize"),
|
||||||
textColor: document.querySelector("#textColor"),
|
textColor: document.querySelector("#textColor"),
|
||||||
drawLineButton: document.querySelector("#drawLineButton"),
|
drawLineButton: document.querySelector("#drawLineButton"),
|
||||||
@@ -270,7 +289,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
|||||||
onTransform: (item, change) => changeDocument(item.items ? "ofdgoTransformObjects" : "ofdgoTransformObject", item,
|
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),
|
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),
|
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),
|
onCrop: (item, box) => changeDocument("ofdgoCropImage", item, box.x, box.y, box.width, box.height),
|
||||||
onCropChange: () => {
|
onCropChange: () => {
|
||||||
updatePendingChanges();
|
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),
|
box.x, box.y, box.width, box.height, style.fill, style.fillColor, style.stroke, style.strokeColor, style.lineWidth),
|
||||||
onDrawText: beginCanvasText,
|
onDrawText: beginCanvasText,
|
||||||
onCommitText: (item, value, fontData) => item.draft
|
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),
|
: changeDocument("ofdgoUpdateText", item, value, fontData || null, item.size, null),
|
||||||
onTextChange: () => {
|
onTextChange: () => {
|
||||||
const editing = canvasEditor.input;
|
const editing = canvasEditor.input;
|
||||||
@@ -357,6 +376,28 @@ el.textColor.addEventListener("change", () => changeTextStyle(true));
|
|||||||
editorClick(el.textWrap, () => changeParagraph(!currentTextStyle().wrap));
|
editorClick(el.textWrap, () => changeParagraph(!currentTextStyle().wrap));
|
||||||
el.textAlign.addEventListener("change", () => changeParagraph());
|
el.textAlign.addEventListener("change", () => changeParagraph());
|
||||||
el.textLineHeight.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.textSpacing.addEventListener("change", () => changeParagraph());
|
||||||
el.shapeWidth.addEventListener("focus", () => { el.shapeWidth.defaultValue = el.shapeWidth.value; });
|
el.shapeWidth.addEventListener("focus", () => { el.shapeWidth.defaultValue = el.shapeWidth.value; });
|
||||||
for (const input of [el.textSize, el.shapeWidth, el.textLineHeight, el.textSpacing]) {
|
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]) {
|
for (const input of [el.shapeFill, el.shapeFillColor, el.shapeStroke, el.shapeStrokeColor, el.shapeWidth]) {
|
||||||
input.addEventListener("change", changeShapeStyle);
|
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();
|
const page = currentPageInfo();
|
||||||
return changeDocument("ofdgoChangePage", null, "add", state.pageIndex, page.width, page.height);
|
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.copyPageButton, () => changeDocument("ofdgoChangePage", null, "copy", state.pageIndex));
|
||||||
editorClick(el.deletePageButton, () => changeDocument("ofdgoChangePage", null, "delete", state.pageIndex));
|
editorClick(el.deletePageButton, () => changeDocument("ofdgoChangePage", null, "delete", state.pageIndex));
|
||||||
editorClick(el.movePagePrevButton, () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex - 1));
|
editorClick(el.movePagePrevButton, () => changeDocument("ofdgoChangePage", null, "move", state.pageIndex, state.pageIndex - 1));
|
||||||
@@ -712,7 +779,45 @@ function handleKeyDown(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formDialogOpen() {
|
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() {
|
function openPagePanel() {
|
||||||
@@ -943,7 +1048,11 @@ function openInsertPanel(item = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function currentTextStyle() {
|
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() {
|
async function toggleTextTool() {
|
||||||
@@ -958,10 +1067,11 @@ async function toggleTextTool() {
|
|||||||
updateTextFonts(canvasEditor.selected, true);
|
updateTextFonts(canvasEditor.selected, true);
|
||||||
}
|
}
|
||||||
if (openSeq !== state.openSeq) return;
|
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)];
|
const font = state.textFonts[Number(fontPicker.value)];
|
||||||
state.textDefaults = { type: "TextObject", size: style.size, color: style.color, wrap: style.wrap,
|
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,
|
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 };
|
fontChoice: font && !font.disabled ? font : state.textDefaults.fontChoice };
|
||||||
state.selectObjects = true;
|
state.selectObjects = true;
|
||||||
setPan(false);
|
setPan(false);
|
||||||
@@ -1045,22 +1155,25 @@ async function readTextFont(font) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateTextFonts(item, refresh = false) {
|
function updateTextFonts(item, refresh = false) {
|
||||||
|
item = selectedText(item);
|
||||||
const id = item?.type === "TextObject" && !item.draft ? item.font : null;
|
const id = item?.type === "TextObject" && !item.draft ? item.font : null;
|
||||||
if (refresh || state.textFontID !== id) {
|
const key = item?.items ? `selection:${item.items.map(member => member.id).join(",")}:${id || ""}` : id;
|
||||||
state.textFontID = id;
|
if (refresh || state.textFontID !== key) {
|
||||||
|
state.textFontID = key;
|
||||||
state.textFonts = editorFonts(id ? item : null);
|
state.textFonts = editorFonts(id ? item : null);
|
||||||
const selected = canvasEditor.input?.fontChoice || (id ? state.textFonts[0] : state.textDefaults.fontChoice);
|
const selected = canvasEditor.input?.fontChoice || (id ? state.textFonts[0] : state.textDefaults.fontChoice);
|
||||||
if (selected && (canvasEditor.input || selected.embedded)
|
if (selected && (canvasEditor.input || selected.embedded)
|
||||||
&& !state.textFonts.some(font => (font.id || font.postscriptName) === (selected.id || selected.postscriptName))) {
|
&& !state.textFonts.some(font => (font.id || font.postscriptName) === (selected.id || selected.postscriptName))) {
|
||||||
state.textFonts.unshift(selected);
|
state.textFonts.unshift(selected);
|
||||||
}
|
}
|
||||||
fontPicker.setFonts(state.textFonts, selected || state.textFonts[0]);
|
fontPicker.setFonts(state.textFonts, item?.items && !id ? null : selected || state.textFonts[0]);
|
||||||
if (!id) state.textDefaults.fontChoice = state.textFonts[Number(fontPicker.value)];
|
el.textFont.placeholder = item?.items && !id ? "混合" : "";
|
||||||
|
if (!id && !item?.items) state.textDefaults.fontChoice = state.textFonts[Number(fontPicker.value)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function changeTextFont() {
|
async function changeTextFont() {
|
||||||
const item = canvasEditor.selected;
|
const item = selectedText(canvasEditor.selected);
|
||||||
const font = state.textFonts[Number(fontPicker.value)];
|
const font = state.textFonts[Number(fontPicker.value)];
|
||||||
if (!state.ready || document.body.hasAttribute("aria-busy") || !font || font.disabled) return;
|
if (!state.ready || document.body.hasAttribute("aria-busy") || !font || font.disabled) return;
|
||||||
const editing = canvasEditor.input;
|
const editing = canvasEditor.input;
|
||||||
@@ -1069,6 +1182,7 @@ async function changeTextFont() {
|
|||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const data = await readTextFont(font);
|
const data = await readTextFont(font);
|
||||||
|
await callWASM("ofdgoCheckTextFont", data, editing.input.value);
|
||||||
const face = new FontFace("ofdgo-edit-font", data);
|
const face = new FontFace("ofdgo-edit-font", data);
|
||||||
await face.load();
|
await face.load();
|
||||||
if (openSeq !== state.openSeq || canvasEditor.input !== editing) return;
|
if (openSeq !== state.openSeq || canvasEditor.input !== editing) return;
|
||||||
@@ -1084,8 +1198,8 @@ async function changeTextFont() {
|
|||||||
if (openSeq === state.openSeq) setBusy(false);
|
if (openSeq === state.openSeq) setBusy(false);
|
||||||
}
|
}
|
||||||
} else if (item?.type === "TextObject") {
|
} else if (item?.type === "TextObject") {
|
||||||
if (!font.embedded && canEditObject(item, "replaceFont")) {
|
if (canEditObject(item, "replaceFont") && (!font.embedded || item.font !== font.id.slice(9))) {
|
||||||
await changeDocument("ofdgoUpdateText", item, canEditObject(item, "layoutKnown") ? item.text : null, readTextFont(font), item.size, null);
|
await changeDocument("ofdgoStyleText", { ...item, id: (item.items || [item]).map(member => member.id) }, readTextFont(font), 0, null);
|
||||||
updateTextFonts(canvasEditor.selected, true);
|
updateTextFonts(canvasEditor.selected, true);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -1174,7 +1288,7 @@ async function changeParagraph(wrap = currentTextStyle().wrap) {
|
|||||||
updateObjectControls(item, true);
|
updateObjectControls(item, true);
|
||||||
return;
|
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);
|
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 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;
|
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) {
|
if (item === state.textDefaults) {
|
||||||
item.size = size;
|
item.size = size;
|
||||||
if (fill !== null) item.color = fill;
|
if (fill !== null) item.color = fill;
|
||||||
@@ -1314,6 +1437,10 @@ async function changeDocument(name, item, ...args) {
|
|||||||
if (openSeq === state.openSeq) {
|
if (openSeq === state.openSeq) {
|
||||||
if (el.pagePanel.open) {
|
if (el.pagePanel.open) {
|
||||||
el.pageStatus.textContent = err.message;
|
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 {
|
} else {
|
||||||
showError(err, false);
|
showError(err, false);
|
||||||
}
|
}
|
||||||
@@ -1825,6 +1952,8 @@ async function openOFD(file) {
|
|||||||
el.createPanel.close();
|
el.createPanel.close();
|
||||||
el.insertPanel.close();
|
el.insertPanel.close();
|
||||||
el.pagePanel.close();
|
el.pagePanel.close();
|
||||||
|
el.paragraphPanel.close();
|
||||||
|
el.importPanel.close();
|
||||||
updateControls();
|
updateControls();
|
||||||
await openDocument({ pageIndex: 0, resetScroll: true, openSeq });
|
await openDocument({ pageIndex: 0, resetScroll: true, openSeq });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -3049,10 +3178,10 @@ async function pasteEditorContent(event) {
|
|||||||
el.textFontAdd.focus();
|
el.textFontAdd.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const style = currentTextStyle();
|
const style = canvasEditor.selected?.items ? state.textDefaults : currentTextStyle();
|
||||||
const data = readTextFont(font);
|
const data = readTextFont(font);
|
||||||
await changeDocument("ofdgoInsertText", null, index, value, data, x, y, style.size, style.color,
|
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) {
|
function copySelection(event) {
|
||||||
@@ -4512,13 +4641,15 @@ function updateObjectControls(item, reset = false) {
|
|||||||
el.editObjectButton.disabled = disabled || Boolean(item.items) || item.type === "PathObject"
|
el.editObjectButton.disabled = disabled || Boolean(item.items) || item.type === "PathObject"
|
||||||
|| !canEditObject(item, item.type === "TextObject" ? "reflow" : "update");
|
|| !canEditObject(item, item.type === "TextObject" ? "reflow" : "update");
|
||||||
el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting;
|
el.multiSelectButton.disabled = !state.editing || !canvasEditor.enabled || !state.ready || state.exporting;
|
||||||
const text = item?.type === "TextObject" ? item : state.textDefaults;
|
const selection = selectedText(item);
|
||||||
const textDisabled = !state.editing || !state.ready || state.exporting || Boolean(item?.items);
|
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"));
|
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.textColor.disabled = textDisabled || Boolean(item?.draft) || text !== state.textDefaults && !canEditObject(item, "update");
|
||||||
el.textFontAdd.disabled = !state.editing || !state.ready || state.exporting;
|
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");
|
const layoutKnown = text === state.textDefaults || Boolean(item?.draft) || canEditObject(item, "layoutKnown");
|
||||||
el.textAlign.value = layoutKnown ? text.align || "left" : "";
|
el.textAlign.value = layoutKnown ? text.align || "left" : "";
|
||||||
el.textLineHeight.placeholder = layoutKnown ? "自动" : "原文";
|
el.textLineHeight.placeholder = layoutKnown ? "自动" : "原文";
|
||||||
@@ -4532,10 +4663,13 @@ function updateObjectControls(item, reset = false) {
|
|||||||
}
|
}
|
||||||
updateTextFonts(item);
|
updateTextFonts(item);
|
||||||
if (reset || el.textSize.disabled || document.activeElement !== el.textSize) {
|
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.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) {
|
if (reset || el.textColor.disabled || document.activeElement !== el.textColor) {
|
||||||
el.textColor.value = text.color || "#000000";
|
el.textColor.value = text.color || "#000000";
|
||||||
}
|
}
|
||||||
@@ -4589,6 +4723,7 @@ async function callWASM(name, ...args) {
|
|||||||
args[0] = args[0].slice();
|
args[0] = args[0].slice();
|
||||||
transfer.push(args[0].buffer);
|
transfer.push(args[0].buffer);
|
||||||
}
|
}
|
||||||
|
if (name === "ofdgoLoadImport" && args[0]) transfer.push(args[0].buffer);
|
||||||
wasmWorker.postMessage({ id, name, args }, transfer);
|
wasmWorker.postMessage({ id, name, args }, transfer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
wasmRequests.delete(id);
|
wasmRequests.delete(id);
|
||||||
@@ -4610,6 +4745,8 @@ function setBusy(busy, text = "", percent = 0, status = "") {
|
|||||||
el.createForm.inert = busy;
|
el.createForm.inert = busy;
|
||||||
el.insertForm.inert = busy;
|
el.insertForm.inert = busy;
|
||||||
el.pageForm.inert = busy;
|
el.pageForm.inert = busy;
|
||||||
|
el.paragraphForm.inert = busy;
|
||||||
|
el.importForm.inert = busy;
|
||||||
el.editorTools.inert = busy;
|
el.editorTools.inert = busy;
|
||||||
el.fontList.inert = busy;
|
el.fontList.inert = busy;
|
||||||
if (!busy) {
|
if (!busy) {
|
||||||
|
|||||||
@@ -131,22 +131,29 @@ export function missingGlyphMessage(diagnostic) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function objectEditReason(item) {
|
export function objectEditReason(item) {
|
||||||
|
const labels = { fontUnavailable: "字体不可用", unsupportedColor: "颜色样式暂不支持", unsupportedStyle: "绘制参数暂不支持",
|
||||||
|
unsupportedContainer: "容器特性暂不支持", invalidObject: "对象数据异常" };
|
||||||
const reasons = (item?.items || (item ? [item] : [])).map(object => {
|
const reasons = (item?.items || (item ? [item] : [])).map(object => {
|
||||||
const reason = object.capabilities?.reason || "";
|
const reason = object.capabilities?.reason || "";
|
||||||
if (!reason) return "";
|
if (!reason) return "";
|
||||||
let message = "对象特性暂不支持";
|
let message = labels[object.capabilities.reasonCode] || "对象特性暂不支持";
|
||||||
if (object.capabilities.missingGlyphs) message = missingGlyphMessage(object.capabilities.missingGlyphs);
|
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 `${object.capabilities.transform ? "部分操作受限" : "暂不可编辑"}:${message}`;
|
||||||
});
|
});
|
||||||
return [...new Set(reasons.filter(Boolean))].join(";");
|
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 {
|
export class CanvasEditor {
|
||||||
constructor(viewer, options) {
|
constructor(viewer, options) {
|
||||||
this.viewer = viewer;
|
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,
|
lineHeight: item.paragraphHeight || item.lineHeight ? `${(item.paragraphHeight || item.lineHeight) * PX_PER_MM}px` : "normal", color: item.color,
|
||||||
textAlign: item.align || "left",
|
textAlign: item.align || "left",
|
||||||
letterSpacing: `${(item.letterSpacing || 0) * PX_PER_MM}px`,
|
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) {
|
if (item.textFrame) {
|
||||||
const { width, height, matrix: [a, b, c, d, e, f] } = item.textFrame;
|
const { width, height, matrix: [a, b, c, d, e, f] } = item.textFrame;
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ export class FontPicker {
|
|||||||
this.names.set(name, (this.names.get(name) || 0) + 1);
|
this.names.set(name, (this.names.get(name) || 0) + 1);
|
||||||
}
|
}
|
||||||
const key = font => font?.id || font?.postscriptName;
|
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();
|
this.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+166
-6
@@ -28,6 +28,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"syscall/js"
|
"syscall/js"
|
||||||
|
|
||||||
"github.com/xiaoqidun/ofdgo"
|
"github.com/xiaoqidun/ofdgo"
|
||||||
@@ -76,12 +77,16 @@ func awaitExport(fn js.Value, args ...any) error {
|
|||||||
// currentSession 当前WebUI文档会话
|
// currentSession 当前WebUI文档会话
|
||||||
var currentSession *Session
|
var currentSession *Session
|
||||||
|
|
||||||
|
// pendingImport 待插页文档,仅保留文件数据与索引,不创建渲染会话
|
||||||
|
var pendingImport *ofdgo.Reader
|
||||||
|
|
||||||
// apiResult 浏览器接口返回结果
|
// apiResult 浏览器接口返回结果
|
||||||
type apiResult struct {
|
type apiResult struct {
|
||||||
OK bool `json:"ok"`
|
OK bool `json:"ok"`
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
Data any `json:"data,omitempty"`
|
Data any `json:"data,omitempty"`
|
||||||
MissingGlyphs *ofdgo.MissingGlyphError `json:"missingGlyphs,omitempty"`
|
MissingGlyphs *ofdgo.MissingGlyphError `json:"missingGlyphs,omitempty"`
|
||||||
|
ReasonCode ofdgo.EditReason `json:"reasonCode,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunWASM 注册浏览器WASM接口并阻塞运行
|
// RunWASM 注册浏览器WASM接口并阻塞运行
|
||||||
@@ -104,8 +109,12 @@ func RunWASM() {
|
|||||||
registerCallback("ofdgoCreateDocument", createDocument)
|
registerCallback("ofdgoCreateDocument", createDocument)
|
||||||
registerCallback("ofdgoEditDocument", editDocument)
|
registerCallback("ofdgoEditDocument", editDocument)
|
||||||
registerCallback("ofdgoChangePage", changePage)
|
registerCallback("ofdgoChangePage", changePage)
|
||||||
|
registerCallback("ofdgoLoadImport", loadImport)
|
||||||
|
registerCallback("ofdgoImportPages", importPages)
|
||||||
registerCallback("ofdgoInsertText", insertText)
|
registerCallback("ofdgoInsertText", insertText)
|
||||||
registerCallback("ofdgoUpdateText", updateText)
|
registerCallback("ofdgoUpdateText", updateText)
|
||||||
|
registerCallback("ofdgoStyleText", styleText)
|
||||||
|
registerCallback("ofdgoCheckTextFont", checkTextFont)
|
||||||
registerCallback("ofdgoInsertImage", insertImage)
|
registerCallback("ofdgoInsertImage", insertImage)
|
||||||
registerCallback("ofdgoInsertShape", insertShape)
|
registerCallback("ofdgoInsertShape", insertShape)
|
||||||
registerCallback("ofdgoUpdatePathStyle", updatePathStyle)
|
registerCallback("ofdgoUpdatePathStyle", updatePathStyle)
|
||||||
@@ -170,7 +179,13 @@ func callbackResult(fn func([]js.Value) (any, error), args []js.Value) any {
|
|||||||
data, err := safeCall(fn, args)
|
data, err := safeCall(fn, args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
result := apiResult{Error: err.Error()}
|
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)
|
return encodeResult(result)
|
||||||
}
|
}
|
||||||
if result, ok := data.(js.Value); ok {
|
if result, ok := data.(js.Value); ok {
|
||||||
@@ -207,6 +222,7 @@ func openDocument(args []js.Value) (any, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
renderAnnotations := args[2].Bool()
|
renderAnnotations := args[2].Bool()
|
||||||
|
clearImport()
|
||||||
currentEditor = nil
|
currentEditor = nil
|
||||||
copiedObjects = nil
|
copiedObjects = nil
|
||||||
if currentSession != nil {
|
if currentSession != nil {
|
||||||
@@ -701,7 +717,7 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
|
|||||||
return nil, err
|
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,
|
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 {
|
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)["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["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["wrap"], item["align"], item["paragraphHeight"] = layout.Wrap, layout.Align, layout.LineHeight
|
||||||
item["letterSpacing"] = layout.LetterSpacing
|
item["letterSpacing"] = layout.LetterSpacing
|
||||||
|
item["leftIndent"], item["rightIndent"], item["firstLineIndent"] = layout.LeftIndent, layout.RightIndent, layout.FirstLineIndent
|
||||||
if capability.Reflow {
|
if capability.Reflow {
|
||||||
frame, err := object.TextObject.TextFrame()
|
frame, err := object.TextObject.TextFrame()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -992,10 +1009,79 @@ func changePage(args []js.Value) (any, error) {
|
|||||||
return editorPageInfo{info, index}, nil
|
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 新建单页文档
|
// createDocument 新建单页文档
|
||||||
// 入参: args 标题、纸张宽高和注解设置
|
// 入参: args 标题、纸张宽高和注解设置
|
||||||
// 返回: any 文档信息, error 错误信息
|
// 返回: any 文档信息, error 错误信息
|
||||||
func createDocument(args []js.Value) (any, error) {
|
func createDocument(args []js.Value) (any, error) {
|
||||||
|
clearImport()
|
||||||
editor := ofdgo.NewEditor()
|
editor := ofdgo.NewEditor()
|
||||||
editor.Info.Title = args[0].String()
|
editor.Info.Title = args[0].String()
|
||||||
if _, err := editor.AddPage(args[1].Float(), args[2].Float()); err != nil {
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
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)
|
object.TextObject.Font, err = currentEditor.AddFont(FontFile{Data: data}, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1097,6 +1190,73 @@ func updateText(args []js.Value) (any, error) {
|
|||||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
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色值写入文字对象,保留颜色透明度
|
// setTextColor 将浏览器RGB色值写入文字对象,保留颜色透明度
|
||||||
// 入参: object 文字对象, value 十六进制色值
|
// 入参: object 文字对象, value 十六进制色值
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
@@ -1223,7 +1383,7 @@ func updatePathStyle(args []js.Value) (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// insertText 使用选定字体创建文字对象
|
// insertText 使用选定字体创建文字对象
|
||||||
// 入参: args 页码、内容、字体数据、横纵坐标、字号、颜色、框宽、折行、对齐、行距和字距
|
// 入参: args 页码、内容、字体数据、横纵坐标、字号、颜色、框宽、折行、对齐、行距、字距及左右和首行缩进
|
||||||
// 返回: any 文档信息, error 错误信息
|
// 返回: any 文档信息, error 错误信息
|
||||||
func insertText(args []js.Value) (any, error) {
|
func insertText(args []js.Value) (any, error) {
|
||||||
if currentEditor == nil {
|
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),
|
Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H),
|
||||||
Font: fontID, Size: args[5].Float(),
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := setTextColor(&object.TextObject, args[6].String()); err != nil {
|
if err := setTextColor(&object.TextObject, args[6].String()); err != nil {
|
||||||
@@ -1269,7 +1429,7 @@ func insertText(args []js.Value) (any, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// layoutText 调整文字框和段落排版,保留软换行前的原文
|
// layoutText 调整文字框和段落排版,保留软换行前的原文
|
||||||
// 入参: args 页码、对象标识、本地左侧偏移、宽度、折行、对齐、行距和字距
|
// 入参: args 页码、对象标识、本地左侧偏移、宽度、折行、对齐、行距、字距及左右和首行缩进
|
||||||
// 返回: any 文档信息, error 错误信息
|
// 返回: any 文档信息, error 错误信息
|
||||||
func layoutText(args []js.Value) (any, error) {
|
func layoutText(args []js.Value) (any, error) {
|
||||||
if currentEditor == nil {
|
if currentEditor == nil {
|
||||||
@@ -1290,7 +1450,7 @@ func layoutText(args []js.Value) (any, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
value, _ := object.TextObject.TextLayout()
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
revision := currentEditor.Revision()
|
revision := currentEditor.Revision()
|
||||||
|
|||||||
+25
-8
@@ -606,7 +606,7 @@ func (e *Editor) prepareObject(id string, object GraphicObject) (GraphicObject,
|
|||||||
}
|
}
|
||||||
for _, color := range []*FillColor{fill, stroke} {
|
for _, color := range []*FillColor{fill, stroke} {
|
||||||
if err := creationColor(color); err != nil {
|
if err := creationColor(color); err != nil {
|
||||||
return GraphicObject{}, err
|
return GraphicObject{}, &EditError{Code: EditUnsupportedColor, Err: err}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if join != "" && join != "Miter" && join != "Round" && join != "Bevel" {
|
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 := *obj.layout
|
||||||
layout.options.LineHeight *= scale
|
layout.options.LineHeight *= scale
|
||||||
layout.options.LetterSpacing *= scale
|
layout.options.LetterSpacing *= scale
|
||||||
|
layout.options.LeftIndent *= scale
|
||||||
|
layout.options.RightIndent *= scale
|
||||||
|
layout.options.FirstLineIndent *= scale
|
||||||
obj.layout = &layout
|
obj.layout = &layout
|
||||||
}
|
}
|
||||||
if obj.LineWidth == 0 && obj.Stroke != nil && *obj.Stroke {
|
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)
|
sfnt, err := e.editorFont(obj.Font)
|
||||||
if err != nil {
|
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 {
|
if !finite(obj.Size) || obj.Size <= 0 || !finite(obj.HScale) || obj.HScale < 0 {
|
||||||
return fmt.Errorf("invalid text dimensions")
|
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) {
|
if !finite(options.LetterSpacing) {
|
||||||
return fmt.Errorf("letter spacing must be finite")
|
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) {
|
if !slices.Contains([]string{"", "left", "center", "right", "justify"}, options.Align) {
|
||||||
return fmt.Errorf("invalid text alignment %q", options.Align)
|
return fmt.Errorf("invalid text alignment %q", options.Align)
|
||||||
}
|
}
|
||||||
var width float64
|
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()
|
box, err := obj.TextFrame()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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
|
hScale := obj.HScale
|
||||||
if hScale == 0 {
|
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")
|
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 {
|
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
|
y := float64(ascender)*unit + float64(len(codes))*lineHeight
|
||||||
if !finite(x) || !finite(y) {
|
if !finite(x) || !finite(y) {
|
||||||
return fmt.Errorf("text position exceeds finite range")
|
return fmt.Errorf("text position exceeds finite range")
|
||||||
@@ -905,14 +919,17 @@ func (e *Editor) LayoutText(obj *TextObject, value string, options TextLayout) e
|
|||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) prepareText(obj *TextObject) error {
|
func (e *Editor) prepareText(obj *TextObject) error {
|
||||||
sfnt, err := e.editorFont(obj.Font)
|
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")
|
return fmt.Errorf("text requires an embedded font and a positive finite size")
|
||||||
}
|
}
|
||||||
if len(obj.TextCode) == 0 {
|
if len(obj.TextCode) == 0 {
|
||||||
return fmt.Errorf("text codes are empty")
|
return fmt.Errorf("text codes are empty")
|
||||||
}
|
}
|
||||||
if obj.VScale != 0 || obj.Decoration != "" || len(obj.CGTransform) != 0 {
|
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 {
|
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")
|
return fmt.Errorf("invalid text dimensions")
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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(`<ofd:ColorSpace ID="` + m.defaultCS + `" Type="RGB" BitsPerComponent="8"/>`)
|
||||||
|
m.resources["defaultRGB"] = editorImportEntry{group: "ColorSpaces"}
|
||||||
|
}
|
||||||
|
refs := make([]Page, len(indexes))
|
||||||
|
ids := make([]string, len(indexes))
|
||||||
|
for i, index := range indexes {
|
||||||
|
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
|
||||||
|
}
|
||||||
+16
-12
@@ -45,7 +45,7 @@ type editorObjectOrigin struct {
|
|||||||
page *editorSourcePage
|
page *editorSourcePage
|
||||||
node *editorXML
|
node *editorXML
|
||||||
object GraphicObject
|
object GraphicObject
|
||||||
reason string
|
reason error
|
||||||
}
|
}
|
||||||
|
|
||||||
// editorSourcePage 保存页面原文、解析快照及图层和页块内的对象位置
|
// editorSourcePage 保存页面原文、解析快照及图层和页块内的对象位置
|
||||||
@@ -72,16 +72,18 @@ type ObjectCapabilities struct {
|
|||||||
Delete bool
|
Delete bool
|
||||||
Order bool
|
Order bool
|
||||||
Reason string
|
Reason string
|
||||||
|
ReasonCode EditReason
|
||||||
MissingGlyphs *MissingGlyphError
|
MissingGlyphs *MissingGlyphError
|
||||||
}
|
}
|
||||||
|
|
||||||
// editError 将操作受限原因转为错误,保留可供调用方识别的缺字诊断
|
// editError 将操作受限原因转为错误,保留可供调用方识别的缺字诊断
|
||||||
// 返回: error 受限原因
|
// 返回: error 受限原因
|
||||||
func (c ObjectCapabilities) editError() error {
|
func (c ObjectCapabilities) editError() error {
|
||||||
|
var err error = errors.New(c.Reason)
|
||||||
if c.MissingGlyphs != nil {
|
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对象;新增空白页面始终可用
|
// PageCapabilities 页面可执行的操作,Insert表示可以添加RGB对象;新增空白页面始终可用
|
||||||
@@ -309,7 +311,7 @@ func (e *Editor) loadSourcePage(index int) error {
|
|||||||
if editorXMLSupported(node) {
|
if editorXMLSupported(node) {
|
||||||
resolved, err := e.resolveEditorStyle(object, layer.DrawParam)
|
resolved, err := e.resolveEditorStyle(object, layer.DrawParam)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
origin.reason = err.Error()
|
origin.reason = err
|
||||||
} else {
|
} else {
|
||||||
layer.Objects[j] = resolved
|
layer.Objects[j] = resolved
|
||||||
}
|
}
|
||||||
@@ -343,10 +345,10 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er
|
|||||||
return all, nil
|
return all, nil
|
||||||
}
|
}
|
||||||
if !editorXMLSupported(node) {
|
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 != "" {
|
if origin := e.objectOrigin(id); origin.reason != nil {
|
||||||
return ObjectCapabilities{Reason: 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 {
|
for parent := node.parent; parent.name.Local != "Content"; parent = parent.parent {
|
||||||
allowed := "ID"
|
allowed := "ID"
|
||||||
@@ -354,21 +356,23 @@ func (e *Editor) ObjectCapabilities(page int, id string) (ObjectCapabilities, er
|
|||||||
allowed += " Type DrawParam"
|
allowed += " Type DrawParam"
|
||||||
}
|
}
|
||||||
if !editorXMLAttributes(parent, allowed) {
|
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 {
|
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)
|
all.Order = editorContainerOrderable(node.parent)
|
||||||
if _, err := e.prepareObject(id, object); err != nil {
|
if _, err := e.prepareObject(id, object); err != nil {
|
||||||
all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, err.Error()
|
all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, err.Error()
|
||||||
|
all.ReasonCode = editReason(err)
|
||||||
errors.As(err, &all.MissingGlyphs)
|
errors.As(err, &all.MissingGlyphs)
|
||||||
}
|
}
|
||||||
if !e.sourceRGB() {
|
if !e.sourceRGB() {
|
||||||
all.ReplaceFont = false
|
all.ReplaceFont = false
|
||||||
all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, "document uses a non-RGB default color space"
|
all.Update, all.Reflow, all.Copy, all.Reason = false, false, false, "document uses a non-RGB default color space"
|
||||||
all.MissingGlyphs = nil
|
all.MissingGlyphs = nil
|
||||||
|
all.ReasonCode = EditUnsupportedColor
|
||||||
}
|
}
|
||||||
if object.Type == "TextObject" {
|
if object.Type == "TextObject" {
|
||||||
all.Arrange = all.Update
|
all.Arrange = all.Update
|
||||||
@@ -426,16 +430,16 @@ func (e *Editor) editorFont(id string) (*font.SFNT, error) {
|
|||||||
if e.source != nil {
|
if e.source != nil {
|
||||||
data, err := e.source.reader.FontData(id)
|
data, err := e.source.reader.FontData(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, &EditError{Code: EditFontUnavailable, Err: err}
|
||||||
}
|
}
|
||||||
sfnt, err := font.ParseSFNT(data, 0)
|
sfnt, err := font.ParseSFNT(data, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, &EditError{Code: EditFontUnavailable, Err: err}
|
||||||
}
|
}
|
||||||
e.fonts[id] = sfnt
|
e.fonts[id] = sfnt
|
||||||
return sfnt, nil
|
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 按需读取原图片尺寸,不解码完整像素
|
// editorImage 按需读取原图片尺寸,不解码完整像素
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ func (e *Editor) editorDrawParam(id string, visited map[string]bool) (*DrawParam
|
|||||||
}
|
}
|
||||||
dp := e.source.reader.drawParamCache[id]
|
dp := e.source.reader.drawParamCache[id]
|
||||||
if dp == nil || visited[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
|
visited[id] = true
|
||||||
base, err := e.editorDrawParam(dp.Relative, visited)
|
base, err := e.editorDrawParam(dp.Relative, visited)
|
||||||
@@ -72,7 +72,7 @@ func (e *Editor) resolveEditorStyle(object GraphicObject, layer string) (Graphic
|
|||||||
} else {
|
} else {
|
||||||
obj := &object.TextObject
|
obj := &object.TextObject
|
||||||
if style.Cap != "" && style.Cap != "Butt" || style.DashPattern != "" || style.DashOffset != nil && *style.DashOffset != 0 {
|
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,
|
style = mergeDrawParam(*style, &DrawParam{LineWidth: obj.LineWidth, Join: obj.Join,
|
||||||
MiterLimit: obj.MiterLimit, FillColor: obj.FillColor, StrokeColor: obj.StrokeColor})
|
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)} {
|
for _, color := range []*FillColor{style.FillColor, (*FillColor)(style.StrokeColor)} {
|
||||||
if err := creationColor(color); err != nil {
|
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)
|
return cloneEditorObject(object)
|
||||||
|
|||||||
+69
-8
@@ -58,12 +58,69 @@ func missingGlyphError(id string, characters []rune) error {
|
|||||||
|
|
||||||
// TextLayout 本地横向段落选项,Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify
|
// TextLayout 本地横向段落选项,Wrap按CTM变换前的边界宽度折行,Align为left、center、right或justify
|
||||||
// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负
|
// LineHeight为毫米单位的基线间距,0使用字体度量;LetterSpacing为字素间的附加毫米间距,可为负
|
||||||
|
// LeftIndent和RightIndent为左右缩进,FirstLineIndent为每段首行相对左缩进的偏移,单位为毫米
|
||||||
// 零值保持显式换行和左对齐
|
// 零值保持显式换行和左对齐
|
||||||
type TextLayout struct {
|
type TextLayout struct {
|
||||||
Wrap bool
|
Wrap bool
|
||||||
Align string
|
Align string
|
||||||
LineHeight float64
|
LineHeight float64
|
||||||
LetterSpacing 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,也不参与渲染
|
// textLayout 保存编辑中的原文与选项,不写入OFD,也不参与渲染
|
||||||
@@ -83,9 +140,9 @@ func (obj TextObject) TextLayout() (string, TextLayout) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// breakTextLines 优先使用Unicode断行机会,过长词仅在字素边界折行,不丢弃空白
|
// breakTextLines 优先使用Unicode断行机会,过长词仅在字素边界折行,不丢弃空白
|
||||||
// 入参: runes 段落字符, advances 含字距的字符步进, width 本地排版宽度, wrap 是否自动折行, spacing 附加字距
|
// 入参: runes 段落字符, advances 含字距的字符步进, width 本地排版宽度, wrap 是否自动折行, spacing 附加字距, indent 首行缩进
|
||||||
// 返回: [][2]int 各行的字符起止索引,左闭右开
|
// 返回: [][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 {
|
if !wrap || len(runes) == 0 {
|
||||||
return [][2]int{{0, len(runes)}}
|
return [][2]int{{0, len(runes)}}
|
||||||
}
|
}
|
||||||
@@ -107,6 +164,10 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool,
|
|||||||
}
|
}
|
||||||
var lines [][2]int
|
var lines [][2]int
|
||||||
for start, cursor := 0, 0; start < len(runes); {
|
for start, cursor := 0, 0; start < len(runes); {
|
||||||
|
available := width
|
||||||
|
if start == 0 {
|
||||||
|
available -= indent
|
||||||
|
}
|
||||||
end, legal, next := start, start, cursor
|
end, legal, next := start, start, cursor
|
||||||
for next < len(ends) {
|
for next < len(ends) {
|
||||||
candidate := ends[next]
|
candidate := ends[next]
|
||||||
@@ -118,7 +179,7 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool,
|
|||||||
if visible > start {
|
if visible > start {
|
||||||
length -= spacing
|
length -= spacing
|
||||||
}
|
}
|
||||||
if length > width && end > start {
|
if length > available && end > start {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
end = candidate
|
end = candidate
|
||||||
@@ -126,7 +187,7 @@ func breakTextLines(runes []rune, advances []float64, width float64, wrap bool,
|
|||||||
if breaks[end] {
|
if breaks[end] {
|
||||||
legal = end
|
legal = end
|
||||||
}
|
}
|
||||||
if length > width {
|
if length > available {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"encoding/binary"
|
"encoding/binary"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/tdewolff/font"
|
"github.com/tdewolff/font"
|
||||||
)
|
)
|
||||||
@@ -96,6 +97,35 @@ func (f FontFile) Face(index int) ([]byte, error) {
|
|||||||
return bytes.Clone(data), nil
|
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 校验字体或集合头并读取字体数量
|
// fontFileCount 校验字体或集合头并读取字体数量
|
||||||
// 入参: data OpenType数据
|
// 入参: data OpenType数据
|
||||||
// 返回: int 字体数量, error 格式错误
|
// 返回: int 字体数量, error 格式错误
|
||||||
|
|||||||
Reference in new issue
Block a user