mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-30 01:27:26 +08:00
This commit is contained in:
1 parent
e9d9ba0add
commit
cfcf1939d1
9 files changed
+490
-86
No files matched your search
+11
-2
@@ -57,7 +57,7 @@
|
|||||||
<div id="editorTools" class="editor-toolbar" role="toolbar" aria-label="创作工具" hidden>
|
<div id="editorTools" class="editor-toolbar" role="toolbar" aria-label="创作工具" hidden>
|
||||||
<div class="editor-group">
|
<div class="editor-group">
|
||||||
<button id="selectObjectButton" class="button" type="button" aria-pressed="true">选择</button>
|
<button id="selectObjectButton" class="button" type="button" aria-pressed="true">选择</button>
|
||||||
<button id="editTextButton" class="button" type="button" aria-label="修改文字" disabled>修改</button>
|
<button id="editObjectButton" class="button" type="button" aria-label="修改对象" disabled>修改</button>
|
||||||
<button id="copyObjectButton" class="button" type="button" aria-label="复制对象" disabled>复制</button>
|
<button id="copyObjectButton" class="button" type="button" aria-label="复制对象" disabled>复制</button>
|
||||||
<button id="deleteObjectButton" class="button" type="button" disabled>删除</button>
|
<button id="deleteObjectButton" class="button" type="button" disabled>删除</button>
|
||||||
<select id="objectOrder" class="order-select" aria-label="对象层级" disabled>
|
<select id="objectOrder" class="order-select" aria-label="对象层级" disabled>
|
||||||
@@ -67,6 +67,15 @@
|
|||||||
<option value="top">置顶</option>
|
<option value="top">置顶</option>
|
||||||
<option value="bottom">置底</option>
|
<option value="bottom">置底</option>
|
||||||
</select>
|
</select>
|
||||||
|
<select id="objectAlign" class="order-select" aria-label="页面对齐" disabled>
|
||||||
|
<option value="" hidden>对齐</option>
|
||||||
|
<option value="left">左侧</option>
|
||||||
|
<option value="center">水平居中</option>
|
||||||
|
<option value="right">右侧</option>
|
||||||
|
<option value="top">顶部</option>
|
||||||
|
<option value="middle">垂直居中</option>
|
||||||
|
<option value="bottom">底部</option>
|
||||||
|
</select>
|
||||||
<span>对象</span>
|
<span>对象</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="editor-group">
|
<div class="editor-group">
|
||||||
@@ -149,7 +158,7 @@
|
|||||||
<form id="insertForm">
|
<form id="insertForm">
|
||||||
<div id="insertTextRow" class="form-row">
|
<div id="insertTextRow" class="form-row">
|
||||||
<label for="insertText">内容</label>
|
<label for="insertText">内容</label>
|
||||||
<input id="insertText" type="text" required autofocus>
|
<textarea id="insertText" rows="3" required autofocus></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div id="insertFontRow" class="form-row">
|
<div id="insertFontRow" class="form-row">
|
||||||
<label for="insertFont">字体</label>
|
<label for="insertFont">字体</label>
|
||||||
|
|||||||
+38
-2
@@ -42,6 +42,7 @@ body {
|
|||||||
|
|
||||||
button,
|
button,
|
||||||
input,
|
input,
|
||||||
|
textarea,
|
||||||
select {
|
select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
}
|
}
|
||||||
@@ -56,6 +57,7 @@ button {
|
|||||||
|
|
||||||
button:disabled,
|
button:disabled,
|
||||||
input:disabled,
|
input:disabled,
|
||||||
|
textarea:disabled,
|
||||||
select:disabled {
|
select:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.48;
|
opacity: 0.48;
|
||||||
@@ -63,6 +65,7 @@ select:disabled {
|
|||||||
|
|
||||||
button:focus-visible,
|
button:focus-visible,
|
||||||
input:focus-visible,
|
input:focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
select:focus-visible,
|
select:focus-visible,
|
||||||
a:focus-visible {
|
a:focus-visible {
|
||||||
outline: 2px solid var(--focus);
|
outline: 2px solid var(--focus);
|
||||||
@@ -105,7 +108,7 @@ a:focus-visible {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.editor-group:has(#selectObjectButton) {
|
.editor-group:has(#selectObjectButton) {
|
||||||
grid-template-columns: repeat(5, max-content);
|
grid-template-columns: repeat(6, max-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-group:has(#insertTextButton) {
|
.editor-group:has(#insertTextButton) {
|
||||||
@@ -430,6 +433,7 @@ button:not(:disabled):hover {
|
|||||||
.form-row input[type="number"],
|
.form-row input[type="number"],
|
||||||
.form-row input[type="file"],
|
.form-row input[type="file"],
|
||||||
.form-row select,
|
.form-row select,
|
||||||
|
.form-row textarea,
|
||||||
.text-size input {
|
.text-size input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -441,6 +445,14 @@ button:not(:disabled):hover {
|
|||||||
color: #1f2428;
|
color: #1f2428;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-row textarea {
|
||||||
|
height: auto;
|
||||||
|
min-height: var(--control-height);
|
||||||
|
padding: 6px;
|
||||||
|
line-height: 1.5;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
.form-unit.text-size {
|
.form-unit.text-size {
|
||||||
grid-template-columns: calc(2em + 22px) auto;
|
grid-template-columns: calc(2em + 22px) auto;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
@@ -1120,6 +1132,28 @@ button.outline-link:hover {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-text {
|
||||||
|
position: absolute;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 1;
|
||||||
|
margin: -2px;
|
||||||
|
padding: 1px;
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 0;
|
||||||
|
background: #fff;
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
font-kerning: none;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
letter-spacing: 0;
|
||||||
|
cursor: text;
|
||||||
|
touch-action: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-text:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
.editing-select .edit-layer {
|
.editing-select .edit-layer {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -1831,11 +1865,13 @@ button.outline-link:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 360px) {
|
@media (max-width: 360px) {
|
||||||
|
.editor-group:has(#selectObjectButton),
|
||||||
.editor-group:has(#addPageButton) {
|
.editor-group:has(#addPageButton) {
|
||||||
grid-template-columns: repeat(3, max-content);
|
grid-template-columns: repeat(3, max-content);
|
||||||
}
|
}
|
||||||
|
|
||||||
.editor-group:has(#addPageButton) > button {
|
.editor-group:has(#addPageButton) > button,
|
||||||
|
.editor-group:has(#selectObjectButton) > :not(span) {
|
||||||
grid-row: auto;
|
grid-row: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+129
-50
@@ -36,7 +36,7 @@ const state = {
|
|||||||
dirty: false,
|
dirty: false,
|
||||||
editorInfo: null,
|
editorInfo: null,
|
||||||
savedRevision: null,
|
savedRevision: null,
|
||||||
textObject: null,
|
insertObject: null,
|
||||||
insertFonts: [],
|
insertFonts: [],
|
||||||
textFonts: [],
|
textFonts: [],
|
||||||
ofdBytes: null,
|
ofdBytes: null,
|
||||||
@@ -87,9 +87,10 @@ const el = {
|
|||||||
editorTools: document.querySelector("#editorTools"),
|
editorTools: document.querySelector("#editorTools"),
|
||||||
selectObjectButton: document.querySelector("#selectObjectButton"),
|
selectObjectButton: document.querySelector("#selectObjectButton"),
|
||||||
deleteObjectButton: document.querySelector("#deleteObjectButton"),
|
deleteObjectButton: document.querySelector("#deleteObjectButton"),
|
||||||
editTextButton: document.querySelector("#editTextButton"),
|
editObjectButton: document.querySelector("#editObjectButton"),
|
||||||
copyObjectButton: document.querySelector("#copyObjectButton"),
|
copyObjectButton: document.querySelector("#copyObjectButton"),
|
||||||
objectOrder: document.querySelector("#objectOrder"),
|
objectOrder: document.querySelector("#objectOrder"),
|
||||||
|
objectAlign: document.querySelector("#objectAlign"),
|
||||||
textFont: document.querySelector("#textFont"),
|
textFont: document.querySelector("#textFont"),
|
||||||
textSize: document.querySelector("#textSize"),
|
textSize: document.querySelector("#textSize"),
|
||||||
textColor: document.querySelector("#textColor"),
|
textColor: document.querySelector("#textColor"),
|
||||||
@@ -229,7 +230,16 @@ const canvasEditor = new CanvasEditor(el.viewerPanel, {
|
|||||||
onTransform: (item, change) => changeDocument("ofdgoTransformObject", item,
|
onTransform: (item, change) => changeDocument("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),
|
||||||
onDelete: (item) => changeDocument("ofdgoDeleteObject", item),
|
onDelete: (item) => changeDocument("ofdgoDeleteObject", item),
|
||||||
onEdit: (item) => openInsertPanel(true, item),
|
onEdit: editCanvasObject,
|
||||||
|
onCommitText: (item, value) => changeDocument("ofdgoUpdateText", item, value, null, item.size, null),
|
||||||
|
onTextChange: () => {
|
||||||
|
const editing = canvasEditor.input;
|
||||||
|
setDirty(state.editing && (state.editorInfo?.revision !== state.savedRevision
|
||||||
|
|| Boolean(editing && editing.input.value !== editing.item.text)));
|
||||||
|
if (!editing) {
|
||||||
|
syncSelection();
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
el.selectObjectButton.addEventListener("click", () => {
|
el.selectObjectButton.addEventListener("click", () => {
|
||||||
@@ -247,9 +257,9 @@ el.deleteObjectButton.addEventListener("click", () => {
|
|||||||
changeDocument("ofdgoDeleteObject", canvasEditor.selected);
|
changeDocument("ofdgoDeleteObject", canvasEditor.selected);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
el.editTextButton.addEventListener("click", () => {
|
el.editObjectButton.addEventListener("click", () => {
|
||||||
if (canvasEditor.selected && !canvasEditor.selected.image) {
|
if (canvasEditor.selected) {
|
||||||
openInsertPanel(true, canvasEditor.selected);
|
openInsertPanel(!canvasEditor.selected.image, canvasEditor.selected);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
el.textSize.addEventListener("change", () => changeTextStyle(false));
|
el.textSize.addEventListener("change", () => changeTextStyle(false));
|
||||||
@@ -291,6 +301,13 @@ el.objectOrder.addEventListener("change", () => {
|
|||||||
const targets = { up: item.order + 1, down: item.order - 1, top: item.count - 1, bottom: 0 };
|
const targets = { up: item.order + 1, down: item.order - 1, top: item.count - 1, bottom: 0 };
|
||||||
return changeDocument("ofdgoMoveObject", item, targets[action]);
|
return changeDocument("ofdgoMoveObject", item, targets[action]);
|
||||||
});
|
});
|
||||||
|
el.objectAlign.addEventListener("change", () => {
|
||||||
|
const alignment = el.objectAlign.value;
|
||||||
|
el.objectAlign.value = "";
|
||||||
|
if (canvasEditor.selected && alignment) {
|
||||||
|
return changeDocument("ofdgoAlignObject", canvasEditor.selected, alignment);
|
||||||
|
}
|
||||||
|
});
|
||||||
el.undoButton.addEventListener("click", () => changeDocument("ofdgoUndo"));
|
el.undoButton.addEventListener("click", () => changeDocument("ofdgoUndo"));
|
||||||
el.redoButton.addEventListener("click", () => changeDocument("ofdgoRedo"));
|
el.redoButton.addEventListener("click", () => changeDocument("ofdgoRedo"));
|
||||||
el.addPageButton.addEventListener("click", () => {
|
el.addPageButton.addEventListener("click", () => {
|
||||||
@@ -325,7 +342,7 @@ el.createForm.addEventListener("submit", createDocument);
|
|||||||
el.insertTextButton.addEventListener("click", () => openInsertPanel(true));
|
el.insertTextButton.addEventListener("click", () => openInsertPanel(true));
|
||||||
el.insertImageButton.addEventListener("click", () => openInsertPanel(false));
|
el.insertImageButton.addEventListener("click", () => openInsertPanel(false));
|
||||||
el.insertCancel.addEventListener("click", () => el.insertPanel.close());
|
el.insertCancel.addEventListener("click", () => el.insertPanel.close());
|
||||||
el.insertPanel.addEventListener("close", () => { state.textObject = null; });
|
el.insertPanel.addEventListener("close", () => { state.insertObject = null; });
|
||||||
el.insertForm.addEventListener("submit", insertObject);
|
el.insertForm.addEventListener("submit", insertObject);
|
||||||
el.insertFontAdd.addEventListener("click", () => openFontFile(el.fontInput));
|
el.insertFontAdd.addEventListener("click", () => openFontFile(el.fontInput));
|
||||||
el.saveButton.addEventListener("click", () => exportFile(true, null, "ofd"));
|
el.saveButton.addEventListener("click", () => exportFile(true, null, "ofd"));
|
||||||
@@ -633,6 +650,33 @@ async function createDocument(event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function editCanvasObject(item) {
|
||||||
|
if (item.image) {
|
||||||
|
return openInsertPanel(false, item);
|
||||||
|
}
|
||||||
|
if (!state.editing || document.body.hasAttribute("aria-busy") || canvasEditor.input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const openSeq = state.openSeq;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const data = await callWASM("ofdgoEditorFont", item.font);
|
||||||
|
const face = new FontFace(`ofdgo-edit-${item.font}`, data.bytes);
|
||||||
|
await face.load();
|
||||||
|
if (openSeq === state.openSeq && item.node.isConnected) {
|
||||||
|
canvasEditor.editText(item, face);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (openSeq === state.openSeq) {
|
||||||
|
showError(err, false);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (openSeq === state.openSeq) {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function openInsertPanel(text, item = null) {
|
async function openInsertPanel(text, item = null) {
|
||||||
if (!state.editing || document.body.hasAttribute("aria-busy")) {
|
if (!state.editing || document.body.hasAttribute("aria-busy")) {
|
||||||
return;
|
return;
|
||||||
@@ -645,13 +689,13 @@ async function openInsertPanel(text, item = null) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.insertForm.reset();
|
el.insertForm.reset();
|
||||||
state.textObject = item;
|
state.insertObject = item;
|
||||||
if (item) {
|
if (item && text) {
|
||||||
state.insertFonts = [];
|
state.insertFonts = [];
|
||||||
el.insertText.value = item.text;
|
el.insertText.value = item.text;
|
||||||
}
|
}
|
||||||
el.insertPanel.setAttribute("aria-label", item ? "修改文字" : text ? "添加文字" : "添加图片");
|
el.insertPanel.setAttribute("aria-label", item ? text ? "修改文字" : "替换图片" : text ? "添加文字" : "添加图片");
|
||||||
el.insertSubmit.textContent = item ? "修改" : "添加";
|
el.insertSubmit.textContent = item ? text ? "修改" : "替换" : "添加";
|
||||||
for (const [row, input] of [[el.insertTextRow, el.insertText], [el.insertFontRow, el.insertFont]]) {
|
for (const [row, input] of [[el.insertTextRow, el.insertText], [el.insertFontRow, el.insertFont]]) {
|
||||||
row.hidden = !text;
|
row.hidden = !text;
|
||||||
input.disabled = !text;
|
input.disabled = !text;
|
||||||
@@ -665,7 +709,7 @@ async function openInsertPanel(text, item = null) {
|
|||||||
|
|
||||||
function updateInsertFonts() {
|
function updateInsertFonts() {
|
||||||
const selected = state.insertFonts[Number(el.insertFont.value)];
|
const selected = state.insertFonts[Number(el.insertFont.value)];
|
||||||
state.insertFonts = editorFonts(state.textObject);
|
state.insertFonts = editorFonts(state.insertObject?.image ? null : state.insertObject);
|
||||||
setFontOptions(el.insertFont, state.insertFonts, selected);
|
setFontOptions(el.insertFont, state.insertFonts, selected);
|
||||||
el.insertSubmit.disabled = !el.insertTextRow.hidden && !state.insertFonts.length;
|
el.insertSubmit.disabled = !el.insertTextRow.hidden && !state.insertFonts.length;
|
||||||
el.insertStatus.textContent = el.insertSubmit.disabled ? "尚未添加字体" : "";
|
el.insertStatus.textContent = el.insertSubmit.disabled ? "尚未添加字体" : "";
|
||||||
@@ -707,7 +751,7 @@ async function insertObject(event) {
|
|||||||
}
|
}
|
||||||
let openSeq = state.openSeq;
|
let openSeq = state.openSeq;
|
||||||
const text = !el.insertTextRow.hidden;
|
const text = !el.insertTextRow.hidden;
|
||||||
const item = state.textObject;
|
const item = state.insertObject;
|
||||||
const index = item?.index ?? state.pageIndex;
|
const index = item?.index ?? state.pageIndex;
|
||||||
const page = state.doc.pages[index];
|
const page = state.doc.pages[index];
|
||||||
const x = Math.min(20, page.width / 10);
|
const x = Math.min(20, page.width / 10);
|
||||||
@@ -729,7 +773,8 @@ async function insertObject(event) {
|
|||||||
}
|
}
|
||||||
const value = item && data === null && el.insertText.value === item.text ? null : el.insertText.value;
|
const value = item && data === null && el.insertText.value === item.text ? null : el.insertText.value;
|
||||||
const doc = item
|
const doc = item
|
||||||
? await callWASM("ofdgoUpdateText", item.index, item.id, value, data, item.size, null)
|
? text ? await callWASM("ofdgoUpdateText", item.index, item.id, value, data, item.size, null)
|
||||||
|
: await callWASM("ofdgoReplaceImage", item.index, item.id, data)
|
||||||
: text ? await callWASM("ofdgoInsertText", index, el.insertText.value, data, x, y, 12 * 25.4 / 72, "#000000")
|
: text ? await callWASM("ofdgoInsertText", index, el.insertText.value, data, x, y, 12 * 25.4 / 72, "#000000")
|
||||||
: await callWASM("ofdgoInsertImage", index, data, x, y, page.width - x * 2);
|
: await callWASM("ofdgoInsertImage", index, data, x, y, page.width - x * 2);
|
||||||
if (openSeq !== state.openSeq) {
|
if (openSeq !== state.openSeq) {
|
||||||
@@ -822,15 +867,8 @@ async function changeDocument(name, item, ...args) {
|
|||||||
const page = doc.pages[pageIndex];
|
const page = doc.pages[pageIndex];
|
||||||
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,
|
||||||
if (openSeq === state.openSeq) {
|
keepPreview: true, previewScroll: keepScroll ? { scrollLeft, scrollTop } : null });
|
||||||
if (keepScroll) {
|
|
||||||
el.viewerPanel.scrollLeft = scrollLeft;
|
|
||||||
el.viewerPanel.scrollTop = scrollTop;
|
|
||||||
} else {
|
|
||||||
scrollToPage(pageIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (openSeq === state.openSeq) {
|
if (openSeq === state.openSeq) {
|
||||||
el.viewerPanel.focus({ preventScroll: true });
|
el.viewerPanel.focus({ preventScroll: true });
|
||||||
@@ -877,6 +915,20 @@ async function refreshEditorPage(doc, index, openSeq) {
|
|||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
if (openSeq === state.openSeq) {
|
if (openSeq === state.openSeq) {
|
||||||
|
releasePageResources();
|
||||||
|
renderMeta();
|
||||||
|
updateControls();
|
||||||
|
loadDocumentDetails(openSeq);
|
||||||
|
for (const page of doc.pages) {
|
||||||
|
observeFlowPage(pageShell(page.index), page.index);
|
||||||
|
const button = el.pageList.querySelector(`[data-page-index="${page.index}"]`);
|
||||||
|
observeThumbnail(button, page.index, openSeq);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function releasePageResources() {
|
||||||
const pages = [...state.pageCache.values()];
|
const pages = [...state.pageCache.values()];
|
||||||
for (const [name, image] of state.svgImages) {
|
for (const [name, image] of state.svgImages) {
|
||||||
if (!pages.some((page) => page.imageNames.includes(name))) {
|
if (!pages.some((page) => page.imageNames.includes(name))) {
|
||||||
@@ -890,16 +942,6 @@ async function refreshEditorPage(doc, index, openSeq) {
|
|||||||
state.svgFonts.delete(name);
|
state.svgFonts.delete(name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
renderMeta();
|
|
||||||
updateControls();
|
|
||||||
loadDocumentDetails(openSeq);
|
|
||||||
for (const page of doc.pages) {
|
|
||||||
observeFlowPage(pageShell(page.index), page.index);
|
|
||||||
const button = el.pageList.querySelector(`[data-page-index="${page.index}"]`);
|
|
||||||
observeThumbnail(button, page.index, openSeq);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openOFDFile() {
|
async function openOFDFile() {
|
||||||
@@ -1703,6 +1745,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) : [];
|
||||||
if (!state.ready || state.wasmExited) {
|
if (!state.ready || state.wasmExited) {
|
||||||
await ensureWASM();
|
await ensureWASM();
|
||||||
}
|
}
|
||||||
@@ -1716,10 +1759,12 @@ async function openDocument(options = {}) {
|
|||||||
updateFontSummary();
|
updateFontSummary();
|
||||||
renderFontList();
|
renderFontList();
|
||||||
}
|
}
|
||||||
setBusy(true, "正在打开文档", 20, STATUS.opening);
|
options.keepPreview ? setBusy(true) : setBusy(true, "正在打开文档", 20, STATUS.opening);
|
||||||
try {
|
try {
|
||||||
|
if (!options.keepPreview) {
|
||||||
setProgress("正在解析文档", 52);
|
setProgress("正在解析文档", 52);
|
||||||
await nextFrame();
|
await nextFrame();
|
||||||
|
}
|
||||||
if (openSeq !== state.openSeq) {
|
if (openSeq !== state.openSeq) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1762,13 +1807,30 @@ async function openDocument(options = {}) {
|
|||||||
if (openSeq !== state.openSeq) {
|
if (openSeq !== state.openSeq) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resetPageFlow();
|
if (options.keepPreview) {
|
||||||
|
resetPageLoading();
|
||||||
|
state.pageCache.clear();
|
||||||
|
const indices = new Set([pageIndex, ...doc.pages.filter((page) => previewIDs.includes(page.id)).map((page) => page.index)]);
|
||||||
|
for (const index of indices) {
|
||||||
|
await loadPageData(index, { openSeq, priority: 0, refresh: true });
|
||||||
|
if (openSeq !== state.openSeq) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resetPageFlow(options.keepPreview);
|
||||||
renderPageList();
|
renderPageList();
|
||||||
if (options.resetScroll || el.outlineList.childElementCount === 0) {
|
if (options.resetScroll || el.outlineList.childElementCount === 0) {
|
||||||
renderOutlines();
|
renderOutlines();
|
||||||
}
|
}
|
||||||
renderMeta();
|
renderMeta();
|
||||||
renderPageFlow();
|
renderPageFlow();
|
||||||
|
if (options.keepPreview) {
|
||||||
|
for (const [index, page] of state.pageCache) {
|
||||||
|
mountPageSVG(index, page, openSeq);
|
||||||
|
}
|
||||||
|
releasePageResources();
|
||||||
|
}
|
||||||
if (options.resetScroll) {
|
if (options.resetScroll) {
|
||||||
el.viewerPanel.scrollLeft = 0;
|
el.viewerPanel.scrollLeft = 0;
|
||||||
el.viewerPanel.scrollTop = 0;
|
el.viewerPanel.scrollTop = 0;
|
||||||
@@ -1778,8 +1840,20 @@ async function openDocument(options = {}) {
|
|||||||
el.metaPanel.scrollTop = 0;
|
el.metaPanel.scrollTop = 0;
|
||||||
}
|
}
|
||||||
applyFit(false);
|
applyFit(false);
|
||||||
|
if (options.keepPreview) {
|
||||||
|
if (options.previewScroll) {
|
||||||
|
Object.assign(el.viewerPanel, options.previewScroll);
|
||||||
|
} else {
|
||||||
|
scrollToPage(pageIndex);
|
||||||
|
}
|
||||||
|
setStatus(pageStatus(pageIndex, pageCount));
|
||||||
|
}
|
||||||
await nextFrame();
|
await nextFrame();
|
||||||
const page = await renderPage(pageIndex, { keepBusy: true, scroll: false, openSeq });
|
if (openSeq !== state.openSeq) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const page = options.keepPreview ? state.pageCache.get(pageIndex)
|
||||||
|
: await renderPage(pageIndex, { keepBusy: true, scroll: false, openSeq });
|
||||||
if (page && options.resetScroll) {
|
if (page && options.resetScroll) {
|
||||||
el.viewerPanel.focus({ preventScroll: true });
|
el.viewerPanel.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
@@ -2051,16 +2125,11 @@ function renderPageFlow() {
|
|||||||
el.pageFrame.hidden = false;
|
el.pageFrame.hidden = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetPageFlow() {
|
function resetPageFlow(keepCache = false) {
|
||||||
for (const font of state.svgFonts.values()) {
|
if (!keepCache) {
|
||||||
document.fonts.delete(font);
|
|
||||||
}
|
|
||||||
state.svgFonts.clear();
|
|
||||||
for (const image of state.svgImages.values()) {
|
|
||||||
URL.revokeObjectURL(image.url);
|
|
||||||
}
|
|
||||||
state.svgImages.clear();
|
|
||||||
state.pageCache.clear();
|
state.pageCache.clear();
|
||||||
|
releasePageResources();
|
||||||
|
}
|
||||||
state.selectedPages.clear();
|
state.selectedPages.clear();
|
||||||
state.documentSelection = null;
|
state.documentSelection = null;
|
||||||
state.visiblePages.clear();
|
state.visiblePages.clear();
|
||||||
@@ -2124,8 +2193,9 @@ function flowPageObserver() {
|
|||||||
function unmountPage(index) {
|
function unmountPage(index) {
|
||||||
const shell = pageShell(index);
|
const shell = pageShell(index);
|
||||||
const selection = document.getSelection();
|
const selection = document.getSelection();
|
||||||
const range = selection.rangeCount ? selection.getRangeAt(0) : null;
|
const range = !selection.isCollapsed && selection.rangeCount ? selection.getRangeAt(0) : null;
|
||||||
if (range && (!state.documentSelection || !isDocumentSelection(range)) && range.intersectsNode(shell)) {
|
if (canvasEditor.input?.item.index === index
|
||||||
|
|| range && (!state.documentSelection || !isDocumentSelection(range)) && range.intersectsNode(shell)) {
|
||||||
state.selectedPages.add(index);
|
state.selectedPages.add(index);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2365,7 +2435,12 @@ function createTextLayer(text) {
|
|||||||
const line = document.createElement("span");
|
const line = document.createElement("span");
|
||||||
line.className = "text-run";
|
line.className = "text-run";
|
||||||
const chars = Array.from(run.text);
|
const chars = Array.from(run.text);
|
||||||
|
let end = 0;
|
||||||
for (const item of run.spans || []) {
|
for (const item of run.spans || []) {
|
||||||
|
const breaks = chars.slice(end, item.start).filter((char) => char === "\n").join("");
|
||||||
|
if (breaks) {
|
||||||
|
line.append(document.createTextNode(breaks));
|
||||||
|
}
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.textContent = chars.slice(item.start, item.end).join("");
|
span.textContent = chars.slice(item.start, item.end).join("");
|
||||||
let width = widths.get(span.textContent);
|
let width = widths.get(span.textContent);
|
||||||
@@ -2376,6 +2451,11 @@ function createTextLayer(text) {
|
|||||||
const [a, b, c, d, e, f] = item.matrix.map((value) => value * MM_TO_PX);
|
const [a, b, c, d, e, f] = item.matrix.map((value) => value * MM_TO_PX);
|
||||||
span.style.transform = `matrix(${a / width}, ${b / width}, ${c / 100}, ${d / 100}, ${e}, ${f})`;
|
span.style.transform = `matrix(${a / width}, ${b / width}, ${c / 100}, ${d / 100}, ${e}, ${f})`;
|
||||||
line.append(span);
|
line.append(span);
|
||||||
|
end = item.end;
|
||||||
|
}
|
||||||
|
const breaks = chars.slice(end).filter((char) => char === "\n").join("");
|
||||||
|
if (breaks) {
|
||||||
|
line.append(document.createTextNode(breaks));
|
||||||
}
|
}
|
||||||
layer.append(line);
|
layer.append(line);
|
||||||
}
|
}
|
||||||
@@ -3866,9 +3946,8 @@ function updateEditorTools() {
|
|||||||
|
|
||||||
function updateObjectControls(item, reset = false) {
|
function updateObjectControls(item, reset = false) {
|
||||||
const disabled = !item || !state.ready || state.exporting;
|
const disabled = !item || !state.ready || state.exporting;
|
||||||
el.deleteObjectButton.disabled = el.copyObjectButton.disabled = disabled;
|
el.deleteObjectButton.disabled = el.copyObjectButton.disabled = el.editObjectButton.disabled = el.objectAlign.disabled = disabled;
|
||||||
el.editTextButton.disabled = !item || item.image || !state.ready || state.exporting;
|
el.textFont.disabled = el.textSize.disabled = el.textColor.disabled = disabled || item.image;
|
||||||
el.textFont.disabled = el.textSize.disabled = el.textColor.disabled = el.editTextButton.disabled;
|
|
||||||
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 = item && !item.image ? textPoints(item.size) : "";
|
el.textSize.value = item && !item.image ? textPoints(item.size) : "";
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ export class CanvasEditor {
|
|||||||
viewer.addEventListener("keydown", (event) => this.keyDown(event));
|
viewer.addEventListener("keydown", (event) => this.keyDown(event));
|
||||||
viewer.addEventListener("dblclick", () => {
|
viewer.addEventListener("dblclick", () => {
|
||||||
const item = this.selected;
|
const item = this.selected;
|
||||||
if (this.enabled && item && !item.image && !this.options.busy()) {
|
if (this.enabled && item && !this.input && !this.options.busy()) {
|
||||||
this.options.onEdit(item);
|
this.options.onEdit(item);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -107,12 +107,16 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
|
this.closeText();
|
||||||
this.cancel();
|
this.cancel();
|
||||||
this.selectLast = false;
|
this.selectLast = false;
|
||||||
this.select(null);
|
this.select(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
start(event) {
|
start(event) {
|
||||||
|
if (this.input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) {
|
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -173,11 +177,11 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
keyDown(event) {
|
keyDown(event) {
|
||||||
if (!this.enabled || !this.selected || this.options.busy() || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) {
|
if (this.input || !this.enabled || !this.selected || this.options.busy() || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const directions = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
const directions = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
||||||
const edit = (event.key === "Enter" || event.key === "F2") && !this.selected.image;
|
const edit = event.key === "Enter" || event.key === "F2";
|
||||||
if (event.key !== "Escape" && event.key !== "Delete" && !edit && !directions[event.key]) {
|
if (event.key !== "Escape" && event.key !== "Delete" && !edit && !directions[event.key]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -200,4 +204,84 @@ export class CanvasEditor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
editText(item, face) {
|
||||||
|
this.cancel();
|
||||||
|
this.closeText();
|
||||||
|
this.select(item);
|
||||||
|
document.fonts.add(face);
|
||||||
|
const input = document.createElement("textarea");
|
||||||
|
input.className = "edit-text";
|
||||||
|
input.setAttribute("aria-label", "编辑文字");
|
||||||
|
input.wrap = "off";
|
||||||
|
input.spellcheck = false;
|
||||||
|
input.value = item.text;
|
||||||
|
Object.assign(input.style, {
|
||||||
|
left: `${item.x * PX_PER_MM}px`, top: `${item.y * PX_PER_MM}px`,
|
||||||
|
width: `${Math.max(item.width, Math.min(50, item.page.width - item.x)) * PX_PER_MM + 4}px`,
|
||||||
|
minHeight: `${item.height * PX_PER_MM + 4}px`,
|
||||||
|
fontFamily: `"${face.family}"`, fontSize: `${item.size * PX_PER_MM}px`,
|
||||||
|
lineHeight: item.lineHeight ? `${item.lineHeight * PX_PER_MM}px` : "normal", color: item.color,
|
||||||
|
});
|
||||||
|
this.input = { input, item, face };
|
||||||
|
item.surface.append(input);
|
||||||
|
const resize = () => {
|
||||||
|
input.style.height = "0px";
|
||||||
|
input.style.height = `${input.scrollHeight + 2}px`;
|
||||||
|
};
|
||||||
|
input.addEventListener("input", () => {
|
||||||
|
resize();
|
||||||
|
this.options.onTextChange();
|
||||||
|
});
|
||||||
|
input.addEventListener("blur", () => this.commitText());
|
||||||
|
input.addEventListener("keydown", (event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
if (event.isComposing || input.readOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
this.closeText();
|
||||||
|
this.viewer.focus({ preventScroll: true });
|
||||||
|
} else if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
this.commitText();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resize();
|
||||||
|
input.focus({ preventScroll: true });
|
||||||
|
input.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
async commitText() {
|
||||||
|
const editing = this.input;
|
||||||
|
if (!editing || editing.input.readOnly) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (editing.input.value === editing.item.text) {
|
||||||
|
this.closeText();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
editing.input.readOnly = true;
|
||||||
|
const saved = await this.options.onCommitText(editing.item, editing.input.value);
|
||||||
|
if (this.input !== editing) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (saved) {
|
||||||
|
this.closeText();
|
||||||
|
} else {
|
||||||
|
editing.input.readOnly = false;
|
||||||
|
editing.input.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeText() {
|
||||||
|
const editing = this.input;
|
||||||
|
this.input = null;
|
||||||
|
if (editing) {
|
||||||
|
editing.input.remove();
|
||||||
|
document.fonts.delete(editing.face);
|
||||||
|
this.options.onTextChange();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+72
-2
@@ -26,6 +26,7 @@ import (
|
|||||||
"image"
|
"image"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
|
"strconv"
|
||||||
"syscall/js"
|
"syscall/js"
|
||||||
|
|
||||||
"github.com/xiaoqidun/ofdgo"
|
"github.com/xiaoqidun/ofdgo"
|
||||||
@@ -101,6 +102,9 @@ func RunWASM() {
|
|||||||
registerCallback("ofdgoInsertText", insertText)
|
registerCallback("ofdgoInsertText", insertText)
|
||||||
registerCallback("ofdgoUpdateText", updateText)
|
registerCallback("ofdgoUpdateText", updateText)
|
||||||
registerCallback("ofdgoInsertImage", insertImage)
|
registerCallback("ofdgoInsertImage", insertImage)
|
||||||
|
registerCallback("ofdgoReplaceImage", replaceImage)
|
||||||
|
registerCallback("ofdgoEditorFont", editorFont)
|
||||||
|
registerCallback("ofdgoAlignObject", alignObject)
|
||||||
registerCallback("ofdgoTransformObject", transformObject)
|
registerCallback("ofdgoTransformObject", transformObject)
|
||||||
registerCallback("ofdgoCopyObject", copyObject)
|
registerCallback("ofdgoCopyObject", copyObject)
|
||||||
registerCallback("ofdgoMoveObject", moveObject)
|
registerCallback("ofdgoMoveObject", moveObject)
|
||||||
@@ -612,6 +616,11 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
|
|||||||
item := map[string]any{"id": id, "image": object.Type == "ImageObject", "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "count": len(layer.Objects)}
|
item := map[string]any{"id": id, "image": object.Type == "ImageObject", "x": box.X, "y": box.Y, "width": box.W, "height": box.H, "order": order, "count": len(layer.Objects)}
|
||||||
if object.Type == "TextObject" {
|
if object.Type == "TextObject" {
|
||||||
item["text"], item["font"], item["fontName"], item["size"] = textValues[id], object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size
|
item["text"], item["font"], item["fontName"], item["size"] = textValues[id], object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size
|
||||||
|
if codes := object.TextObject.TextCode; len(codes) > 1 {
|
||||||
|
first, _ := strconv.ParseFloat(codes[0].Y, 64)
|
||||||
|
second, _ := strconv.ParseFloat(codes[1].Y, 64)
|
||||||
|
item["lineHeight"] = second - first
|
||||||
|
}
|
||||||
var r, g, b float64
|
var r, g, b float64
|
||||||
if fill := object.TextObject.FillColor; fill != nil {
|
if fill := object.TextObject.FillColor; fill != nil {
|
||||||
if _, err := fmt.Sscan(fill.Value, &r, &g, &b); err != nil {
|
if _, err := fmt.Sscan(fill.Value, &r, &g, &b); err != nil {
|
||||||
@@ -627,6 +636,67 @@ func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
|
|||||||
return objects, nil
|
return objects, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// editorFont 读取创作对象的完整内嵌字体,供画布输入使用
|
||||||
|
// 入参: args 字体资源标识
|
||||||
|
// 返回: any 字体数据, error 错误信息
|
||||||
|
func editorFont(args []js.Value) (any, error) {
|
||||||
|
if currentEditor == nil {
|
||||||
|
return nil, fmt.Errorf("no document is being created")
|
||||||
|
}
|
||||||
|
fonts, err := currentSession.Reader.Fonts()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, font := range fonts {
|
||||||
|
if font.ID == args[0].String() {
|
||||||
|
data, err := currentSession.Reader.ResData(font.FontFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return successResult(map[string]any{"bytes": bytesToJS(data)}), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("font resource %q not found", args[0].String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceImage 替换图片资源并更新预览
|
||||||
|
// 入参: args 页码、对象标识和图片数据
|
||||||
|
// 返回: any 文档信息, error 错误信息
|
||||||
|
func replaceImage(args []js.Value) (any, error) {
|
||||||
|
if currentEditor == nil {
|
||||||
|
return nil, fmt.Errorf("no document is being created")
|
||||||
|
}
|
||||||
|
data, err := bytesFromJS(args[2])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
revision := currentEditor.Revision()
|
||||||
|
if err := currentEditor.ReplaceImage(args[0].Int(), args[1].String(), data); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if currentEditor.Revision() == revision {
|
||||||
|
return editorSummary(), nil
|
||||||
|
}
|
||||||
|
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||||
|
}
|
||||||
|
|
||||||
|
// alignObject 将对象对齐页面并更新预览
|
||||||
|
// 入参: args 页码、对象标识和对齐方式
|
||||||
|
// 返回: any 文档信息, error 错误信息
|
||||||
|
func alignObject(args []js.Value) (any, error) {
|
||||||
|
if currentEditor == nil {
|
||||||
|
return nil, fmt.Errorf("no document is being created")
|
||||||
|
}
|
||||||
|
revision := currentEditor.Revision()
|
||||||
|
if err := currentEditor.AlignObject(args[0].Int(), args[1].String(), args[2].String()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if currentEditor.Revision() == revision {
|
||||||
|
return editorSummary(), nil
|
||||||
|
}
|
||||||
|
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||||
|
}
|
||||||
|
|
||||||
// transformObject 等比缩放后平移对象
|
// transformObject 等比缩放后平移对象
|
||||||
// 入参: args 页码、对象标识、位移和缩放比例
|
// 入参: args 页码、对象标识、位移和缩放比例
|
||||||
// 返回: any 文档信息, error 错误信息
|
// 返回: any 文档信息, error 错误信息
|
||||||
@@ -810,7 +880,7 @@ func updateText(args []js.Value) (any, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
object.TextObject.Size = args[4].Float()
|
object.TextObject.Size = args[4].Float()
|
||||||
if err := currentEditor.LayoutText(&object.TextObject, args[2].String()); err != nil {
|
if err := currentEditor.LayoutText(&object.TextObject, args[2].String(), 0); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -877,7 +947,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()); err != nil {
|
if err := currentEditor.LayoutText(&object.TextObject, args[1].String(), 0); 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 {
|
||||||
|
|||||||
+115
-17
@@ -613,7 +613,7 @@ func cloneEditorObject(object GraphicObject) (GraphicObject, error) {
|
|||||||
return object, nil
|
return object, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddText 添加单行文字,使用嵌入字体的字宽定位,不进行段落排版和复杂文字塑形
|
// AddText 添加文字,按显式换行和嵌入字体度量定位,不自动折行或进行复杂文字塑形
|
||||||
// 入参: page 页面索引, box 文字边界, value 原文, fontID 字体资源标识, size 字号
|
// 入参: page 页面索引, box 文字边界, value 原文, fontID 字体资源标识, size 字号
|
||||||
// 返回: string 对象标识, error 错误信息
|
// 返回: string 对象标识, error 错误信息
|
||||||
func (e *Editor) AddText(page int, box Box, value, fontID string, size float64) (string, error) {
|
func (e *Editor) AddText(page int, box Box, value, fontID string, size float64) (string, error) {
|
||||||
@@ -621,13 +621,13 @@ func (e *Editor) AddText(page int, box Box, value, fontID string, size float64)
|
|||||||
Boundary: fmt.Sprintf("%s %s %s %s", ofdNumber(box.X), ofdNumber(box.Y), ofdNumber(box.W), ofdNumber(box.H)),
|
Boundary: fmt.Sprintf("%s %s %s %s", ofdNumber(box.X), ofdNumber(box.Y), ofdNumber(box.W), ofdNumber(box.H)),
|
||||||
Font: fontID, Size: size,
|
Font: fontID, Size: size,
|
||||||
}}
|
}}
|
||||||
if err := e.LayoutText(&object.TextObject, value); err != nil {
|
if err := e.LayoutText(&object.TextObject, value, 0); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return e.AddObject(page, object)
|
return e.AddObject(page, object)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateText 按AddText规则重排横向单行文字,保留对象标识、顺序、边界及绘制属性
|
// UpdateText 按AddText规则重排横向文字,保留对象标识、顺序、边界及绘制属性
|
||||||
// 自定义文字定位使用UpdateObject,不进行段落排版和复杂文字塑形
|
// 自定义文字定位使用UpdateObject,不进行段落排版和复杂文字塑形
|
||||||
// 入参: page 页面索引, id 文字对象标识, value 原文, fontID 字体资源标识, size 字号
|
// 入参: page 页面索引, id 文字对象标识, value 原文, fontID 字体资源标识, size 字号
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
@@ -641,7 +641,7 @@ func (e *Editor) UpdateText(page int, id, value, fontID string, size float64) er
|
|||||||
return fmt.Errorf("object %q is not text", id)
|
return fmt.Errorf("object %q is not text", id)
|
||||||
}
|
}
|
||||||
object.TextObject.Font, object.TextObject.Size = fontID, size
|
object.TextObject.Font, object.TextObject.Size = fontID, size
|
||||||
if err := e.LayoutText(&object.TextObject, value); err != nil {
|
if err := e.LayoutText(&object.TextObject, value, 0); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
object, err = e.prepareObject(id, object)
|
object, err = e.prepareObject(id, object)
|
||||||
@@ -704,6 +704,83 @@ func (e *Editor) TransformObject(page int, id string, dx, dy, scale float64) err
|
|||||||
return e.UpdateObject(page, id, object)
|
return e.UpdateObject(page, id, object)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReplaceImage 替换图片资源,保留对象位置、尺寸、变换和绘制顺序
|
||||||
|
// 入参: page 页面索引, id 图片对象标识, data PNG或JPEG数据
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) ReplaceImage(page int, id string, data []byte) error {
|
||||||
|
object, err := e.Object(page, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if object.Type != "ImageObject" {
|
||||||
|
return fmt.Errorf("object %q is not an image", id)
|
||||||
|
}
|
||||||
|
object.ImageObject.ResourceID, err = e.AddImage(data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return e.UpdateObject(page, id, object)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AlignObject 将对象对齐页面,文字采用实际字形范围,路径和图片采用对象边界
|
||||||
|
// 入参: page 页面索引, id 对象标识, alignment 为left、center、right、top、middle或bottom
|
||||||
|
// 返回: error 错误信息
|
||||||
|
func (e *Editor) AlignObject(page int, id, alignment string) error {
|
||||||
|
if !slices.Contains([]string{"left", "center", "right", "top", "middle", "bottom"}, alignment) {
|
||||||
|
return fmt.Errorf("invalid alignment %q", alignment)
|
||||||
|
}
|
||||||
|
object, err := e.Object(page, id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content := &e.pages[page]
|
||||||
|
pageBox, _ := ParseBox(content.Area.PhysicalBox)
|
||||||
|
var box Box
|
||||||
|
switch object.Type {
|
||||||
|
case "TextObject":
|
||||||
|
reader, err := e.Reader()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
renderer := NewRenderer(reader)
|
||||||
|
text, err := renderer.PageText(content)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, run := range text.Runs {
|
||||||
|
if run.ID == id {
|
||||||
|
for _, bounds := range run.Boxes {
|
||||||
|
box = unionTextBox(box, bounds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "PathObject":
|
||||||
|
box, _ = ParseBox(object.PathObject.Boundary)
|
||||||
|
case "ImageObject":
|
||||||
|
box, _ = ParseBox(object.ImageObject.Boundary)
|
||||||
|
}
|
||||||
|
if box.W <= 0 || box.H <= 0 {
|
||||||
|
return fmt.Errorf("object %q has no visible bounds", id)
|
||||||
|
}
|
||||||
|
dx, dy := 0.0, 0.0
|
||||||
|
switch alignment {
|
||||||
|
case "left":
|
||||||
|
dx = -box.X
|
||||||
|
case "center":
|
||||||
|
dx = (pageBox.W-box.W)/2 - box.X
|
||||||
|
case "right":
|
||||||
|
dx = pageBox.W - box.W - box.X
|
||||||
|
case "top":
|
||||||
|
dy = -box.Y
|
||||||
|
case "middle":
|
||||||
|
dy = (pageBox.H-box.H)/2 - box.Y
|
||||||
|
case "bottom":
|
||||||
|
dy = pageBox.H - box.H - box.Y
|
||||||
|
}
|
||||||
|
return e.TransformObject(page, id, dx, dy, 1)
|
||||||
|
}
|
||||||
|
|
||||||
// scaleTextNumbers 缩放已校验的文字定位数值,保留g重复编码
|
// scaleTextNumbers 缩放已校验的文字定位数值,保留g重复编码
|
||||||
// 入参: value 定位数值, scale 缩放比例
|
// 入参: value 定位数值, scale 缩放比例
|
||||||
// 返回: string 缩放后的数值
|
// 返回: string 缩放后的数值
|
||||||
@@ -719,11 +796,11 @@ func scaleTextNumbers(value string, scale float64) string {
|
|||||||
return strings.Join(fields, " ")
|
return strings.Join(fields, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// LayoutText 按嵌入字体度量重排横向单行文字,设置基线和字距,保留绘制属性
|
// LayoutText 按显式换行和嵌入字体度量重排横向文字,保留绘制属性
|
||||||
// 不进行段落排版和复杂文字塑形,不修改文档,通过AddObject或UpdateObject提交
|
// 不自动折行或进行复杂文字塑形,不修改文档,通过AddObject或UpdateObject提交
|
||||||
// 入参: obj 文字对象, value 原文
|
// 入参: obj 文字对象, value 原文, lineHeight 基线间距,0使用字体度量,单位为毫米
|
||||||
// 返回: error 错误信息
|
// 返回: error 错误信息
|
||||||
func (e *Editor) LayoutText(obj *TextObject, value string) error {
|
func (e *Editor) LayoutText(obj *TextObject, value string, lineHeight float64) error {
|
||||||
if obj.ReadDirection != 0 || obj.CharDirection != 0 {
|
if obj.ReadDirection != 0 || obj.CharDirection != 0 {
|
||||||
return fmt.Errorf("automatic text layout requires horizontal text")
|
return fmt.Errorf("automatic text layout requires horizontal text")
|
||||||
}
|
}
|
||||||
@@ -734,26 +811,47 @@ func (e *Editor) LayoutText(obj *TextObject, value string) error {
|
|||||||
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")
|
||||||
}
|
}
|
||||||
if !utf8.ValidString(value) || strings.ContainsAny(value, "\r\n\t") || value == "" {
|
value = strings.ReplaceAll(strings.ReplaceAll(value, "\r\n", "\n"), "\r", "\n")
|
||||||
return fmt.Errorf("text must be a nonempty UTF-8 line")
|
if !utf8.ValidString(value) || strings.Contains(value, "\t") || strings.Trim(value, "\n") == "" {
|
||||||
|
return fmt.Errorf("text must contain UTF-8 characters without tabs")
|
||||||
|
}
|
||||||
|
if !finite(lineHeight) || lineHeight < 0 {
|
||||||
|
return fmt.Errorf("line height must be finite and nonnegative")
|
||||||
}
|
}
|
||||||
hScale := obj.HScale
|
hScale := obj.HScale
|
||||||
if hScale == 0 {
|
if hScale == 0 {
|
||||||
hScale = 1
|
hScale = 1
|
||||||
}
|
}
|
||||||
|
ascender, descender, gap := sfnt.VerticalMetrics()
|
||||||
|
unit := obj.Size / float64(sfnt.UnitsPerEm())
|
||||||
|
if lineHeight == 0 {
|
||||||
|
lineHeight = math.Max(obj.Size, float64(int(ascender)-int(descender)+int(gap))*unit)
|
||||||
|
}
|
||||||
|
lines := strings.Split(value, "\n")
|
||||||
|
codes := make([]TextCode, len(lines))
|
||||||
|
for line, value := range lines {
|
||||||
runes := []rune(value)
|
runes := []rune(value)
|
||||||
deltas := make([]string, 0, len(runes)-1)
|
deltas := make([]string, 0, max(0, len(runes)-1))
|
||||||
for i, char := range runes {
|
for i, char := range runes {
|
||||||
glyph := sfnt.GlyphIndex(char)
|
glyph := sfnt.GlyphIndex(char)
|
||||||
if glyph == 0 {
|
if glyph == 0 {
|
||||||
return fmt.Errorf("font does not contain U+%04X", char)
|
return fmt.Errorf("font does not contain U+%04X", char)
|
||||||
}
|
}
|
||||||
if i+1 < len(runes) {
|
if i+1 < len(runes) {
|
||||||
deltas = append(deltas, ofdNumber(float64(sfnt.GlyphAdvance(glyph))*obj.Size/float64(sfnt.UnitsPerEm())*hScale))
|
advance := float64(sfnt.GlyphAdvance(glyph)) * unit * hScale
|
||||||
|
if !finite(advance) {
|
||||||
|
return fmt.Errorf("text advance exceeds finite range")
|
||||||
|
}
|
||||||
|
deltas = append(deltas, ofdNumber(advance))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ascender, _, _ := sfnt.VerticalMetrics()
|
y := float64(ascender)*unit + float64(line)*lineHeight
|
||||||
obj.TextCode = []TextCode{{X: "0", Y: ofdNumber(float64(ascender) * obj.Size / float64(sfnt.UnitsPerEm())), DeltaX: strings.Join(deltas, " "), Value: escapeOFDText(value)}}
|
if !finite(y) {
|
||||||
|
return fmt.Errorf("text baseline exceeds finite range")
|
||||||
|
}
|
||||||
|
codes[line] = TextCode{X: "0", Y: ofdNumber(y), DeltaX: strings.Join(deltas, " "), Value: escapeOFDText(value)}
|
||||||
|
}
|
||||||
|
obj.TextCode = codes
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -801,9 +899,6 @@ func (e *Editor) prepareText(obj *TextObject) error {
|
|||||||
}
|
}
|
||||||
code.X, code.Y = x, y
|
code.X, code.Y = x, y
|
||||||
runes := textCodeRunes(code.Value)
|
runes := textCodeRunes(code.Value)
|
||||||
if len(runes) == 0 {
|
|
||||||
return fmt.Errorf("text code is empty")
|
|
||||||
}
|
|
||||||
for _, char := range runes {
|
for _, char := range runes {
|
||||||
if e.fonts[obj.Font].GlyphIndex(char) == 0 {
|
if e.fonts[obj.Font].GlyphIndex(char) == 0 {
|
||||||
return fmt.Errorf("font does not contain U+%04X", char)
|
return fmt.Errorf("font does not contain U+%04X", char)
|
||||||
@@ -821,6 +916,9 @@ func (e *Editor) prepareText(obj *TextObject) error {
|
|||||||
}
|
}
|
||||||
code.Value = escapeOFDText(string(runes))
|
code.Value = escapeOFDText(string(runes))
|
||||||
}
|
}
|
||||||
|
if strings.Trim(obj.Text(), "\n") == "" {
|
||||||
|
return fmt.Errorf("text codes are empty")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -198,7 +198,10 @@ func (r *Renderer) renderText(ctx *canvas.Context, obj TextObject, pageH float64
|
|||||||
}
|
}
|
||||||
codePos := 0
|
codePos := 0
|
||||||
textPos := 0
|
textPos := 0
|
||||||
for _, tc := range obj.TextCode {
|
for index, tc := range obj.TextCode {
|
||||||
|
if textRun != nil && obj.textCodeLineBreak(index) {
|
||||||
|
textPos++
|
||||||
|
}
|
||||||
var runes []rune
|
var runes []rune
|
||||||
var glyphs []textGlyph
|
var glyphs []textGlyph
|
||||||
if tc.Index != "" {
|
if tc.Index != "" {
|
||||||
|
|||||||
+2
-9
@@ -204,15 +204,8 @@ func foldText(text string) string {
|
|||||||
// 入参: obj 文本对象
|
// 入参: obj 文本对象
|
||||||
// 返回: *TextRun 文本及待填充的字符区域
|
// 返回: *TextRun 文本及待填充的字符区域
|
||||||
func (p *PageText) addRun(obj TextObject) *TextRun {
|
func (p *PageText) addRun(obj TextObject) *TextRun {
|
||||||
var text strings.Builder
|
text := obj.Text()
|
||||||
for _, code := range obj.TextCode {
|
p.Runs = append(p.Runs, TextRun{ID: obj.ID, Text: text, Boxes: make([]Box, utf8.RuneCountInString(text))})
|
||||||
if code.Index == "" {
|
|
||||||
text.WriteString(string(textCodeRunes(code.Value)))
|
|
||||||
} else {
|
|
||||||
text.WriteRune('\uFFFC')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p.Runs = append(p.Runs, TextRun{ID: obj.ID, Text: text.String(), Boxes: make([]Box, utf8.RuneCountInString(text.String()))})
|
|
||||||
return &p.Runs[len(p.Runs)-1]
|
return &p.Runs[len(p.Runs)-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,38 @@ import (
|
|||||||
"github.com/tdewolff/font"
|
"github.com/tdewolff/font"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Text 获取文字原文,同一行起点处的基线变化以换行分隔,字形索引以占位字符表示
|
||||||
|
// 返回: string 原文
|
||||||
|
func (obj TextObject) Text() string {
|
||||||
|
var text strings.Builder
|
||||||
|
for i, code := range obj.TextCode {
|
||||||
|
if obj.textCodeLineBreak(i) {
|
||||||
|
text.WriteByte('\n')
|
||||||
|
}
|
||||||
|
if code.Index == "" {
|
||||||
|
text.WriteString(string(textCodeRunes(code.Value)))
|
||||||
|
} else {
|
||||||
|
text.WriteRune('\uFFFC')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// textCodeLineBreak 判断横向文本是否回到行首并更换基线
|
||||||
|
// 入参: index 文本编码索引
|
||||||
|
// 返回: bool 是否换行
|
||||||
|
func (obj TextObject) textCodeLineBreak(index int) bool {
|
||||||
|
if index == 0 || obj.ReadDirection != 0 || obj.CharDirection != 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
code, previous := obj.TextCode[index], obj.TextCode[index-1]
|
||||||
|
x, ex := strconv.ParseFloat(code.X, 64)
|
||||||
|
start, es := strconv.ParseFloat(obj.TextCode[0].X, 64)
|
||||||
|
y, ey := strconv.ParseFloat(code.Y, 64)
|
||||||
|
prev, ep := strconv.ParseFloat(previous.Y, 64)
|
||||||
|
return ex == nil && es == nil && ey == nil && ep == nil && x == start && y != prev
|
||||||
|
}
|
||||||
|
|
||||||
// textGlyph 绘制字形
|
// textGlyph 绘制字形
|
||||||
type textGlyph struct {
|
type textGlyph struct {
|
||||||
Text string
|
Text string
|
||||||
|
|||||||
Reference in new issue
Block a user