mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 15:06:46 +08:00
This commit is contained in:
1 parent
299ff8d002
commit
ee48ca666d
13 files changed
+448
-68
No files matched your search
+12
-1
@@ -206,6 +206,14 @@
|
|||||||
<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>
|
<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>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
<dialog id="infoPanel" class="form-dialog" aria-label="文档信息">
|
||||||
|
<form id="infoForm">
|
||||||
|
<label class="form-row"><span>标题</span><input id="infoTitle" type="text" autofocus></label>
|
||||||
|
<label class="form-row"><span>作者</span><input id="infoAuthor" type="text"></label>
|
||||||
|
<label class="form-row"><span>主题</span><input id="infoSubject" type="text"></label>
|
||||||
|
<div class="form-actions"><div id="infoStatus" class="form-status" role="status"></div><button id="infoCancel" 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">
|
||||||
@@ -320,7 +328,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<div class="panel-title">文档</div>
|
<div class="panel-row">
|
||||||
|
<div class="panel-title compact">文档</div>
|
||||||
|
<button id="infoButton" class="small-button" type="button" aria-label="编辑文档信息" aria-haspopup="dialog" aria-controls="infoPanel" hidden>修改</button>
|
||||||
|
</div>
|
||||||
<div id="editNotice" class="font-permission-hint" role="status" hidden></div>
|
<div id="editNotice" class="font-permission-hint" role="status" hidden></div>
|
||||||
<dl class="meta-list">
|
<dl class="meta-list">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+12
-2
@@ -225,6 +225,9 @@ a:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.font-picker #textFont {
|
.font-picker #textFont {
|
||||||
|
flex: 1 1 0;
|
||||||
|
width: 0;
|
||||||
|
text-overflow: ellipsis;
|
||||||
padding: 0 6px;
|
padding: 0 6px;
|
||||||
border: 0;
|
border: 0;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
@@ -263,6 +266,7 @@ a:focus-visible {
|
|||||||
color: #1f2428;
|
color: #1f2428;
|
||||||
box-shadow: 0 5px 18px rgba(20, 40, 50, 0.14);
|
box-shadow: 0 5px 18px rgba(20, 40, 50, 0.14);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1250,6 +1254,7 @@ button.outline-link:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-state[hidden],
|
.empty-state[hidden],
|
||||||
|
.panel-row > [hidden],
|
||||||
.meta-list > [hidden],
|
.meta-list > [hidden],
|
||||||
.navigation-tabs[hidden],
|
.navigation-tabs[hidden],
|
||||||
.form-row[hidden],
|
.form-row[hidden],
|
||||||
@@ -1387,7 +1392,7 @@ button.outline-link:hover {
|
|||||||
|
|
||||||
.edit-text {
|
.edit-text {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
margin: -2px;
|
margin: -2px;
|
||||||
padding: 1px;
|
padding: 1px;
|
||||||
@@ -1395,7 +1400,6 @@ button.outline-link:hover {
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
outline: none;
|
outline: none;
|
||||||
resize: none;
|
|
||||||
transform-origin: 0 0;
|
transform-origin: 0 0;
|
||||||
font-kerning: none;
|
font-kerning: none;
|
||||||
font-variant-ligatures: none;
|
font-variant-ligatures: none;
|
||||||
@@ -1404,6 +1408,12 @@ button.outline-link:hover {
|
|||||||
touch-action: auto;
|
touch-action: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-text div,
|
||||||
|
.edit-text p {
|
||||||
|
margin: 0;
|
||||||
|
text-indent: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.edit-text:focus-visible {
|
.edit-text:focus-visible {
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-12
@@ -1,4 +1,4 @@
|
|||||||
import { CanvasEditor, canEditObject, missingGlyphMessage, objectEditReason, selectedText } from "./ofdgo_edit.js";
|
import { CanvasEditor, canEditObject, editTextValue, 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;
|
||||||
@@ -116,6 +116,14 @@ const el = {
|
|||||||
textFontList: document.querySelector("#textFontList"),
|
textFontList: document.querySelector("#textFontList"),
|
||||||
textFontAdd: document.querySelector("#textFontAdd"),
|
textFontAdd: document.querySelector("#textFontAdd"),
|
||||||
paragraphButton: document.querySelector("#paragraphButton"),
|
paragraphButton: document.querySelector("#paragraphButton"),
|
||||||
|
infoButton: document.querySelector("#infoButton"),
|
||||||
|
infoPanel: document.querySelector("#infoPanel"),
|
||||||
|
infoForm: document.querySelector("#infoForm"),
|
||||||
|
infoTitle: document.querySelector("#infoTitle"),
|
||||||
|
infoAuthor: document.querySelector("#infoAuthor"),
|
||||||
|
infoSubject: document.querySelector("#infoSubject"),
|
||||||
|
infoCancel: document.querySelector("#infoCancel"),
|
||||||
|
infoStatus: document.querySelector("#infoStatus"),
|
||||||
paragraphPanel: document.querySelector("#paragraphPanel"),
|
paragraphPanel: document.querySelector("#paragraphPanel"),
|
||||||
paragraphForm: document.querySelector("#paragraphForm"),
|
paragraphForm: document.querySelector("#paragraphForm"),
|
||||||
paragraphCancel: document.querySelector("#paragraphCancel"),
|
paragraphCancel: document.querySelector("#paragraphCancel"),
|
||||||
@@ -329,7 +337,7 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
|||||||
function updatePendingChanges() {
|
function updatePendingChanges() {
|
||||||
const editing = canvasEditor.input;
|
const editing = canvasEditor.input;
|
||||||
setDirty(Boolean(state.editorInfo) && (state.editorInfo.revision !== state.savedRevision
|
setDirty(Boolean(state.editorInfo) && (state.editorInfo.revision !== state.savedRevision
|
||||||
|| Boolean(editing && (editing.input.value !== editing.item.text || editing.fontData)) || canvasEditor.cropChanged()));
|
|| Boolean(editing && (editTextValue(editing.input) !== editing.item.text || editing.fontData)) || canvasEditor.cropChanged()));
|
||||||
}
|
}
|
||||||
|
|
||||||
el.editorTools.addEventListener("pointerdown", (event) => {
|
el.editorTools.addEventListener("pointerdown", (event) => {
|
||||||
@@ -383,6 +391,16 @@ editorClick(el.paragraphButton, () => {
|
|||||||
el.paragraphPanel.showModal();
|
el.paragraphPanel.showModal();
|
||||||
});
|
});
|
||||||
el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close());
|
el.paragraphCancel.addEventListener("click", () => el.paragraphPanel.close());
|
||||||
|
editorClick(el.infoButton, () => {
|
||||||
|
for (const key of ["Title", "Author", "Subject"]) el[`info${key}`].value = state.doc[key.toLowerCase()] || "";
|
||||||
|
el.infoStatus.textContent = "";
|
||||||
|
el.infoPanel.showModal();
|
||||||
|
});
|
||||||
|
el.infoCancel.addEventListener("click", () => el.infoPanel.close());
|
||||||
|
el.infoForm.addEventListener("submit", async event => {
|
||||||
|
event.preventDefault();
|
||||||
|
if (await changeDocument("ofdgoUpdateInfo", null, el.infoTitle.value, el.infoAuthor.value, el.infoSubject.value)) el.infoPanel.close();
|
||||||
|
});
|
||||||
el.paragraphForm.addEventListener("submit", async event => {
|
el.paragraphForm.addEventListener("submit", async event => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (document.body.hasAttribute("aria-busy") || !el.paragraphForm.reportValidity()) return;
|
if (document.body.hasAttribute("aria-busy") || !el.paragraphForm.reportValidity()) return;
|
||||||
@@ -779,7 +797,7 @@ function handleKeyDown(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formDialogOpen() {
|
function formDialogOpen() {
|
||||||
return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open;
|
return el.exportPanel.open || el.createPanel.open || el.insertPanel.open || el.pagePanel.open || el.paragraphPanel.open || el.importPanel.open || el.infoPanel.open;
|
||||||
}
|
}
|
||||||
|
|
||||||
function openImportPanel() {
|
function openImportPanel() {
|
||||||
@@ -1182,7 +1200,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);
|
await callWASM("ofdgoCheckTextFont", data, editTextValue(editing.input));
|
||||||
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;
|
||||||
@@ -1408,9 +1426,13 @@ async function changeDocument(name, item, ...args) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
setEditorInfo(doc);
|
setEditorInfo(doc);
|
||||||
if (!item || name === "ofdgoDeleteObject" || name === "ofdgoDeleteObjects" || name === "ofdgoEraseObjects" || name === "ofdgoEraseObjectsPath") {
|
if (name === "ofdgoUpdateInfo") {
|
||||||
canvasEditor.clear();
|
Object.assign(state.doc, { title: doc.title, author: doc.author, subject: doc.subject });
|
||||||
|
renderMeta();
|
||||||
|
updateControls();
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
const clearSelection = !item || ["ofdgoDeleteObject", "ofdgoDeleteObjects", "ofdgoEraseObjects", "ofdgoEraseObjectsPath"].includes(name);
|
||||||
if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") {
|
if (name === "ofdgoCopyObjects" || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") {
|
||||||
state.selectObjects = true;
|
state.selectObjects = true;
|
||||||
canvasEditor.setTool("");
|
canvasEditor.setTool("");
|
||||||
@@ -1419,7 +1441,7 @@ async function changeDocument(name, item, ...args) {
|
|||||||
}
|
}
|
||||||
openSeq = ++state.openSeq;
|
openSeq = ++state.openSeq;
|
||||||
if (item || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") {
|
if (item || name === "ofdgoPasteObjects" || name === "ofdgoInsertShape" || name === "ofdgoInsertText" || name === "ofdgoInsertImage") {
|
||||||
await refreshEditorPage(doc, item ? item.index : args[0], openSeq);
|
await refreshEditorPage(doc, item ? item.index : args[0], openSeq, clearSelection);
|
||||||
} else {
|
} else {
|
||||||
const samePage = doc.pages.findIndex((page) => page.id === previous.id);
|
const samePage = doc.pages.findIndex((page) => page.id === previous.id);
|
||||||
const pageIndex = doc.pageIndex ?? (samePage < 0 ? Math.min(state.pageIndex, doc.pageCount - 1) : samePage);
|
const pageIndex = doc.pageIndex ?? (samePage < 0 ? Math.min(state.pageIndex, doc.pageCount - 1) : samePage);
|
||||||
@@ -1427,7 +1449,7 @@ async function changeDocument(name, item, ...args) {
|
|||||||
const keepScroll = doc.pageIndex === undefined && page.id === previous.id && pageIndex === state.pageIndex
|
const keepScroll = doc.pageIndex === undefined && page.id === previous.id && pageIndex === state.pageIndex
|
||||||
&& page.width === previous.width && page.height === previous.height;
|
&& page.width === previous.width && page.height === previous.height;
|
||||||
await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode: state.fitMode, scale: state.scale,
|
await openDocument({ doc, openSeq, skipAutoFonts: true, pageIndex, fitMode: state.fitMode, scale: state.scale,
|
||||||
keepPreview: true, previewScroll: keepScroll ? { scrollLeft, scrollTop } : null });
|
keepPreview: true, clearSelection, previewScroll: keepScroll ? { scrollLeft, scrollTop } : null });
|
||||||
}
|
}
|
||||||
if (openSeq === state.openSeq) {
|
if (openSeq === state.openSeq) {
|
||||||
if (!toolbarFocus) el.viewerPanel.focus({ preventScroll: true });
|
if (!toolbarFocus) el.viewerPanel.focus({ preventScroll: true });
|
||||||
@@ -1435,7 +1457,9 @@ async function changeDocument(name, item, ...args) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (openSeq === state.openSeq) {
|
if (openSeq === state.openSeq) {
|
||||||
if (el.pagePanel.open) {
|
if (el.infoPanel.open) {
|
||||||
|
el.infoStatus.textContent = err.message;
|
||||||
|
} else if (el.pagePanel.open) {
|
||||||
el.pageStatus.textContent = err.message;
|
el.pageStatus.textContent = err.message;
|
||||||
} else if (el.paragraphPanel.open) {
|
} else if (el.paragraphPanel.open) {
|
||||||
el.paragraphStatus.textContent = err.message;
|
el.paragraphStatus.textContent = err.message;
|
||||||
@@ -1453,7 +1477,7 @@ async function changeDocument(name, item, ...args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshEditorPage(doc, index, openSeq) {
|
async function refreshEditorPage(doc, index, openSeq, clearSelection = false) {
|
||||||
state.doc = doc;
|
state.doc = doc;
|
||||||
resetSearch();
|
resetSearch();
|
||||||
state.documentSelection = null;
|
state.documentSelection = null;
|
||||||
@@ -1463,6 +1487,11 @@ async function refreshEditorPage(doc, index, openSeq) {
|
|||||||
if (openSeq !== state.openSeq) {
|
if (openSeq !== state.openSeq) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (clearSelection) {
|
||||||
|
const pending = canvasEditor.pendingSelection;
|
||||||
|
canvasEditor.clear();
|
||||||
|
canvasEditor.pendingSelection = pending;
|
||||||
|
}
|
||||||
mountPageSVG(index, page, openSeq);
|
mountPageSVG(index, page, openSeq);
|
||||||
updateThumbnail(index, openSeq);
|
updateThumbnail(index, openSeq);
|
||||||
setStatus(pageStatus(state.pageIndex, doc.pageCount));
|
setStatus(pageStatus(state.pageIndex, doc.pageCount));
|
||||||
@@ -1953,6 +1982,7 @@ async function openOFD(file) {
|
|||||||
el.insertPanel.close();
|
el.insertPanel.close();
|
||||||
el.pagePanel.close();
|
el.pagePanel.close();
|
||||||
el.paragraphPanel.close();
|
el.paragraphPanel.close();
|
||||||
|
el.infoPanel.close();
|
||||||
el.importPanel.close();
|
el.importPanel.close();
|
||||||
updateControls();
|
updateControls();
|
||||||
await openDocument({ pageIndex: 0, resetScroll: true, openSeq });
|
await openDocument({ pageIndex: 0, resetScroll: true, openSeq });
|
||||||
@@ -2332,7 +2362,7 @@ async function openDocument(options = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const openSeq = options.openSeq || (state.openSeq += 1);
|
const openSeq = options.openSeq || (state.openSeq += 1);
|
||||||
const previewIDs = options.keepPreview ? [...state.visiblePages].map((index) => state.doc.pages[index].id) : [];
|
const previewIDs = options.keepPreview ? [...new Set([...state.visiblePages, ...state.visibleThumbnails])].map((index) => state.doc.pages[index].id) : [];
|
||||||
if (!state.ready || state.wasmExited) {
|
if (!state.ready || state.wasmExited) {
|
||||||
await ensureWASM();
|
await ensureWASM();
|
||||||
}
|
}
|
||||||
@@ -2405,9 +2435,10 @@ async function openDocument(options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (options.clearSelection) canvasEditor.clear();
|
||||||
resetPageFlow(options.keepPreview);
|
resetPageFlow(options.keepPreview);
|
||||||
renderPageList();
|
renderPageList();
|
||||||
if (options.resetScroll || el.outlineList.childElementCount === 0) {
|
if (options.keepPreview || options.resetScroll || el.outlineList.childElementCount === 0) {
|
||||||
renderOutlines();
|
renderOutlines();
|
||||||
}
|
}
|
||||||
renderMeta();
|
renderMeta();
|
||||||
@@ -2893,6 +2924,17 @@ async function processPageRenderQueue() {
|
|||||||
}
|
}
|
||||||
page.imageNames = page.images.map((image) => image.name);
|
page.imageNames = page.images.map((image) => image.name);
|
||||||
delete page.images;
|
delete page.images;
|
||||||
|
if (task.refresh && state.editing) {
|
||||||
|
await Promise.all(page.imageNames.map(name => {
|
||||||
|
const image = new Image();
|
||||||
|
image.src = state.svgImages.get(name).url;
|
||||||
|
return image.decode();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (task.openSeq !== state.openSeq) {
|
||||||
|
task.resolve(null);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
page.cacheBytes = (page.svg.length + page.text.length) * 2;
|
page.cacheBytes = (page.svg.length + page.text.length) * 2;
|
||||||
page.text = JSON.parse(page.text);
|
page.text = JSON.parse(page.text);
|
||||||
state.pageCache.set(task.index, page);
|
state.pageCache.set(task.index, page);
|
||||||
@@ -3613,6 +3655,17 @@ async function selectSearchMatch(index) {
|
|||||||
} else {
|
} else {
|
||||||
scrollToPage(match.page);
|
scrollToPage(match.page);
|
||||||
}
|
}
|
||||||
|
if (state.editing) {
|
||||||
|
const node = [...pageShell(match.page).querySelectorAll(".edit-object")].find(node => canvasEditor.nodes.get(node)?.id === match.id);
|
||||||
|
const item = node && canvasEditor.nodes.get(node);
|
||||||
|
if (item?.type === "TextObject" && canEditObject(item, "update")) {
|
||||||
|
state.selectObjects = true;
|
||||||
|
setPan(false);
|
||||||
|
canvasEditor.setTool("");
|
||||||
|
canvasEditor.select(item);
|
||||||
|
updateControls();
|
||||||
|
} else canvasEditor.select(null);
|
||||||
|
}
|
||||||
const button = el.searchResults.querySelector("[aria-current=true]");
|
const button = el.searchResults.querySelector("[aria-current=true]");
|
||||||
const row = button.getBoundingClientRect();
|
const row = button.getBoundingClientRect();
|
||||||
const panel = el.searchResults.getBoundingClientRect();
|
const panel = el.searchResults.getBoundingClientRect();
|
||||||
@@ -3621,6 +3674,7 @@ async function selectSearchMatch(index) {
|
|||||||
} else if (row.bottom > panel.bottom) {
|
} else if (row.bottom > panel.bottom) {
|
||||||
el.searchResults.scrollTop += row.bottom - panel.bottom;
|
el.searchResults.scrollTop += row.bottom - panel.bottom;
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSearchHighlights() {
|
function clearSearchHighlights() {
|
||||||
@@ -4605,6 +4659,7 @@ function updateEditorTools() {
|
|||||||
el.editButton.title = state.editing ? "阅读" : "编辑";
|
el.editButton.title = state.editing ? "阅读" : "编辑";
|
||||||
el.editButton.setAttribute("aria-label", el.editButton.title);
|
el.editButton.setAttribute("aria-label", el.editButton.title);
|
||||||
el.editNotice.textContent = state.editorInfo?.editWarnings?.join(";") || "";
|
el.editNotice.textContent = state.editorInfo?.editWarnings?.join(";") || "";
|
||||||
|
el.infoButton.hidden = !state.editing;
|
||||||
el.editNotice.hidden = !state.editing || !el.editNotice.textContent;
|
el.editNotice.hidden = !state.editing || !el.editNotice.textContent;
|
||||||
el.editorTools.hidden = !state.editing;
|
el.editorTools.hidden = !state.editing;
|
||||||
const pagesDisabled = !state.editing || !state.ready || state.exporting;
|
const pagesDisabled = !state.editing || !state.ready || state.exporting;
|
||||||
@@ -4746,6 +4801,7 @@ function setBusy(busy, text = "", percent = 0, status = "") {
|
|||||||
el.insertForm.inert = busy;
|
el.insertForm.inert = busy;
|
||||||
el.pageForm.inert = busy;
|
el.pageForm.inert = busy;
|
||||||
el.paragraphForm.inert = busy;
|
el.paragraphForm.inert = busy;
|
||||||
|
el.infoForm.inert = busy;
|
||||||
el.importForm.inert = busy;
|
el.importForm.inert = busy;
|
||||||
el.editorTools.inert = busy;
|
el.editorTools.inert = busy;
|
||||||
el.fontList.inert = busy;
|
el.fontList.inert = busy;
|
||||||
|
|||||||
+73
-37
@@ -1,5 +1,16 @@
|
|||||||
const PX_PER_MM = 96 / 25.4;
|
const PX_PER_MM = 96 / 25.4;
|
||||||
|
|
||||||
|
export function editTextValue(input) {
|
||||||
|
let text = "", previous;
|
||||||
|
for (const node of input.childNodes) {
|
||||||
|
const block = node.nodeName === "DIV" || node.nodeName === "P";
|
||||||
|
if (previous && (block || previous.nodeName === "DIV" || previous.nodeName === "P") && previous.nodeName !== "BR") text += "\n";
|
||||||
|
text += node.nodeType === 3 ? node.textContent : node.nodeName === "BR" ? (node.nextSibling ? "\n" : "") : editTextValue(node);
|
||||||
|
previous = node;
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
export function pagePoint(x, y, rect, page, rotation) {
|
export function pagePoint(x, y, rect, page, rotation) {
|
||||||
const u = (x - rect.left) / rect.width;
|
const u = (x - rect.left) / rect.width;
|
||||||
const v = (y - rect.top) / rect.height;
|
const v = (y - rect.top) / rect.height;
|
||||||
@@ -27,6 +38,21 @@ export function selectionBounds(items) {
|
|||||||
height: Math.max(...items.map(item => item.y + item.height)) - y };
|
height: Math.max(...items.map(item => item.y + item.height)) - y };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resizeCorner(node, event) {
|
||||||
|
let corner = event.target.dataset.corner || "", distance = Infinity;
|
||||||
|
if (!corner) return corner;
|
||||||
|
for (const handle of node.children) {
|
||||||
|
if (!handle.dataset.corner) continue;
|
||||||
|
const rect = handle.getBoundingClientRect();
|
||||||
|
const next = (event.clientX - rect.left - rect.width / 2) ** 2 + (event.clientY - rect.top - rect.height / 2) ** 2;
|
||||||
|
if (next < distance || next === distance && handle.dataset.corner === event.target.dataset.corner) {
|
||||||
|
corner = handle.dataset.corner;
|
||||||
|
distance = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return corner;
|
||||||
|
}
|
||||||
|
|
||||||
function alignmentSnap(box, targets, tolerance) {
|
function alignmentSnap(box, targets, tolerance) {
|
||||||
const lines = [];
|
const lines = [];
|
||||||
const offset = { x: 0, y: 0 };
|
const offset = { x: 0, y: 0 };
|
||||||
@@ -353,7 +379,7 @@ export class CanvasEditor {
|
|||||||
|
|
||||||
start(event) {
|
start(event) {
|
||||||
if (this.input) {
|
if (this.input) {
|
||||||
if (event.target !== this.input.input) this.commitText();
|
if (!this.input.input.contains(event.target)) this.commitText();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) {
|
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) {
|
||||||
@@ -367,7 +393,7 @@ export class CanvasEditor {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.viewer.focus({ preventScroll: true });
|
this.viewer.focus({ preventScroll: true });
|
||||||
const { item, box } = this.crop;
|
const { item, box } = this.crop;
|
||||||
this.drag = { crop: true, box: { ...box }, pointerID: event.pointerId, corner: event.target.dataset.corner || "",
|
this.drag = { crop: true, box: { ...box }, pointerID: event.pointerId, corner: resizeCorner(this.crop.node, event),
|
||||||
page: item.page, rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(), clientX: event.clientX, clientY: event.clientY };
|
page: item.page, rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(), clientX: event.clientX, clientY: event.clientY };
|
||||||
this.viewer.setPointerCapture(event.pointerId);
|
this.viewer.setPointerCapture(event.pointerId);
|
||||||
return;
|
return;
|
||||||
@@ -403,7 +429,7 @@ export class CanvasEditor {
|
|||||||
const item = this.selected;
|
const item = this.selected;
|
||||||
if (!canEditObject(item, "transform")) return;
|
if (!canEditObject(item, "transform")) return;
|
||||||
this.drag = {
|
this.drag = {
|
||||||
item, pointerID: event.pointerId, corner: !item.items || target === item ? event.target.dataset.corner || "" : "",
|
item, pointerID: event.pointerId, corner: !item.items || target === item ? resizeCorner(node, event) : "",
|
||||||
rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(),
|
rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(),
|
||||||
clientX: event.clientX, clientY: event.clientY,
|
clientX: event.clientX, clientY: event.clientY,
|
||||||
};
|
};
|
||||||
@@ -568,10 +594,12 @@ export class CanvasEditor {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.cancel();
|
|
||||||
if (drag.change && (drag.change.x || drag.change.y || drag.change.scale !== 1)) {
|
if (drag.change && (drag.change.x || drag.change.y || drag.change.scale !== 1)) {
|
||||||
this.options.onTransform(drag.item, drag.change);
|
this.drag = null;
|
||||||
}
|
drag.guides?.preview.remove();
|
||||||
|
this.viewer.releasePointerCapture(drag.pointerID);
|
||||||
|
Promise.resolve(this.options.onTransform(drag.item, drag.change)).finally(() => this.place(drag.item));
|
||||||
|
} else this.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
cancel() {
|
cancel() {
|
||||||
@@ -796,34 +824,47 @@ export class CanvasEditor {
|
|||||||
this.closeText();
|
this.closeText();
|
||||||
this.select(item);
|
this.select(item);
|
||||||
document.fonts.add(face);
|
document.fonts.add(face);
|
||||||
const input = document.createElement("textarea");
|
const input = document.createElement("div");
|
||||||
input.className = "edit-text";
|
input.className = "edit-text";
|
||||||
input.setAttribute("aria-label", "编辑文字");
|
input.setAttribute("aria-label", "编辑文字");
|
||||||
input.wrap = item.wrap ? "soft" : "off";
|
input.setAttribute("role", "textbox");
|
||||||
|
input.setAttribute("aria-multiline", "true");
|
||||||
|
input.contentEditable = "true";
|
||||||
input.spellcheck = false;
|
input.spellcheck = false;
|
||||||
input.value = item.text;
|
for (const text of item.text.split("\n")) {
|
||||||
|
const paragraph = document.createElement("div");
|
||||||
|
if (text) paragraph.textContent = text;
|
||||||
|
else paragraph.append(document.createElement("br"));
|
||||||
|
input.append(paragraph);
|
||||||
|
}
|
||||||
|
const left = item.leftIndent || 0, right = item.rightIndent || 0;
|
||||||
|
const frame = item.textFrame || { width: item.width, height: item.height, matrix: [1, 0, 0, 1, item.x, item.y] };
|
||||||
|
const [a, b, c, d, e, f] = frame.matrix;
|
||||||
Object.assign(input.style, {
|
Object.assign(input.style, {
|
||||||
left: `${item.x * PX_PER_MM}px`, top: `${item.y * PX_PER_MM}px`,
|
left: "0px", top: "0px",
|
||||||
width: `${(item.wrap ? item.width : Math.max(item.width, Math.min(50, item.page.width - item.x))) * PX_PER_MM + 4}px`,
|
width: `${(Math.max(frame.width, item.wrap ? frame.width : 50) - left - right) * PX_PER_MM + 4}px`,
|
||||||
minHeight: `${item.height * PX_PER_MM + 4}px`,
|
minHeight: `${frame.height * PX_PER_MM + 4}px`,
|
||||||
|
transform: `matrix(${a},${b},${c},${d},${(e + a * left) * PX_PER_MM},${(f + b * left) * PX_PER_MM})`,
|
||||||
|
whiteSpace: item.wrap ? "pre-wrap" : "pre",
|
||||||
fontFamily: `"${face.family}"`, fontSize: `${item.size * PX_PER_MM}px`,
|
fontFamily: `"${face.family}"`, fontSize: `${item.size * PX_PER_MM}px`,
|
||||||
lineHeight: item.paragraphHeight || item.lineHeight ? `${(item.paragraphHeight || item.lineHeight) * PX_PER_MM}px` : "normal", color: item.color,
|
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`,
|
textIndent: `${(item.firstLineIndent || 0) * PX_PER_MM}px`,
|
||||||
});
|
});
|
||||||
if (item.textFrame) {
|
|
||||||
const { width, height, matrix: [a, b, c, d, e, f] } = item.textFrame;
|
|
||||||
Object.assign(input.style, { left: "0px", top: "0px", width: `${Math.max(width, item.wrap ? width : 50) * PX_PER_MM + 4}px`,
|
|
||||||
minHeight: `${height * PX_PER_MM + 4}px`, transform: `matrix(${a},${b},${c},${d},${e * PX_PER_MM},${f * PX_PER_MM})` });
|
|
||||||
}
|
|
||||||
this.input = { input, item, face, fontChoice: item.fontChoice };
|
this.input = { input, item, face, fontChoice: item.fontChoice };
|
||||||
item.surface.append(input);
|
item.surface.append(input);
|
||||||
input.addEventListener("input", () => {
|
input.addEventListener("input", () => this.options.onTextChange());
|
||||||
this.resizeText();
|
input.addEventListener("paste", event => {
|
||||||
this.options.onTextChange();
|
event.preventDefault();
|
||||||
|
document.execCommand("insertText", false, event.clipboardData.getData("text/plain"));
|
||||||
|
});
|
||||||
|
input.addEventListener("beforeinput", event => {
|
||||||
|
if (event.inputType.startsWith("format")) event.preventDefault();
|
||||||
|
if (event.inputType === "insertLineBreak") {
|
||||||
|
event.preventDefault();
|
||||||
|
document.execCommand("insertParagraph");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
input.addEventListener("blur", (event) => {
|
input.addEventListener("blur", (event) => {
|
||||||
if (!this.options.fontControls?.includes(event.relatedTarget)) this.commitText();
|
if (!this.options.fontControls?.includes(event.relatedTarget)) this.commitText();
|
||||||
@@ -831,7 +872,7 @@ export class CanvasEditor {
|
|||||||
input.addEventListener("keydown", (event) => {
|
input.addEventListener("keydown", (event) => {
|
||||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "s") return;
|
if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === "s") return;
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (event.isComposing || input.readOnly) {
|
if (event.isComposing || this.input?.saving) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.key === "Escape") {
|
if (event.key === "Escape") {
|
||||||
@@ -843,15 +884,8 @@ export class CanvasEditor {
|
|||||||
this.commitText();
|
this.commitText();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.resizeText();
|
|
||||||
input.focus({ preventScroll: true });
|
input.focus({ preventScroll: true });
|
||||||
if (!item.draft) input.select();
|
if (!item.draft) document.getSelection().selectAllChildren(input);
|
||||||
}
|
|
||||||
|
|
||||||
resizeText() {
|
|
||||||
const { input } = this.input;
|
|
||||||
input.style.height = "0px";
|
|
||||||
input.style.height = `${input.scrollHeight + 2}px`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setTextFont(face, data) {
|
setTextFont(face, data) {
|
||||||
@@ -861,7 +895,6 @@ export class CanvasEditor {
|
|||||||
editing.face = face;
|
editing.face = face;
|
||||||
editing.fontData = data;
|
editing.fontData = data;
|
||||||
editing.input.style.fontFamily = `"${face.family}"`;
|
editing.input.style.fontFamily = `"${face.family}"`;
|
||||||
this.resizeText();
|
|
||||||
this.options.onTextChange();
|
this.options.onTextChange();
|
||||||
editing.input.focus({ preventScroll: true });
|
editing.input.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
@@ -869,20 +902,23 @@ export class CanvasEditor {
|
|||||||
async commitText() {
|
async commitText() {
|
||||||
const editing = this.input;
|
const editing = this.input;
|
||||||
if (!editing) return true;
|
if (!editing) return true;
|
||||||
if (editing.input.readOnly) return false;
|
if (editing.saving) return false;
|
||||||
if (editing.input.value === editing.item.text && !editing.fontData || editing.item.draft && !editing.input.value.trim()) {
|
const value = editTextValue(editing.input);
|
||||||
|
if (value === editing.item.text && !editing.fontData || editing.item.draft && !value.trim()) {
|
||||||
this.closeText();
|
this.closeText();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
editing.input.readOnly = true;
|
editing.saving = true;
|
||||||
const saved = await this.options.onCommitText(editing.item, editing.input.value, editing.fontData);
|
editing.input.contentEditable = "false";
|
||||||
|
const saved = await this.options.onCommitText(editing.item, value, editing.fontData);
|
||||||
if (this.input !== editing) {
|
if (this.input !== editing) {
|
||||||
return Boolean(saved);
|
return Boolean(saved);
|
||||||
}
|
}
|
||||||
if (saved) {
|
if (saved) {
|
||||||
this.closeText();
|
this.closeText();
|
||||||
} else {
|
} else {
|
||||||
editing.input.readOnly = false;
|
editing.saving = false;
|
||||||
|
editing.input.contentEditable = "true";
|
||||||
editing.input.focus({ preventScroll: true });
|
editing.input.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
return Boolean(saved);
|
return Boolean(saved);
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export class FontPicker {
|
|||||||
restore() {
|
restore() {
|
||||||
const font = this.fonts[Number(this.value)];
|
const font = this.fonts[Number(this.value)];
|
||||||
this.input.value = font?.fullName || font?.name || "";
|
this.input.value = font?.fullName || font?.name || "";
|
||||||
|
this.input.scrollLeft = 0;
|
||||||
this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : "";
|
this.input.title = font ? [this.input.value, this.sourceLabel(font)].filter(Boolean).join(" · ") : "";
|
||||||
this.input.setAttribute("aria-expanded", String(this.open));
|
this.input.setAttribute("aria-expanded", String(this.open));
|
||||||
this.input.removeAttribute("aria-activedescendant");
|
this.input.removeAttribute("aria-activedescendant");
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ func RunWASM() {
|
|||||||
registerCallback("ofdgoFontFace", fontFace)
|
registerCallback("ofdgoFontFace", fontFace)
|
||||||
registerCallback("ofdgoCreateDocument", createDocument)
|
registerCallback("ofdgoCreateDocument", createDocument)
|
||||||
registerCallback("ofdgoEditDocument", editDocument)
|
registerCallback("ofdgoEditDocument", editDocument)
|
||||||
|
registerCallback("ofdgoUpdateInfo", updateInfo)
|
||||||
registerCallback("ofdgoChangePage", changePage)
|
registerCallback("ofdgoChangePage", changePage)
|
||||||
registerCallback("ofdgoLoadImport", loadImport)
|
registerCallback("ofdgoLoadImport", loadImport)
|
||||||
registerCallback("ofdgoImportPages", importPages)
|
registerCallback("ofdgoImportPages", importPages)
|
||||||
@@ -1091,6 +1092,18 @@ func createDocument(args []js.Value) (any, error) {
|
|||||||
return previewEditor(editor, args[3].Bool())
|
return previewEditor(editor, args[3].Bool())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateInfo 修改标题、作者和主题,保留其他元数据及创建程序标识
|
||||||
|
// 入参: args 标题、作者、主题
|
||||||
|
// 返回: any 编辑状态, error 错误信息
|
||||||
|
func updateInfo(args []js.Value) (any, error) {
|
||||||
|
return changeObjects(func() error {
|
||||||
|
info := currentEditor.Info
|
||||||
|
info.Title, info.Author, info.Subject = args[0].String(), args[1].String(), args[2].String()
|
||||||
|
currentEditor.SetInfo(info)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// editDocument 将已打开文档接入编辑器,沿用页面、资源及字体配置
|
// editDocument 将已打开文档接入编辑器,沿用页面、资源及字体配置
|
||||||
// 入参: args 浏览器参数
|
// 入参: args 浏览器参数
|
||||||
// 返回: any 编辑状态, error 错误信息
|
// 返回: any 编辑状态, error 错误信息
|
||||||
|
|||||||
+23
-5
@@ -23,6 +23,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"image"
|
"image"
|
||||||
"math"
|
"math"
|
||||||
|
"reflect"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -89,6 +90,20 @@ func (e *Editor) PageCount() int {
|
|||||||
return len(e.pages)
|
return len(e.pages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetInfo 更新文档元数据,独立保存输入并计入修订和撤销记录
|
||||||
|
// 入参: info 完整元数据,未修改字段由调用方保留
|
||||||
|
func (e *Editor) SetInfo(info DocInfo) {
|
||||||
|
if reflect.DeepEqual(e.Info, info) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
before, after := cloneEditorData(e.Info), cloneEditorData(info)
|
||||||
|
e.Info = cloneEditorData(after)
|
||||||
|
if change := e.recordChange(); change != nil {
|
||||||
|
change.undo = func(e *Editor) { e.Info = cloneEditorData(before) }
|
||||||
|
change.redo = func(e *Editor) { e.Info = cloneEditorData(after) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Page 获取页面的独立副本,修改副本不影响文档
|
// Page 获取页面的独立副本,修改副本不影响文档
|
||||||
// 入参: index 页面索引
|
// 入参: index 页面索引
|
||||||
// 返回: *PageContent 页面内容, error 错误信息
|
// 返回: *PageContent 页面内容, error 错误信息
|
||||||
@@ -160,7 +175,13 @@ func (e *Editor) AddPage(width, height float64) (int, error) {
|
|||||||
// 返回: int 副本页面索引, error 错误信息
|
// 返回: int 副本页面索引, error 错误信息
|
||||||
func (e *Editor) CopyPage(index int) (int, error) {
|
func (e *Editor) CopyPage(index int) (int, error) {
|
||||||
if e.originalPage(index) {
|
if e.originalPage(index) {
|
||||||
return 0, fmt.Errorf("copying imported pages is not supported")
|
source, err := e.Reader()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
at := len(e.pages)
|
||||||
|
_, err = e.importPages(source, []int{index}, at, true)
|
||||||
|
return at, err
|
||||||
}
|
}
|
||||||
source, err := e.page(index)
|
source, err := e.page(index)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -203,13 +224,10 @@ func (e *Editor) CopyPage(index int) (int, error) {
|
|||||||
return index, nil
|
return index, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeletePage 删除页面,不回收资源或复用标识,删除后页面索引随之变化
|
// DeletePage 删除页面,输出时清理相关目录、跳转、注释和签章位置,保留其他页面及原始签名凭据
|
||||||
// 入参: index 页面索引
|
// 入参: index 页面索引
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) DeletePage(index int) error {
|
func (e *Editor) DeletePage(index int) error {
|
||||||
if e.originalPage(index) {
|
|
||||||
return fmt.Errorf("deleting imported pages is not supported")
|
|
||||||
}
|
|
||||||
page, err := e.page(index)
|
page, err := e.page(index)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ type editorChange struct {
|
|||||||
|
|
||||||
// SetHistoryLimit 设置撤销和重做记录的总上限,默认关闭,非正数关闭并释放记录
|
// SetHistoryLimit 设置撤销和重做记录的总上限,默认关闭,非正数关闭并释放记录
|
||||||
// 缩减上限时优先保留最近的撤销记录,不影响当前文档及修订标识
|
// 缩减上限时优先保留最近的撤销记录,不影响当前文档及修订标识
|
||||||
// 记录页面和对象操作,不包含Info的直接修改及字体、图片注册,资源继续共享
|
// 记录页面、对象及SetInfo操作,不包含Info的直接修改及字体、图片注册,资源继续共享
|
||||||
// 入参: limit 最大记录数
|
// 入参: limit 最大记录数
|
||||||
func (e *Editor) SetHistoryLimit(limit int) {
|
func (e *Editor) SetHistoryLimit(limit int) {
|
||||||
e.historyLimit = max(0, limit)
|
e.historyLimit = max(0, limit)
|
||||||
@@ -55,7 +55,7 @@ func (e *Editor) CanRedo() bool {
|
|||||||
return e.historyIndex < len(e.history)
|
return e.historyIndex < len(e.history)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Undo 撤销最近一次页面或对象操作,保留资源和已分配的标识
|
// Undo 撤销最近一次操作,保留资源和已分配的标识
|
||||||
// 返回: bool 是否执行了撤销
|
// 返回: bool 是否执行了撤销
|
||||||
func (e *Editor) Undo() bool {
|
func (e *Editor) Undo() bool {
|
||||||
if !e.CanUndo() {
|
if !e.CanUndo() {
|
||||||
@@ -81,7 +81,7 @@ func (e *Editor) Redo() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Revision 获取页面和对象的当前修订标识,撤销或重做时恢复对应标识
|
// Revision 获取当前修订标识,撤销或重做时恢复对应标识
|
||||||
// Info的直接修改和资源注册不计入修订,标识仅在当前Editor实例内有效
|
// Info的直接修改和资源注册不计入修订,标识仅在当前Editor实例内有效
|
||||||
// 返回: uint64 修订标识
|
// 返回: uint64 修订标识
|
||||||
func (e *Editor) Revision() uint64 {
|
func (e *Editor) Revision() uint64 {
|
||||||
|
|||||||
+67
-3
@@ -47,6 +47,7 @@ type editorPageImport struct {
|
|||||||
templateParts map[string][]byte
|
templateParts map[string][]byte
|
||||||
defaultCS string
|
defaultCS string
|
||||||
attachments map[string][]byte
|
attachments map[string][]byte
|
||||||
|
copyPage bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ImportPages 将来源主文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作
|
// ImportPages 将来源主文档中的指定页面插入目标位置,保持输入顺序并作为一次撤销操作
|
||||||
@@ -56,6 +57,13 @@ type editorPageImport struct {
|
|||||||
// 入参: source 来源阅读器, indexes 来源页面索引,不可重复, at 目标插入位置
|
// 入参: source 来源阅读器, indexes 来源页面索引,不可重复, at 目标插入位置
|
||||||
// 返回: []string 新页面标识, error 错误信息
|
// 返回: []string 新页面标识, error 错误信息
|
||||||
func (e *Editor) ImportPages(source *Reader, indexes []int, at int) ([]string, error) {
|
func (e *Editor) ImportPages(source *Reader, indexes []int, at int) ([]string, error) {
|
||||||
|
return e.importPages(source, indexes, at, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// importPages 迁移页面,同文档复制时复用原资源并保留其他页面的跳转
|
||||||
|
// 入参: source 来源阅读器, indexes 来源页索引, at 插入位置, copyPage 是否同文档复制
|
||||||
|
// 返回: []string 新页标识, error 错误信息
|
||||||
|
func (e *Editor) importPages(source *Reader, indexes []int, at int, copyPage bool) ([]string, error) {
|
||||||
if at < 0 || at > len(e.pages) {
|
if at < 0 || at > len(e.pages) {
|
||||||
return nil, fmt.Errorf("page index %d out of range", at)
|
return nil, fmt.Errorf("page index %d out of range", at)
|
||||||
}
|
}
|
||||||
@@ -91,8 +99,15 @@ func (e *Editor) ImportPages(source *Reader, indexes []int, at int) ([]string, e
|
|||||||
for editorDirectoryExists(base.reader, prefix) {
|
for editorDirectoryExists(base.reader, prefix) {
|
||||||
prefix += "_"
|
prefix += "_"
|
||||||
}
|
}
|
||||||
m := &editorPageImport{reader: source, doc: doc, prefix: prefix, maximum: e.maxID,
|
m := &editorPageImport{reader: source, doc: doc, prefix: prefix, maximum: e.maxID, copyPage: copyPage,
|
||||||
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)}
|
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)}
|
||||||
|
if copyPage {
|
||||||
|
for _, page := range doc.Pages.Page {
|
||||||
|
if !selected[page.ID] {
|
||||||
|
m.ids[page.ID] = page.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, name := range append(slices.Clone(doc.CommonData.PublicRes), doc.CommonData.DocumentRes...) {
|
for _, name := range append(slices.Clone(doc.CommonData.PublicRes), doc.CommonData.DocumentRes...) {
|
||||||
if err := m.resourceIndex(source.ResPath(name)); err != nil {
|
if err := m.resourceIndex(source.ResPath(name)); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -130,6 +145,7 @@ func (e *Editor) ImportPages(source *Reader, indexes []int, at int) ([]string, e
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if !copyPage {
|
||||||
var resourceContent []byte
|
var resourceContent []byte
|
||||||
for _, group := range []string{"ColorSpaces", "DrawParams", "Fonts", "MultiMedias", "CompositeGraphicUnits"} {
|
for _, group := range []string{"ColorSpaces", "DrawParams", "Fonts", "MultiMedias", "CompositeGraphicUnits"} {
|
||||||
var entries []byte
|
var entries []byte
|
||||||
@@ -152,6 +168,7 @@ func (e *Editor) ImportPages(source *Reader, indexes []int, at int) ([]string, e
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
m.files[path.Join(prefix, "Resources.xml")] = resources
|
m.files[path.Join(prefix, "Resources.xml")] = resources
|
||||||
|
}
|
||||||
next, err := m.merge(base, refs, annotations, signatures)
|
next, err := m.merge(base, refs, annotations, signatures)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -160,6 +177,32 @@ func (e *Editor) ImportPages(source *Reader, indexes []int, at int) ([]string, e
|
|||||||
for i, ref := range refs {
|
for i, ref := range refs {
|
||||||
added[i] = PageContent{ID: ref.ID}
|
added[i] = PageContent{ID: ref.ID}
|
||||||
}
|
}
|
||||||
|
if copyPage {
|
||||||
|
preview := *e
|
||||||
|
preview.source, preview.pages = next, added
|
||||||
|
for i, index := range indexes {
|
||||||
|
layouts := make(map[string]*textLayout)
|
||||||
|
for _, layer := range e.pages[index].Content.Layer {
|
||||||
|
for _, object := range layer.Objects {
|
||||||
|
if layout := object.TextObject.layout; object.Type == "TextObject" && layout != nil {
|
||||||
|
value := *layout
|
||||||
|
layouts[m.ids[object.TextObject.ID]] = &value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := preview.loadSourcePage(i); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for j := range added[i].Content.Layer {
|
||||||
|
for k := range added[i].Content.Layer[j].Objects {
|
||||||
|
object := &added[i].Content.Layer[j].Objects[k]
|
||||||
|
if object.Type == "TextObject" {
|
||||||
|
object.TextObject.layout = layouts[object.TextObject.ID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if e.source == nil {
|
if e.source == nil {
|
||||||
for i := range e.resources {
|
for i := range e.resources {
|
||||||
resource := &e.resources[i]
|
resource := &e.resources[i]
|
||||||
@@ -268,6 +311,14 @@ func (m *editorPageImport) resourceIndex(name string) error {
|
|||||||
// 入参: value 来源资源标识
|
// 入参: value 来源资源标识
|
||||||
// 返回: string 新标识, error 错误信息
|
// 返回: string 新标识, error 错误信息
|
||||||
func (m *editorPageImport) reference(value string) (string, error) {
|
func (m *editorPageImport) reference(value string) (string, error) {
|
||||||
|
if m.copyPage {
|
||||||
|
if _, ok := m.resources[value]; ok {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
if _, ok := m.templates[value]; ok {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
if entry, ok := m.resources[value]; ok {
|
if entry, ok := m.resources[value]; ok {
|
||||||
if _, seen := m.used[value]; !seen {
|
if _, seen := m.used[value]; !seen {
|
||||||
m.used[value] = nil
|
m.used[value] = nil
|
||||||
@@ -387,6 +438,13 @@ func (m *editorPageImport) encode(entry editorImportEntry, node *editorXML) ([]b
|
|||||||
}
|
}
|
||||||
name := node.name.Local
|
name := node.name.Local
|
||||||
if name == "PageRes" {
|
if name == "PageRes" {
|
||||||
|
if m.copyPage {
|
||||||
|
loc, err := m.resolve(entry.name, "", strings.TrimSpace(editorImportText(entry.data, node)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return editorXMLText("PageRes", "/"+loc), nil
|
||||||
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if name == "StampAnnot" && !m.pages[node.attr("PageRef")] {
|
if name == "StampAnnot" && !m.pages[node.attr("PageRef")] {
|
||||||
@@ -402,7 +460,7 @@ func (m *editorPageImport) encode(entry editorImportEntry, node *editorXML) ([]b
|
|||||||
for _, bookmark := range m.doc.Bookmarks.Bookmark {
|
for _, bookmark := range m.doc.Bookmarks.Bookmark {
|
||||||
bookmarks[bookmark.Name] = bookmark.Dest
|
bookmarks[bookmark.Name] = bookmark.Dest
|
||||||
}
|
}
|
||||||
if dest := gotoDest(&action, bookmarks); dest == nil || !m.pages[dest.PageID] {
|
if dest := gotoDest(&action, bookmarks); dest == nil || !m.pages[dest.PageID] && !m.copyPage {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -586,6 +644,9 @@ func (m *editorPageImport) annotations() ([]byte, error) {
|
|||||||
// 入参: id 来源附件标识
|
// 入参: id 来源附件标识
|
||||||
// 返回: string 新标识, error 错误信息
|
// 返回: string 新标识, error 错误信息
|
||||||
func (m *editorPageImport) attachment(id string) (string, error) {
|
func (m *editorPageImport) attachment(id string) (string, error) {
|
||||||
|
if m.copyPage {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
if _, exists := m.attachments[id]; exists {
|
if _, exists := m.attachments[id]; exists {
|
||||||
return m.id(id), nil
|
return m.id(id), nil
|
||||||
}
|
}
|
||||||
@@ -692,7 +753,10 @@ func (m *editorPageImport) merge(base *editorSource, refs []Page, annotations, s
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
content := editorXMLText("DocumentRes", "/"+path.Join(m.prefix, "Resources.xml"))
|
var content []byte
|
||||||
|
if m.files[path.Join(m.prefix, "Resources.xml")] != nil {
|
||||||
|
content = editorXMLText("DocumentRes", "/"+path.Join(m.prefix, "Resources.xml"))
|
||||||
|
}
|
||||||
for _, id := range slices.Sorted(maps.Keys(m.templateParts)) {
|
for _, id := range slices.Sorted(maps.Keys(m.templateParts)) {
|
||||||
content = append(content, m.templateParts[id]...)
|
content = append(content, m.templateParts[id]...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,15 +247,14 @@ func (e *Editor) originalPage(index int) bool {
|
|||||||
return e.source != nil && index >= 0 && index < len(e.pages) && e.source.pages[e.pages[index].ID] != nil
|
return e.source != nil && index >= 0 && index < len(e.pages) && e.source.pages[e.pages[index].ID] != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PageCapabilities 获取页面操作范围;原始页面暂不复制、删除,避免破坏模板与外部引用
|
// PageCapabilities 获取页面操作范围,复制和删除由库统一维护标准引用
|
||||||
// 入参: index 页面索引
|
// 入参: index 页面索引
|
||||||
// 返回: PageCapabilities 操作能力, error 错误信息
|
// 返回: PageCapabilities 操作能力, error 错误信息
|
||||||
func (e *Editor) PageCapabilities(index int) (PageCapabilities, error) {
|
func (e *Editor) PageCapabilities(index int) (PageCapabilities, error) {
|
||||||
if index < 0 || index >= len(e.pages) {
|
if index < 0 || index >= len(e.pages) {
|
||||||
return PageCapabilities{}, fmt.Errorf("page index %d out of range", index)
|
return PageCapabilities{}, fmt.Errorf("page index %d out of range", index)
|
||||||
}
|
}
|
||||||
original := e.originalPage(index)
|
return PageCapabilities{Insert: e.sourceRGB(), Copy: true, Delete: true, Move: true, Resize: true}, nil
|
||||||
return PageCapabilities{Insert: e.sourceRGB(), Copy: !original, Delete: !original, Move: true, Resize: true}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceRGB 判断新建RGB对象能否直接使用文档默认颜色空间
|
// sourceRGB 判断新建RGB对象能否直接使用文档默认颜色空间
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// sourceParts 生成编辑过的XML和新增资源,不读取未修改页面或原始二进制资源
|
// sourceParts 生成编辑快照的XML和新增资源,删页时同步清理标准引用
|
||||||
// 返回: map[string][]byte 替换及新增条目, error 错误信息
|
// 返回: map[string][]byte 替换及新增条目, error 错误信息
|
||||||
func (e *Editor) sourceParts() (map[string][]byte, error) {
|
func (e *Editor) sourceParts() (map[string][]byte, error) {
|
||||||
parts := make(map[string][]byte)
|
parts := make(map[string][]byte)
|
||||||
@@ -159,6 +159,9 @@ func (e *Editor) sourceParts() (map[string][]byte, error) {
|
|||||||
}
|
}
|
||||||
parts["OFD.xml"] = data
|
parts["OFD.xml"] = data
|
||||||
}
|
}
|
||||||
|
if err := e.prunePageReferences(parts); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
for name, data := range maps.Clone(parts) {
|
for name, data := range maps.Clone(parts) {
|
||||||
if file, ok := reader.packageFile(name); ok {
|
if file, ok := reader.packageFile(name); ok {
|
||||||
actual := cleanPackagePath(file.Name)
|
actual := cleanPackagePath(file.Name)
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
// Copyright 2025-2026 肖其顿 (XIAO QI DUN)
|
||||||
|
//
|
||||||
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
// you may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package ofdgo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// prunePageReferences 在输出快照中删除已移除页面的标准引用,不修改历史快照或其他文档
|
||||||
|
// 沿主文档、页面、模板、资源、注释和签名索引检查,不进入附件、私有扩展和签名原始凭据
|
||||||
|
// 入参: parts 输出覆盖条目
|
||||||
|
// 返回: error 读取或解析错误
|
||||||
|
func (e *Editor) prunePageReferences(parts map[string][]byte) error {
|
||||||
|
removed := make(map[string]bool)
|
||||||
|
for id := range e.source.pages {
|
||||||
|
removed[id] = true
|
||||||
|
}
|
||||||
|
for _, page := range e.pages {
|
||||||
|
delete(removed, page.ID)
|
||||||
|
}
|
||||||
|
if len(removed) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
reader := e.source.reader
|
||||||
|
bookmarks := make(map[string]bool)
|
||||||
|
for _, bookmark := range e.source.document.Bookmarks.Bookmark {
|
||||||
|
if removed[bookmark.Dest.PageID] {
|
||||||
|
bookmarks[bookmark.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
queue := []string{reader.ResPath(reader.OFD.DocBody[0].DocRoot)}
|
||||||
|
if name := e.source.document.Signatures; name != "" {
|
||||||
|
queue = append(queue, reader.ResPath(name))
|
||||||
|
}
|
||||||
|
seen := make(map[string]bool)
|
||||||
|
for len(queue) > 0 {
|
||||||
|
name := queue[0]
|
||||||
|
queue = queue[1:]
|
||||||
|
if seen[strings.ToLower(name)] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[strings.ToLower(name)] = true
|
||||||
|
data, ok := parts[name]
|
||||||
|
if !ok {
|
||||||
|
var err error
|
||||||
|
data, err = reader.readFile(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root, err := parseEditorXML(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var patches []editorXMLPatch
|
||||||
|
var walk func(*editorXML) error
|
||||||
|
walk = func(node *editorXML) error {
|
||||||
|
if node.name.Space != "" && node.name.Space != ofdNamespace && node.name.Space != "http://www.ofdspec.org" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
remove := false
|
||||||
|
switch node.name.Local {
|
||||||
|
case "Action":
|
||||||
|
if target := node.child("Goto"); target != nil {
|
||||||
|
if dest := target.child("Dest"); dest != nil {
|
||||||
|
remove = removed[dest.attr("PageID")]
|
||||||
|
} else if bookmark := target.child("Bookmark"); bookmark != nil {
|
||||||
|
remove = bookmarks[bookmark.attr("Name")]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "Bookmark":
|
||||||
|
if node.parent != nil && node.parent.name.Local == "Bookmarks" {
|
||||||
|
remove = bookmarks[node.attr("Name")]
|
||||||
|
}
|
||||||
|
case "Page":
|
||||||
|
if root.name.Local == "Annotations" {
|
||||||
|
remove = removed[node.attr("PageID")]
|
||||||
|
}
|
||||||
|
case "Dest":
|
||||||
|
remove = removed[node.attr("PageID")]
|
||||||
|
case "StampAnnot":
|
||||||
|
remove = removed[node.attr("PageRef")]
|
||||||
|
case "Extension":
|
||||||
|
remove = removed[node.attr("RefId")]
|
||||||
|
}
|
||||||
|
if remove {
|
||||||
|
patches = append(patches, editorXMLPatch{start: node.start, end: node.end})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var location string
|
||||||
|
switch node.name.Local {
|
||||||
|
case "Page", "TemplatePage", "Signature":
|
||||||
|
if node.parent != nil && (node.parent.name.Local == "Pages" || node.parent.name.Local == "CommonData" || node.parent.name.Local == "Signatures") {
|
||||||
|
location = node.attr("BaseLoc")
|
||||||
|
}
|
||||||
|
case "PublicRes", "DocumentRes", "PageRes", "Annotations", "Extensions":
|
||||||
|
if len(node.children) == 0 {
|
||||||
|
location = strings.TrimSpace(editorImportText(data, node))
|
||||||
|
}
|
||||||
|
case "FileLoc":
|
||||||
|
if root.name.Local == "Annotations" {
|
||||||
|
location = strings.TrimSpace(editorImportText(data, node))
|
||||||
|
}
|
||||||
|
case "CustomTags", "ExtendData", "Data":
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if location != "" {
|
||||||
|
resolved, err := editorPageLocation(reader, parts, name, location)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
queue = append(queue, resolved)
|
||||||
|
}
|
||||||
|
start := len(patches)
|
||||||
|
for _, child := range node.children {
|
||||||
|
if err := walk(child); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.name.Local == "Actions" || node.name.Local == "Bookmarks") && len(node.children) > 0 && len(patches)-start == len(node.children) {
|
||||||
|
empty := true
|
||||||
|
for i, child := range node.children {
|
||||||
|
empty = empty && patches[start+i].start == child.start && patches[start+i].end == child.end
|
||||||
|
}
|
||||||
|
if empty {
|
||||||
|
patches = append(patches[:start], editorXMLPatch{start: node.start, end: node.end})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := walk(root); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(patches) != 0 {
|
||||||
|
parts[name] = editorPatchXML(data, patches)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// editorPageLocation 解析标准XML引用,优先使用所在文件的相对路径
|
||||||
|
// 入参: reader 原包, parts 覆盖条目, name 所在XML, value 路径
|
||||||
|
// 返回: string 包内路径, error 路径不存在
|
||||||
|
func editorPageLocation(reader *Reader, parts map[string][]byte, name, value string) (string, error) {
|
||||||
|
for _, candidate := range []string{resolveResourcePath(name, "", value), reader.ResPath(value)} {
|
||||||
|
if _, ok := parts[candidate]; ok || reader.files[candidate] != nil {
|
||||||
|
return candidate, nil
|
||||||
|
}
|
||||||
|
if file, ok := reader.packageFile(candidate); ok {
|
||||||
|
return cleanPackagePath(file.Name), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("page reference %q in %s not found", value, name)
|
||||||
|
}
|
||||||
+3
-1
@@ -80,8 +80,9 @@ type TextSpan struct {
|
|||||||
Matrix [6]float64 `json:"matrix"`
|
Matrix [6]float64 `json:"matrix"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TextMatch 文字匹配结果,Run为文本对象索引,Start和End为原文Unicode字符区间
|
// TextMatch 文字匹配结果,ID为对象标识,Run为文本对象索引,Start和End为原文Unicode字符区间
|
||||||
type TextMatch struct {
|
type TextMatch struct {
|
||||||
|
ID string `json:"id"`
|
||||||
Run int `json:"run"`
|
Run int `json:"run"`
|
||||||
Start int `json:"start"`
|
Start int `json:"start"`
|
||||||
End int `json:"end"`
|
End int `json:"end"`
|
||||||
@@ -169,6 +170,7 @@ func (p *PageText) Search(query string) []TextMatch {
|
|||||||
offset += found
|
offset += found
|
||||||
end := start + length
|
end := start + length
|
||||||
match := TextMatch{
|
match := TextMatch{
|
||||||
|
ID: run.ID,
|
||||||
Run: index, Start: start, End: end,
|
Run: index, Start: start, End: end,
|
||||||
Before: string(runes[max(0, start-16):start]),
|
Before: string(runes[max(0, start-16):start]),
|
||||||
Text: string(runes[start:end]),
|
Text: string(runes[start:end]),
|
||||||
|
|||||||
Reference in new issue
Block a user