mirror of
https://github.com/xiaoqidun/ofdgo.git
synced 2026-10-29 13:56:32 +08:00
This commit is contained in:
1 parent
dcf1ae8b3a
commit
aec10e93a4
13 files changed
+1669
-317
No files matched your search
@@ -22,7 +22,7 @@ import (
|
||||
|
||||
// FS WebUI嵌入静态文件
|
||||
//
|
||||
//go:embed index.html ofdgo.css ofdgo.js ofdgo.json ofdgo.svg ofdgo.sw.js ofdgo.wasm ofdgo.wasm.js wasm_exec.js
|
||||
//go:embed index.html ofdgo.css ofdgo.js ofdgo.json ofdgo.svg ofdgo.wasm ofdgo_edit.js ofdgo_font.js ofdgo_wasm.js ofdgo_work.js wasm_exec.js
|
||||
var FS embed.FS
|
||||
|
||||
// Checksum 计算WebUI嵌入资源校验值
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<input id="fontDirectoryInput" class="file-input" type="file" webkitdirectory multiple>
|
||||
<div class="tool-group">
|
||||
<button id="ofdButton" class="icon-button" type="button" title="打开" aria-label="打开" aria-keyshortcuts="Control+O Meta+O"><span class="open-icon" aria-hidden="true"></span></button>
|
||||
<button id="newButton" class="icon-button" type="button" title="新建" aria-label="新建文档"><span class="new-icon" aria-hidden="true"></span></button>
|
||||
<button id="togglePagesButton" class="icon-button" type="button" title="页面栏" aria-label="页面栏" aria-pressed="true">☰</button>
|
||||
<button id="toggleMetaButton" class="icon-button" type="button" title="信息栏" aria-label="信息栏" aria-pressed="true">ⓘ</button>
|
||||
</div>
|
||||
@@ -53,6 +54,74 @@
|
||||
<button id="exportButton" class="button" type="button" data-short="多" title="导出文档" aria-label="导出文档" aria-haspopup="dialog" aria-controls="exportPanel" disabled>文档</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="editorTools" class="editor-toolbar" role="toolbar" aria-label="创作工具" hidden>
|
||||
<div class="editor-group">
|
||||
<button id="selectObjectButton" class="button" type="button" aria-pressed="true">选择</button>
|
||||
<button id="editTextButton" class="button" type="button" aria-label="修改文字" disabled>修改</button>
|
||||
<button id="deleteObjectButton" class="button" type="button" disabled>删除</button>
|
||||
<span>对象</span>
|
||||
</div>
|
||||
<div class="editor-group">
|
||||
<button id="insertTextButton" class="button" type="button" aria-label="添加文字">文字</button>
|
||||
<button id="insertImageButton" class="button" type="button" aria-label="添加图片">图片</button>
|
||||
<span>插入</span>
|
||||
</div>
|
||||
<div class="editor-group">
|
||||
<button id="saveButton" class="button" type="button" aria-label="保存OFD">保存</button>
|
||||
<button id="undoButton" class="button" type="button" disabled>撤销</button>
|
||||
<button id="redoButton" class="button" type="button" disabled>重做</button>
|
||||
<span>文件</span>
|
||||
</div>
|
||||
</div>
|
||||
<dialog id="createPanel" class="form-dialog" aria-label="新建文档">
|
||||
<form id="createForm">
|
||||
<div class="form-row">
|
||||
<label for="createName">名称</label>
|
||||
<input id="createName" type="text" value="未命名" required autofocus>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="createWidth">宽度</label>
|
||||
<div class="form-unit"><input id="createWidth" type="number" value="210" min="1" step="any" required><span>mm</span></div>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label for="createHeight">高度</label>
|
||||
<div class="form-unit"><input id="createHeight" type="number" value="297" min="1" step="any" required><span>mm</span></div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<div id="createStatus" class="form-status" role="status"></div>
|
||||
<button id="createCancel" class="button" type="button">取消</button>
|
||||
<button class="button" type="submit">新建</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="insertPanel" class="form-dialog" aria-label="添加文字">
|
||||
<form id="insertForm">
|
||||
<div id="insertTextRow" class="form-row">
|
||||
<label for="insertText">内容</label>
|
||||
<input id="insertText" type="text" required autofocus>
|
||||
</div>
|
||||
<div id="insertFontRow" class="form-row">
|
||||
<label for="insertFont">字体</label>
|
||||
<div class="form-font">
|
||||
<select id="insertFont" required></select>
|
||||
<button id="insertFontAdd" class="icon-button" type="button" title="添加字体" aria-label="添加字体">+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="insertSizeRow" class="form-row">
|
||||
<label for="insertSize">字号</label>
|
||||
<div class="form-unit"><input id="insertSize" type="number" value="12" min="1" step="any" required><span>pt</span></div>
|
||||
</div>
|
||||
<div id="insertImageRow" class="form-row" hidden>
|
||||
<label for="insertImage">图片</label>
|
||||
<input id="insertImage" type="file" accept=".png,.jpg,.jpeg,image/png,image/jpeg" required>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<div id="insertStatus" class="form-status" role="status"></div>
|
||||
<button id="insertCancel" class="button" type="button">取消</button>
|
||||
<button id="insertSubmit" class="button" type="submit">添加</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
<dialog id="exportPanel" class="form-dialog" aria-label="导出文档">
|
||||
<form id="exportForm">
|
||||
<div class="form-row">
|
||||
|
||||
+168
-1
@@ -77,6 +77,48 @@ a:focus-visible {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app:has(.editor-toolbar:not([hidden])) {
|
||||
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 8px 12px 6px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.editor-toolbar[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.editor-group {
|
||||
display: grid;
|
||||
grid-auto-columns: max-content;
|
||||
column-gap: 6px;
|
||||
row-gap: 4px;
|
||||
padding-right: 12px;
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.editor-group:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.editor-group > button {
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.editor-group > span {
|
||||
grid-column: 1 / -1;
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.app-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -186,6 +228,29 @@ button:not(:disabled):hover {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.new-icon {
|
||||
position: relative;
|
||||
width: 12px;
|
||||
height: 15px;
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
|
||||
.new-icon::before,
|
||||
.new-icon::after {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 6px;
|
||||
height: 1px;
|
||||
background: currentColor;
|
||||
content: "";
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.new-icon::after {
|
||||
transform: translate(-50%, -50%) rotate(90deg);
|
||||
}
|
||||
|
||||
.toolbar-space {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
@@ -325,7 +390,10 @@ button:not(:disabled):hover {
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.form-row input[type="text"] {
|
||||
.form-row input[type="text"],
|
||||
.form-row input[type="number"],
|
||||
.form-row input[type="file"],
|
||||
.form-row select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: var(--control-height);
|
||||
@@ -336,6 +404,28 @@ button:not(:disabled):hover {
|
||||
color: #1f2428;
|
||||
}
|
||||
|
||||
.form-row input[type="file"] {
|
||||
height: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.form-unit {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 2em;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-unit > span {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-font {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-row input[aria-invalid="true"] {
|
||||
border-color: #b14343;
|
||||
}
|
||||
@@ -813,6 +903,7 @@ button.outline-link:hover {
|
||||
.meta-list > [hidden],
|
||||
.navigation-tabs[hidden],
|
||||
.form-row[hidden],
|
||||
.tool-group[hidden],
|
||||
.page-list[hidden],
|
||||
.page-frame[hidden],
|
||||
.progress-panel > [hidden],
|
||||
@@ -937,6 +1028,82 @@ button.outline-link:hover {
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
|
||||
.edit-layer {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editing-select .edit-layer {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.editing-select .text-run > span {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.edit-object {
|
||||
position: absolute;
|
||||
pointer-events: auto;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.edit-object:hover,
|
||||
.edit-object.selected {
|
||||
outline: calc(1px / var(--surface-scale)) solid var(--accent);
|
||||
}
|
||||
|
||||
.edit-object.selected {
|
||||
background: rgba(15, 123, 104, 0.04);
|
||||
}
|
||||
|
||||
.edit-handle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
width: calc(9px / var(--surface-scale));
|
||||
height: calc(9px / var(--surface-scale));
|
||||
border: calc(1px / var(--surface-scale)) solid var(--accent);
|
||||
background: #fff;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.selected .edit-handle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.edit-handle::before {
|
||||
position: absolute;
|
||||
inset: calc(-8px / var(--surface-scale));
|
||||
content: "";
|
||||
}
|
||||
|
||||
.edit-nw {
|
||||
left: 0;
|
||||
top: 0;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.edit-ne {
|
||||
left: 100%;
|
||||
top: 0;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.edit-sw {
|
||||
left: 0;
|
||||
top: 100%;
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.edit-se {
|
||||
left: 100%;
|
||||
top: 100%;
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.text-layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
+413
-274
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,204 @@
|
||||
const PX_PER_MM = 96 / 25.4;
|
||||
|
||||
export function pagePoint(x, y, rect, page, rotation) {
|
||||
const u = (x - rect.left) / rect.width;
|
||||
const v = (y - rect.top) / rect.height;
|
||||
const points = [[u, v], [v, 1 - u], [1 - u, 1 - v], [1 - v, u]];
|
||||
const point = points[rotation / 90];
|
||||
return { x: point[0] * page.width, y: point[1] * page.height };
|
||||
}
|
||||
|
||||
export function objectTransform(box, page, dx, dy, corner = "") {
|
||||
if (!corner) {
|
||||
return {
|
||||
x: Math.max(-box.x, Math.min(dx, Math.max(-box.x, page.width - box.x - box.width))),
|
||||
y: Math.max(-box.y, Math.min(dy, Math.max(-box.y, page.height - box.y - box.height))),
|
||||
scale: 1,
|
||||
};
|
||||
}
|
||||
const left = corner.includes("w");
|
||||
const top = corner.includes("n");
|
||||
const x = left ? -dx : dx;
|
||||
const y = top ? -dy : dy;
|
||||
const limit = Math.min(
|
||||
(left ? box.x + box.width : page.width - box.x) / box.width,
|
||||
(top ? box.y + box.height : page.height - box.y) / box.height,
|
||||
);
|
||||
const scale = Math.max(Math.min(1 / Math.min(box.width, box.height), limit), Math.min(limit,
|
||||
1 + (x * box.width + y * box.height) / (box.width ** 2 + box.height ** 2)));
|
||||
return { x: left ? box.width * (1 - scale) : 0, y: top ? box.height * (1 - scale) : 0, scale };
|
||||
}
|
||||
|
||||
export class CanvasEditor {
|
||||
constructor(viewer, options) {
|
||||
this.viewer = viewer;
|
||||
this.options = options;
|
||||
this.enabled = false;
|
||||
this.selected = null;
|
||||
this.nodes = new WeakMap();
|
||||
viewer.addEventListener("pointerdown", (event) => this.start(event));
|
||||
viewer.addEventListener("pointermove", (event) => this.move(event));
|
||||
viewer.addEventListener("pointerup", (event) => this.end(event));
|
||||
viewer.addEventListener("pointercancel", () => this.cancel());
|
||||
viewer.addEventListener("lostpointercapture", () => this.cancel());
|
||||
viewer.addEventListener("keydown", (event) => this.keyDown(event));
|
||||
viewer.addEventListener("dblclick", () => {
|
||||
const item = this.selected;
|
||||
if (this.enabled && item && !item.image && !this.options.busy()) {
|
||||
this.options.onEdit(item);
|
||||
}
|
||||
});
|
||||
viewer.addEventListener("scroll", () => this.cancel());
|
||||
window.addEventListener("blur", () => this.cancel());
|
||||
window.addEventListener("resize", () => this.cancel());
|
||||
}
|
||||
|
||||
setEnabled(enabled) {
|
||||
this.enabled = enabled;
|
||||
this.viewer.classList.toggle("editing-select", enabled);
|
||||
if (!enabled) {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
mount(index, page, surface) {
|
||||
const layer = document.createElement("div");
|
||||
layer.className = "edit-layer";
|
||||
for (const object of page.objects) {
|
||||
const node = document.createElement("div");
|
||||
node.className = "edit-object";
|
||||
node.tabIndex = 0;
|
||||
node.setAttribute("role", "button");
|
||||
node.setAttribute("aria-label", object.image ? "图片对象" : "文字对象");
|
||||
const item = { ...object, index, page: { width: page.width, height: page.height }, node, surface };
|
||||
this.nodes.set(node, item);
|
||||
node.addEventListener("focus", () => this.select(item));
|
||||
this.place(item);
|
||||
if (object.image) {
|
||||
for (const corner of ["nw", "ne", "sw", "se"]) {
|
||||
const handle = document.createElement("span");
|
||||
handle.className = `edit-handle edit-${corner}`;
|
||||
handle.dataset.corner = corner;
|
||||
node.append(handle);
|
||||
}
|
||||
}
|
||||
layer.append(node);
|
||||
if (this.selected?.index === index && this.selected.id === object.id) {
|
||||
this.select(item);
|
||||
}
|
||||
}
|
||||
surface.append(layer);
|
||||
if (this.selectLast && layer.lastElementChild) {
|
||||
this.selectLast = false;
|
||||
this.select(this.nodes.get(layer.lastElementChild));
|
||||
}
|
||||
}
|
||||
|
||||
place(item, change = { x: 0, y: 0, scale: 1 }) {
|
||||
Object.assign(item.node.style, {
|
||||
left: `${(item.x + change.x) * PX_PER_MM}px`, top: `${(item.y + change.y) * PX_PER_MM}px`,
|
||||
width: `${item.width * change.scale * PX_PER_MM}px`, height: `${item.height * change.scale * PX_PER_MM}px`,
|
||||
});
|
||||
}
|
||||
|
||||
select(item) {
|
||||
this.selected?.node.classList.remove("selected");
|
||||
this.selected = item;
|
||||
item?.node.classList.add("selected");
|
||||
this.options.onSelect(item);
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.cancel();
|
||||
this.selectLast = false;
|
||||
this.select(null);
|
||||
}
|
||||
|
||||
start(event) {
|
||||
if (!this.enabled || this.options.busy() || event.button !== 0 || this.drag) {
|
||||
return;
|
||||
}
|
||||
const node = event.target.closest(".edit-object");
|
||||
if (!node) {
|
||||
this.select(null);
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.select(this.nodes.get(node));
|
||||
this.viewer.focus({ preventScroll: true });
|
||||
const item = this.selected;
|
||||
this.drag = {
|
||||
item, pointerID: event.pointerId, corner: event.target.dataset.corner || "",
|
||||
rect: item.surface.getBoundingClientRect(), rotation: this.options.rotation(),
|
||||
clientX: event.clientX, clientY: event.clientY,
|
||||
};
|
||||
this.viewer.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
move(event) {
|
||||
const drag = this.drag;
|
||||
if (!drag || drag.pointerID !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
if (!drag.change && Math.hypot(event.clientX - drag.clientX, event.clientY - drag.clientY) < 3) {
|
||||
return;
|
||||
}
|
||||
const from = pagePoint(drag.clientX, drag.clientY, drag.rect, drag.item.page, drag.rotation);
|
||||
const to = pagePoint(event.clientX, event.clientY, drag.rect, drag.item.page, drag.rotation);
|
||||
drag.change = objectTransform(drag.item, drag.item.page, to.x - from.x, to.y - from.y, drag.corner);
|
||||
this.place(drag.item, drag.change);
|
||||
}
|
||||
|
||||
end(event) {
|
||||
const drag = this.drag;
|
||||
if (!drag || drag.pointerID !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
this.move(event);
|
||||
this.cancel();
|
||||
if (drag.change && (drag.change.x || drag.change.y || drag.change.scale !== 1)) {
|
||||
this.options.onTransform(drag.item, drag.change);
|
||||
}
|
||||
}
|
||||
|
||||
cancel() {
|
||||
const drag = this.drag;
|
||||
this.drag = null;
|
||||
if (drag) {
|
||||
this.place(drag.item);
|
||||
if (this.viewer.hasPointerCapture(drag.pointerID)) {
|
||||
this.viewer.releasePointerCapture(drag.pointerID);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
keyDown(event) {
|
||||
if (!this.enabled || !this.selected || this.options.busy() || event.ctrlKey || event.metaKey || event.altKey || event.isComposing) {
|
||||
return;
|
||||
}
|
||||
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;
|
||||
if (event.key !== "Escape" && event.key !== "Delete" && !edit && !directions[event.key]) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (event.key === "Escape") {
|
||||
this.drag ? this.cancel() : this.clear();
|
||||
} else if (event.key === "Delete") {
|
||||
this.options.onDelete(this.selected);
|
||||
} else if (edit && !this.drag) {
|
||||
this.options.onEdit(this.selected);
|
||||
} else if (!this.drag) {
|
||||
const [x, y] = directions[event.key];
|
||||
const [dx, dy] = [[x, y], [y, -x], [-x, -y], [-y, x]][this.options.rotation() / 90];
|
||||
const step = event.shiftKey ? 10 : 1;
|
||||
const change = objectTransform(this.selected, this.selected.page,
|
||||
dx * step, dy * step);
|
||||
if (change.x || change.y) {
|
||||
this.options.onTransform(this.selected, change);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
const DATABASE = "ofdgo";
|
||||
|
||||
export class FontManager {
|
||||
constructor({ onChange, onPermissionChange }) {
|
||||
this.userFonts = [];
|
||||
this.localFonts = [];
|
||||
this.catalog = [];
|
||||
this.catalogLoaded = false;
|
||||
this.permission = "prompt";
|
||||
this.sequence = 0;
|
||||
this.database = null;
|
||||
this.onPermissionChange = onPermissionChange;
|
||||
this.channel = typeof BroadcastChannel === "function" ? new BroadcastChannel(DATABASE) : null;
|
||||
if (this.channel) {
|
||||
this.channel.onmessage = onChange;
|
||||
}
|
||||
}
|
||||
|
||||
openDatabase() {
|
||||
if (!this.database) {
|
||||
this.database = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DATABASE, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
const store = request.result.createObjectStore("fonts", { keyPath: "id", autoIncrement: true });
|
||||
store.createIndex("checksum", "checksum", { unique: true });
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
const db = request.result;
|
||||
db.onversionchange = () => {
|
||||
db.close();
|
||||
this.database = null;
|
||||
};
|
||||
resolve(db);
|
||||
};
|
||||
request.onerror = () => reject(request.error);
|
||||
}).catch((err) => {
|
||||
this.database = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return this.database;
|
||||
}
|
||||
|
||||
async transaction(mode, action) {
|
||||
const db = await this.openDatabase();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction("fonts", mode);
|
||||
let request;
|
||||
tx.oncomplete = () => {
|
||||
if (mode === "readwrite") {
|
||||
this.channel?.postMessage(null);
|
||||
}
|
||||
resolve(request?.result);
|
||||
};
|
||||
tx.onabort = () => reject(tx.error || new Error("字体保存失败"));
|
||||
try {
|
||||
request = action(tx.objectStore("fonts"));
|
||||
} catch (err) {
|
||||
tx.abort();
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
listStored() {
|
||||
return this.transaction("readonly", (store) => store.getAll());
|
||||
}
|
||||
|
||||
async store(fonts) {
|
||||
const records = new Map();
|
||||
for (const font of fonts) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", font.data);
|
||||
const checksum = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
if (!records.has(checksum)) {
|
||||
records.set(checksum, {
|
||||
name: font.name,
|
||||
data: new Blob([font.data]),
|
||||
enabled: font.enabled,
|
||||
checksum,
|
||||
});
|
||||
}
|
||||
}
|
||||
await this.transaction("readwrite", (store) => {
|
||||
for (const font of records.values()) {
|
||||
const request = store.index("checksum").getKey(font.checksum);
|
||||
request.onsuccess = () => {
|
||||
if (request.result === undefined) {
|
||||
store.add(font);
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateStored(id, changes) {
|
||||
return this.transaction("readwrite", (store) => {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => {
|
||||
if (request.result) {
|
||||
store.put({ ...request.result, ...changes });
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
deleteStored(id) {
|
||||
return this.transaction("readwrite", (store) => store.delete(id));
|
||||
}
|
||||
|
||||
async queryLocal() {
|
||||
try {
|
||||
const available = await window.queryLocalFonts();
|
||||
this.catalog = available;
|
||||
this.catalogLoaded = true;
|
||||
this.permission = "granted";
|
||||
this.onPermissionChange();
|
||||
return available;
|
||||
} catch (err) {
|
||||
if (err && err.name === "NotAllowedError") {
|
||||
this.permission = "denied";
|
||||
this.onPermissionChange();
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async files(fonts) {
|
||||
const result = [];
|
||||
for (const font of fonts) {
|
||||
if (!font.enabled) {
|
||||
continue;
|
||||
}
|
||||
result.push({ name: font.name, data: await this.read(font) });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async read(font) {
|
||||
if (font.blob) {
|
||||
const blob = await font.blob();
|
||||
return new Uint8Array(await blob.arrayBuffer());
|
||||
}
|
||||
if (font.data instanceof Blob) {
|
||||
font.data = new Uint8Array(await font.data.arrayBuffer());
|
||||
}
|
||||
return font.data;
|
||||
}
|
||||
|
||||
async add(fonts) {
|
||||
let saved = true;
|
||||
try {
|
||||
await this.store(fonts);
|
||||
} catch {
|
||||
saved = false;
|
||||
this.userFonts.push(...fonts);
|
||||
}
|
||||
const changed = saved ? await this.restore() : true;
|
||||
if (saved && navigator.storage?.persist) {
|
||||
navigator.storage.persist().catch(() => false);
|
||||
}
|
||||
return { saved, changed };
|
||||
}
|
||||
|
||||
async change(font, changes) {
|
||||
if (font.source === "stored") {
|
||||
if (changes) {
|
||||
await this.updateStored(font.id, changes);
|
||||
} else {
|
||||
await this.deleteStored(font.id);
|
||||
}
|
||||
await this.restore();
|
||||
} else if (changes) {
|
||||
Object.assign(font, changes);
|
||||
} else {
|
||||
this.localFonts = this.localFonts.filter((item) => item.id !== font.id);
|
||||
this.userFonts = this.userFonts.filter((item) => item.id !== font.id);
|
||||
}
|
||||
}
|
||||
|
||||
async restore() {
|
||||
const stored = await this.listStored();
|
||||
const previous = new Map(this.userFonts.map((font) => [font.id, font]));
|
||||
const fonts = stored.map((font) => ({
|
||||
...font,
|
||||
data: previous.get(font.id)?.checksum === font.checksum ? previous.get(font.id).data : font.data,
|
||||
source: "stored",
|
||||
}));
|
||||
fonts.push(...this.userFonts.filter((font) => font.source === "upload"));
|
||||
if (fonts.length === this.userFonts.length && fonts.every((font, index) => {
|
||||
const old = this.userFonts[index];
|
||||
return font.id === old.id && font.name === old.name && font.enabled === old.enabled && font.checksum === old.checksum;
|
||||
})) {
|
||||
return false;
|
||||
}
|
||||
this.userFonts = fonts;
|
||||
return true;
|
||||
}
|
||||
|
||||
record(name, data, source) {
|
||||
this.sequence += 1;
|
||||
return {
|
||||
id: `${source}-${this.sequence}`,
|
||||
name: name || "font.ttf",
|
||||
data,
|
||||
enabled: true,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
records() {
|
||||
return [...this.localFonts, ...this.userFonts];
|
||||
}
|
||||
|
||||
localName(font) {
|
||||
const name = font.fullName || font.family || font.postscriptName || "local-font";
|
||||
return `${name}.ttf`;
|
||||
}
|
||||
|
||||
canReadLocal() {
|
||||
return typeof window.queryLocalFonts === "function";
|
||||
}
|
||||
|
||||
async refreshPermission() {
|
||||
if (!this.canReadLocal() || !navigator.permissions?.query) {
|
||||
this.onPermissionChange();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const permission = await navigator.permissions.query({ name: "local-fonts" });
|
||||
this.permission = permission.state;
|
||||
permission.onchange = () => {
|
||||
this.permission = permission.state;
|
||||
this.onPermissionChange();
|
||||
};
|
||||
} catch {
|
||||
this.permission = "prompt";
|
||||
}
|
||||
this.onPermissionChange();
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ self.onmessage = ({ data }) => {
|
||||
exports.get(data.id)?.abort();
|
||||
return;
|
||||
}
|
||||
if (data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment") {
|
||||
if (data.name === "ofdgoExportPage" || data.name === "ofdgoExportDocument" || data.name === "ofdgoExportAttachment" || data.name === "ofdgoSaveDocument") {
|
||||
exports.set(data.id, new AbortController());
|
||||
}
|
||||
pending = pending.then(() => handleMessage(data));
|
||||
@@ -1,6 +1,6 @@
|
||||
const CACHE_PREFIX = `ofdgo:${self.registration.scope}:app:`;
|
||||
const META_CACHE = `${CACHE_PREFIX}meta`;
|
||||
const ASSETS = ["./", "ofdgo.css", "ofdgo.js", "ofdgo.json", "ofdgo.svg", "ofdgo.sw.js", "ofdgo.wasm", "ofdgo.wasm.js", "wasm_exec.js"]
|
||||
const ASSETS = ["./", "ofdgo.css", "ofdgo.js", "ofdgo.json", "ofdgo.svg", "ofdgo.wasm", "ofdgo_edit.js", "ofdgo_font.js", "ofdgo_wasm.js", "ofdgo_work.js", "wasm_exec.js"]
|
||||
.map((path) => new URL(path, self.registration.scope).href);
|
||||
const CLIENT_PREFIX = new URL("__client/", self.registration.scope).href;
|
||||
|
||||
+3
-3
@@ -40,13 +40,13 @@ func main() {
|
||||
func serveWebUI() http.Handler {
|
||||
files := http.FileServerFS(webuiassets.FS)
|
||||
checksum := webuiassets.Checksum()
|
||||
serviceWorker, _ := webuiassets.FS.ReadFile("ofdgo.sw.js")
|
||||
serviceWorker, _ := webuiassets.FS.ReadFile("ofdgo_work.js")
|
||||
serviceWorker = append([]byte(fmt.Sprintf("const BUNDLE_CHECKSUM = %q;\n", checksum)), serviceWorker...)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("X-OFDGo-Checksum", checksum)
|
||||
if r.URL.Path == "/ofdgo.sw.js" {
|
||||
http.ServeContent(w, r, "ofdgo.sw.js", time.Time{}, bytes.NewReader(serviceWorker))
|
||||
if r.URL.Path == "/ofdgo_work.js" {
|
||||
http.ServeContent(w, r, "ofdgo_work.js", time.Time{}, bytes.NewReader(serviceWorker))
|
||||
return
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
|
||||
+331
-10
@@ -18,10 +18,13 @@ package webui
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"math"
|
||||
"syscall/js"
|
||||
|
||||
"github.com/xiaoqidun/ofdgo"
|
||||
@@ -92,6 +95,15 @@ func RunWASM() {
|
||||
registerExportCallback("ofdgoExportDocument", exportDocument)
|
||||
registerExportCallback("ofdgoExportAttachment", exportAttachment)
|
||||
registerCallback("ofdgoMatchFontFiles", matchFontFiles)
|
||||
registerCallback("ofdgoCreateDocument", createDocument)
|
||||
registerCallback("ofdgoInsertText", insertText)
|
||||
registerCallback("ofdgoUpdateText", updateText)
|
||||
registerCallback("ofdgoInsertImage", insertImage)
|
||||
registerCallback("ofdgoTransformObject", transformObject)
|
||||
registerCallback("ofdgoDeleteObject", deleteObject)
|
||||
registerCallback("ofdgoUndo", func([]js.Value) (any, error) { return restoreEditor(false) })
|
||||
registerCallback("ofdgoRedo", func([]js.Value) (any, error) { return restoreEditor(true) })
|
||||
registerExportCallback("ofdgoSaveDocument", saveDocument)
|
||||
select {}
|
||||
}
|
||||
|
||||
@@ -163,6 +175,7 @@ func openDocument(args []js.Value) (any, error) {
|
||||
return nil, err
|
||||
}
|
||||
renderAnnotations := args[2].Bool()
|
||||
currentEditor = nil
|
||||
if currentSession != nil {
|
||||
_ = currentSession.Close()
|
||||
currentSession = nil
|
||||
@@ -268,6 +281,10 @@ func renderPage(args []js.Value) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
objects, err := editorObjects(args[0].Int(), text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
links := make([]any, len(page.Links))
|
||||
for i, link := range page.Links {
|
||||
links[i] = map[string]any{
|
||||
@@ -287,16 +304,17 @@ func renderPage(args []js.Value) (any, error) {
|
||||
images[i] = map[string]any{"name": image.Name, "mime": image.MIME, "bytes": bytesToJS(image.Data)}
|
||||
}
|
||||
return successResult(map[string]any{
|
||||
"index": page.Index,
|
||||
"number": page.Number,
|
||||
"id": page.ID,
|
||||
"width": page.Width,
|
||||
"height": page.Height,
|
||||
"svg": page.SVG,
|
||||
"links": links,
|
||||
"fonts": fonts,
|
||||
"images": images,
|
||||
"text": string(textData),
|
||||
"index": page.Index,
|
||||
"number": page.Number,
|
||||
"id": page.ID,
|
||||
"width": page.Width,
|
||||
"height": page.Height,
|
||||
"svg": page.SVG,
|
||||
"links": links,
|
||||
"fonts": fonts,
|
||||
"images": images,
|
||||
"text": string(textData),
|
||||
"objects": objects,
|
||||
}), nil
|
||||
}
|
||||
|
||||
@@ -491,3 +509,306 @@ func encodeResult(result apiResult) string {
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// currentEditor 当前新建文档
|
||||
var currentEditor *ofdgo.Editor
|
||||
|
||||
// editorInfo 创作文档信息与操作状态
|
||||
type editorInfo struct {
|
||||
DocumentInfo
|
||||
Revision uint64 `json:"revision"`
|
||||
CanUndo bool `json:"canUndo"`
|
||||
CanRedo bool `json:"canRedo"`
|
||||
}
|
||||
|
||||
// editorSummary 获取当前创作文档状态
|
||||
// 返回: editorInfo 文档信息
|
||||
func editorSummary() editorInfo {
|
||||
return editorInfo{currentSession.Summary(), currentEditor.Revision(), currentEditor.CanUndo(), currentEditor.CanRedo()}
|
||||
}
|
||||
|
||||
// restoreEditor 撤销或重做并更新预览
|
||||
// 入参: redo 是否重做
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func restoreEditor(redo bool) (any, error) {
|
||||
if currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being created")
|
||||
}
|
||||
apply := currentEditor.Undo
|
||||
if redo {
|
||||
apply = currentEditor.Redo
|
||||
}
|
||||
if !apply() {
|
||||
return editorSummary(), nil
|
||||
}
|
||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||
}
|
||||
|
||||
// editorObjects 提供创作画布的对象命中区域
|
||||
// 入参: index 页面索引, text 页面文字
|
||||
// 返回: []any 对象区域, error 错误信息
|
||||
func editorObjects(index int, text *ofdgo.PageText) ([]any, error) {
|
||||
objects := []any{}
|
||||
if currentEditor == nil {
|
||||
return objects, nil
|
||||
}
|
||||
page, err := currentSession.pageContent(index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
textBoxes := make(map[string]ofdgo.Box)
|
||||
textValues := make(map[string]string)
|
||||
for _, run := range text.Runs {
|
||||
textValues[run.ID] += run.Text
|
||||
for _, box := range run.Boxes {
|
||||
if box.W <= 0 || box.H <= 0 {
|
||||
continue
|
||||
}
|
||||
prev, exists := textBoxes[run.ID]
|
||||
if !exists {
|
||||
textBoxes[run.ID] = box
|
||||
continue
|
||||
}
|
||||
x, y := math.Min(prev.X, box.X), math.Min(prev.Y, box.Y)
|
||||
textBoxes[run.ID] = ofdgo.Box{X: x, Y: y, W: math.Max(prev.X+prev.W, box.X+box.W) - x, H: math.Max(prev.Y+prev.H, box.Y+box.H) - y}
|
||||
}
|
||||
}
|
||||
fonts, err := currentSession.Reader.Fonts()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fontNames := make(map[string]string, len(fonts))
|
||||
for _, font := range fonts {
|
||||
fontNames[font.ID] = font.FontName
|
||||
}
|
||||
for _, layer := range page.Content.Layer {
|
||||
for _, object := range layer.Objects {
|
||||
var id string
|
||||
var box ofdgo.Box
|
||||
switch object.Type {
|
||||
case "TextObject":
|
||||
id = object.TextObject.ID
|
||||
box = textBoxes[id]
|
||||
case "ImageObject":
|
||||
id = object.ImageObject.ID
|
||||
box, err = ofdgo.ParseBox(object.ImageObject.Boundary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if box.W > 0 && box.H > 0 {
|
||||
item := map[string]any{"id": id, "image": object.Type == "ImageObject", "x": box.X, "y": box.Y, "width": box.W, "height": box.H}
|
||||
if object.Type == "TextObject" {
|
||||
item["text"], item["font"], item["fontName"], item["size"] = textValues[id], object.TextObject.Font, fontNames[object.TextObject.Font], object.TextObject.Size
|
||||
}
|
||||
objects = append(objects, item)
|
||||
}
|
||||
}
|
||||
}
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
// transformObject 移动文字或等比缩放图片
|
||||
// 入参: args 页码、对象标识、位移和缩放比例
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func transformObject(args []js.Value) (any, error) {
|
||||
if currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being created")
|
||||
}
|
||||
page, id := args[0].Int(), args[1].String()
|
||||
object, err := currentEditor.Object(page, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scale := args[4].Float()
|
||||
var boundary *string
|
||||
switch object.Type {
|
||||
case "TextObject":
|
||||
if scale != 1 {
|
||||
return nil, fmt.Errorf("text resizing is not supported")
|
||||
}
|
||||
boundary = &object.TextObject.Boundary
|
||||
case "ImageObject":
|
||||
boundary = &object.ImageObject.Boundary
|
||||
object.ImageObject.CTM = ""
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported object type %q", object.Type)
|
||||
}
|
||||
box, err := ofdgo.ParseBox(*boundary)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
*boundary = fmt.Sprintf("%g %g %g %g", box.X+args[2].Float(), box.Y+args[3].Float(), box.W*scale, box.H*scale)
|
||||
if err := currentEditor.UpdateObject(page, id, object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||
}
|
||||
|
||||
// deleteObject 删除创作画布中选中的对象
|
||||
// 入参: args 页码和对象标识
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func deleteObject(args []js.Value) (any, error) {
|
||||
if currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being created")
|
||||
}
|
||||
if err := currentEditor.DeleteObject(args[0].Int(), args[1].String()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||
}
|
||||
|
||||
// createDocument 新建单页文档
|
||||
// 入参: args 标题、纸张宽高和注解设置
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func createDocument(args []js.Value) (any, error) {
|
||||
editor := ofdgo.NewEditor()
|
||||
editor.Info.Title = args[0].String()
|
||||
if _, err := editor.AddPage(args[1].Float(), args[2].Float()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
editor.SetHistoryLimit(100)
|
||||
return previewEditor(editor, args[3].Bool())
|
||||
}
|
||||
|
||||
// previewEditor 通过内存快照更新预览,不生成中间压缩包
|
||||
// 入参: editor 新建文档, annotations 是否显示注解
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func previewEditor(editor *ofdgo.Editor, annotations bool) (any, error) {
|
||||
reader, err := editor.Reader()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
session, err := newSession(reader, OpenOptions{RenderAnnotations: annotations})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if currentSession != nil {
|
||||
_ = currentSession.Close()
|
||||
}
|
||||
currentSession = session
|
||||
currentEditor = editor
|
||||
return editorSummary(), nil
|
||||
}
|
||||
|
||||
// updateText 修改文字内容、字体和字号,空字体数据沿用对象字体
|
||||
// 入参: args 页码、对象标识、内容、字体数据和字号
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func updateText(args []js.Value) (any, error) {
|
||||
if currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being created")
|
||||
}
|
||||
page, id := args[0].Int(), args[1].String()
|
||||
object, err := currentEditor.Object(page, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fontID := object.TextObject.Font
|
||||
if !args[3].IsNull() {
|
||||
data, err := bytesFromJS(args[3])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fontID, err = currentEditor.AddFont(FontFile{Data: data}, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := currentEditor.UpdateText(page, id, args[2].String(), fontID, args[4].Float()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||
}
|
||||
|
||||
// insertText 使用选定字体创建文字对象
|
||||
// 入参: args 页码、内容、字体数据、横纵坐标和字号
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func insertText(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
|
||||
}
|
||||
fontID, err := currentEditor.AddFont(FontFile{Data: data}, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
page := args[0].Int()
|
||||
content, err := currentSession.pageContent(page)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
box, err := currentSession.pageBox(page, content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
x, y := args[3].Float(), args[4].Float()
|
||||
box = ofdgo.Box{X: x, Y: y, W: box.W - x, H: box.H - y}
|
||||
if _, err := currentEditor.AddText(page, box, args[1].String(), fontID, args[5].Float()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||
}
|
||||
|
||||
// insertImage 按原始比例创建图片对象
|
||||
// 入参: args 页码、图片数据、横纵坐标和宽度
|
||||
// 返回: any 文档信息, error 错误信息
|
||||
func insertImage(args []js.Value) (any, error) {
|
||||
if currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being created")
|
||||
}
|
||||
data, err := bytesFromJS(args[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resourceID, err := currentEditor.AddImage(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config, _, err := image.DecodeConfig(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
width := args[4].Float()
|
||||
box := ofdgo.Box{X: args[2].Float(), Y: args[3].Float(), W: width, H: width * float64(config.Height) / float64(config.Width)}
|
||||
content, err := currentSession.pageContent(args[0].Int())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pageBox, err := currentSession.pageBox(args[0].Int(), content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if height := pageBox.H - box.Y*2; box.H > height {
|
||||
box.W *= height / box.H
|
||||
box.H = height
|
||||
}
|
||||
object := ofdgo.GraphicObject{Type: "ImageObject", ImageObject: ofdgo.ImageObject{
|
||||
ResourceID: resourceID, Boundary: fmt.Sprintf("%g %g %g %g", box.X, box.Y, box.W, box.H),
|
||||
}}
|
||||
if _, err := currentEditor.AddObject(args[0].Int(), object); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return previewEditor(currentEditor, currentSession.Renderer.RenderAnnotations)
|
||||
}
|
||||
|
||||
// saveDocument 分块写出新建文档
|
||||
// 入参: args 数据写出回调
|
||||
// 返回: any 保存结果, error 错误信息
|
||||
func saveDocument(args []js.Value) (any, error) {
|
||||
if currentEditor == nil {
|
||||
return nil, fmt.Errorf("no document is being created")
|
||||
}
|
||||
writer := bufio.NewWriterSize(exportWriter{write: args[0]}, 1<<20)
|
||||
if _, err := currentEditor.WriteTo(writer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := writer.Flush(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return successResult(map[string]any{"mime": "application/ofd", "label": "OFD"}), nil
|
||||
}
|
||||
@@ -219,6 +219,13 @@ func Open(data []byte, opts OpenOptions) (*Session, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return newSession(reader, opts)
|
||||
}
|
||||
|
||||
// newSession 从文档快照创建阅读会话
|
||||
// 入参: reader 文档读取器, opts 打开选项
|
||||
// 返回: *Session 文档会话, error 错误信息
|
||||
func newSession(reader *ofdgo.Reader, opts OpenOptions) (*Session, error) {
|
||||
doc, err := reader.Doc()
|
||||
if err != nil {
|
||||
reader.Close()
|
||||
|
||||
+89
-26
@@ -23,6 +23,7 @@ import (
|
||||
"fmt"
|
||||
"image"
|
||||
"math"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -35,13 +36,18 @@ import (
|
||||
// Editor 新建OFD文档,长度单位为毫米,页面索引从0开始,实例需串行使用
|
||||
// Info可修改文档元数据,通过方法管理页面、对象和资源,不修改已有OFD文件
|
||||
type Editor struct {
|
||||
Info DocInfo
|
||||
pages []PageContent
|
||||
resources []editorResource
|
||||
fonts map[string]*font.SFNT
|
||||
images map[string]bool
|
||||
resourceID map[editorResourceKey]string
|
||||
maxID int
|
||||
Info DocInfo
|
||||
pages []PageContent
|
||||
resources []editorResource
|
||||
fonts map[string]*font.SFNT
|
||||
images map[string]bool
|
||||
resourceID map[editorResourceKey]string
|
||||
maxID int
|
||||
history []editorChange
|
||||
historyIndex int
|
||||
historyLimit int
|
||||
revision uint64
|
||||
serial uint64
|
||||
}
|
||||
|
||||
// editorResource 文档内嵌资源
|
||||
@@ -124,7 +130,9 @@ func (e *Editor) AddPage(width, height float64) (int, error) {
|
||||
Area: PageArea{PhysicalBox: fmt.Sprintf("0 0 %s %s", ofdNumber(width), ofdNumber(height))},
|
||||
Content: Content{Layer: []Layer{{ID: e.nextID(), Type: "Body"}}},
|
||||
})
|
||||
return len(e.pages) - 1, nil
|
||||
index := len(e.pages) - 1
|
||||
e.recordPageAddition(index)
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// CopyPage 复制页面并追加到文档末尾,分配新标识,复用字体和图片资源
|
||||
@@ -164,16 +172,24 @@ func (e *Editor) CopyPage(index int) (int, error) {
|
||||
}
|
||||
}
|
||||
e.pages = append(e.pages, page)
|
||||
return len(e.pages) - 1, nil
|
||||
index = len(e.pages) - 1
|
||||
e.recordPageAddition(index)
|
||||
return index, nil
|
||||
}
|
||||
|
||||
// DeletePage 删除页面,不回收资源或复用标识,删除后页面索引随之变化
|
||||
// 入参: index 页面索引
|
||||
// 返回: error 错误信息
|
||||
func (e *Editor) DeletePage(index int) error {
|
||||
if _, err := e.page(index); err != nil {
|
||||
page, err := e.page(index)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if change := e.recordChange(); change != nil {
|
||||
saved := copyEditorPage(*page)
|
||||
change.undo = func(e *Editor) { e.pages = slices.Insert(e.pages, index, copyEditorPage(saved)) }
|
||||
change.redo = func(e *Editor) { e.pages = slices.Delete(e.pages, index, index+1) }
|
||||
}
|
||||
e.pages = slices.Delete(e.pages, index, index+1)
|
||||
return nil
|
||||
}
|
||||
@@ -188,13 +204,14 @@ func (e *Editor) MovePage(from, to int) error {
|
||||
if _, err := e.page(to); err != nil {
|
||||
return err
|
||||
}
|
||||
page := e.pages[from]
|
||||
if from < to {
|
||||
copy(e.pages[from:to], e.pages[from+1:to+1])
|
||||
} else {
|
||||
copy(e.pages[to+1:from+1], e.pages[to:from])
|
||||
if from == to {
|
||||
return nil
|
||||
}
|
||||
moveEditorItem(e.pages, from, to)
|
||||
if change := e.recordChange(); change != nil {
|
||||
change.undo = func(e *Editor) { moveEditorItem(e.pages, to, from) }
|
||||
change.redo = func(e *Editor) { moveEditorItem(e.pages, from, to) }
|
||||
}
|
||||
e.pages[to] = page
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -209,7 +226,15 @@ func (e *Editor) ResizePage(index int, width, height float64) error {
|
||||
if !finite(width) || !finite(height) || width <= 0 || height <= 0 {
|
||||
return fmt.Errorf("page dimensions must be finite and positive")
|
||||
}
|
||||
page.Area.PhysicalBox = fmt.Sprintf("0 0 %s %s", ofdNumber(width), ofdNumber(height))
|
||||
before, after := page.Area.PhysicalBox, fmt.Sprintf("0 0 %s %s", ofdNumber(width), ofdNumber(height))
|
||||
if before == after {
|
||||
return nil
|
||||
}
|
||||
page.Area.PhysicalBox = after
|
||||
if change := e.recordChange(); change != nil {
|
||||
change.undo = func(e *Editor) { e.pages[index].Area.PhysicalBox = before }
|
||||
change.redo = func(e *Editor) { e.pages[index].Area.PhysicalBox = after }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -337,6 +362,17 @@ func (e *Editor) AddObject(page int, object GraphicObject) (string, error) {
|
||||
}
|
||||
e.maxID++
|
||||
content.Content.Layer[0].Objects = append(content.Content.Layer[0].Objects, object)
|
||||
if change := e.recordChange(); change != nil {
|
||||
index := len(content.Content.Layer[0].Objects) - 1
|
||||
change.undo = func(e *Editor) {
|
||||
layer := &e.pages[page].Content.Layer[0]
|
||||
layer.Objects = slices.Delete(layer.Objects, index, index+1)
|
||||
}
|
||||
change.redo = func(e *Editor) {
|
||||
layer := &e.pages[page].Content.Layer[0]
|
||||
layer.Objects = slices.Insert(layer.Objects, index, object)
|
||||
}
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
@@ -355,7 +391,7 @@ func (e *Editor) Object(page int, id string) (GraphicObject, error) {
|
||||
// 入参: page 页面索引, id 对象标识, object 新内容,忽略其ID且不引用调用方的可变数据
|
||||
// 返回: error 错误信息
|
||||
func (e *Editor) UpdateObject(page int, id string, object GraphicObject) error {
|
||||
layer, index, err := e.findObject(page, id)
|
||||
_, index, err := e.findObject(page, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -363,7 +399,7 @@ func (e *Editor) UpdateObject(page int, id string, object GraphicObject) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
layer.Objects[index] = object
|
||||
e.replaceObject(page, index, object)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -375,6 +411,17 @@ func (e *Editor) DeleteObject(page int, id string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if change := e.recordChange(); change != nil {
|
||||
object := layer.Objects[index]
|
||||
change.undo = func(e *Editor) {
|
||||
layer := &e.pages[page].Content.Layer[0]
|
||||
layer.Objects = slices.Insert(layer.Objects, index, object)
|
||||
}
|
||||
change.redo = func(e *Editor) {
|
||||
layer := &e.pages[page].Content.Layer[0]
|
||||
layer.Objects = slices.Delete(layer.Objects, index, index+1)
|
||||
}
|
||||
}
|
||||
layer.Objects = slices.Delete(layer.Objects, index, index+1)
|
||||
return nil
|
||||
}
|
||||
@@ -390,16 +437,32 @@ func (e *Editor) MoveObject(page int, id string, to int) error {
|
||||
if to < 0 || to >= len(layer.Objects) {
|
||||
return fmt.Errorf("object index %d out of range", to)
|
||||
}
|
||||
object := layer.Objects[from]
|
||||
if from < to {
|
||||
copy(layer.Objects[from:to], layer.Objects[from+1:to+1])
|
||||
} else {
|
||||
copy(layer.Objects[to+1:from+1], layer.Objects[to:from])
|
||||
if from == to {
|
||||
return nil
|
||||
}
|
||||
moveEditorItem(layer.Objects, from, to)
|
||||
if change := e.recordChange(); change != nil {
|
||||
change.undo = func(e *Editor) { moveEditorItem(e.pages[page].Content.Layer[0].Objects, to, from) }
|
||||
change.redo = func(e *Editor) { moveEditorItem(e.pages[page].Content.Layer[0].Objects, from, to) }
|
||||
}
|
||||
layer.Objects[to] = object
|
||||
return nil
|
||||
}
|
||||
|
||||
// replaceObject 提交已校验的对象并记录前后内容
|
||||
// 入参: page 页面索引, index 对象索引, object 新内容
|
||||
func (e *Editor) replaceObject(page, index int, object GraphicObject) {
|
||||
layer := &e.pages[page].Content.Layer[0]
|
||||
before := layer.Objects[index]
|
||||
if reflect.DeepEqual(before, object) {
|
||||
return
|
||||
}
|
||||
layer.Objects[index] = object
|
||||
if change := e.recordChange(); change != nil {
|
||||
change.undo = func(e *Editor) { e.pages[page].Content.Layer[0].Objects[index] = before }
|
||||
change.redo = func(e *Editor) { e.pages[page].Content.Layer[0].Objects[index] = object }
|
||||
}
|
||||
}
|
||||
|
||||
// findObject 查找正文图层中的对象
|
||||
// 入参: page 页面索引, id 对象标识
|
||||
// 返回: *Layer 所属图层, int 对象索引, error 错误信息
|
||||
@@ -585,7 +648,7 @@ func (e *Editor) UpdateText(page int, id, value, fontID string, size float64) er
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
layer.Objects[index] = object
|
||||
e.replaceObject(page, index, object)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
// 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 "slices"
|
||||
|
||||
// editorChange 页面或对象操作及其修订标识
|
||||
type editorChange struct {
|
||||
undo func(*Editor)
|
||||
redo func(*Editor)
|
||||
before uint64
|
||||
after uint64
|
||||
}
|
||||
|
||||
// SetHistoryLimit 设置撤销和重做记录的总上限,默认关闭,非正数关闭并释放记录
|
||||
// 缩减上限时优先保留最近的撤销记录,不影响当前文档及修订标识
|
||||
// 记录页面和对象操作,不包含Info的直接修改及字体、图片注册,资源继续共享
|
||||
// 入参: limit 最大记录数
|
||||
func (e *Editor) SetHistoryLimit(limit int) {
|
||||
e.historyLimit = max(0, limit)
|
||||
if e.historyLimit == 0 {
|
||||
e.history = nil
|
||||
e.historyIndex = 0
|
||||
return
|
||||
}
|
||||
if len(e.history) > e.historyLimit {
|
||||
start := max(0, e.historyIndex-e.historyLimit)
|
||||
end := min(len(e.history), start+e.historyLimit)
|
||||
e.history = slices.Clone(e.history[start:end])
|
||||
e.historyIndex -= start
|
||||
}
|
||||
}
|
||||
|
||||
// CanUndo 判断是否有可撤销的操作
|
||||
// 返回: bool 是否可以撤销
|
||||
func (e *Editor) CanUndo() bool {
|
||||
return e.historyIndex > 0
|
||||
}
|
||||
|
||||
// CanRedo 判断是否有可重做的操作
|
||||
// 返回: bool 是否可以重做
|
||||
func (e *Editor) CanRedo() bool {
|
||||
return e.historyIndex < len(e.history)
|
||||
}
|
||||
|
||||
// Undo 撤销最近一次页面或对象操作,保留资源和已分配的标识
|
||||
// 返回: bool 是否执行了撤销
|
||||
func (e *Editor) Undo() bool {
|
||||
if !e.CanUndo() {
|
||||
return false
|
||||
}
|
||||
e.historyIndex--
|
||||
change := e.history[e.historyIndex]
|
||||
change.undo(e)
|
||||
e.revision = change.before
|
||||
return true
|
||||
}
|
||||
|
||||
// Redo 重做最近一次撤销的操作,新的有效修改会清除重做记录
|
||||
// 返回: bool 是否执行了重做
|
||||
func (e *Editor) Redo() bool {
|
||||
if !e.CanRedo() {
|
||||
return false
|
||||
}
|
||||
change := e.history[e.historyIndex]
|
||||
change.redo(e)
|
||||
e.historyIndex++
|
||||
e.revision = change.after
|
||||
return true
|
||||
}
|
||||
|
||||
// Revision 获取页面和对象的当前修订标识,撤销或重做时恢复对应标识
|
||||
// Info的直接修改和资源注册不计入修订,标识仅在当前Editor实例内有效
|
||||
// 返回: uint64 修订标识
|
||||
func (e *Editor) Revision() uint64 {
|
||||
return e.revision
|
||||
}
|
||||
|
||||
// recordChange 分配修订标识并为已完成的有效修改预留记录
|
||||
// 返回: *editorChange 记录,未启用历史时为nil
|
||||
func (e *Editor) recordChange() *editorChange {
|
||||
e.serial++
|
||||
change := editorChange{before: e.revision, after: e.serial}
|
||||
e.revision = e.serial
|
||||
if e.historyLimit == 0 {
|
||||
return nil
|
||||
}
|
||||
clear(e.history[e.historyIndex:])
|
||||
e.history = e.history[:e.historyIndex]
|
||||
if len(e.history) == e.historyLimit {
|
||||
copy(e.history, e.history[1:])
|
||||
e.history = e.history[:len(e.history)-1]
|
||||
}
|
||||
e.history = append(e.history, change)
|
||||
e.historyIndex = len(e.history)
|
||||
return &e.history[e.historyIndex-1]
|
||||
}
|
||||
|
||||
// recordPageAddition 记录新增或复制的页面
|
||||
// 入参: index 页面索引
|
||||
func (e *Editor) recordPageAddition(index int) {
|
||||
if change := e.recordChange(); change != nil {
|
||||
page := copyEditorPage(e.pages[index])
|
||||
change.undo = func(e *Editor) { e.pages = slices.Delete(e.pages, index, index+1) }
|
||||
change.redo = func(e *Editor) { e.pages = slices.Insert(e.pages, index, copyEditorPage(page)) }
|
||||
}
|
||||
}
|
||||
|
||||
// copyEditorPage 复制页面容器,共享经校验且按替换方式更新的对象数据
|
||||
// 入参: page 页面
|
||||
// 返回: PageContent 独立页面容器
|
||||
func copyEditorPage(page PageContent) PageContent {
|
||||
page.Content.Layer = slices.Clone(page.Content.Layer)
|
||||
for i := range page.Content.Layer {
|
||||
page.Content.Layer[i].Objects = slices.Clone(page.Content.Layer[i].Objects)
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
// moveEditorItem 按目标索引移动页面或对象
|
||||
// 入参: items 页面或对象序列, from 原索引, to 目标索引
|
||||
func moveEditorItem[T any](items []T, from, to int) {
|
||||
item := items[from]
|
||||
if from < to {
|
||||
copy(items[from:to], items[from+1:to+1])
|
||||
} else {
|
||||
copy(items[to+1:from+1], items[to:from])
|
||||
}
|
||||
items[to] = item
|
||||
}
|
||||
Reference in new issue
Block a user